From b3ad47a507ef03fdf3a1dffb56d92d6d4f627c87 Mon Sep 17 00:00:00 2001 From: F4zii Date: Tue, 18 Feb 2020 17:33:07 +0200 Subject: Feature: suggest command usage for misspelt commands --- bot/cogs/tags.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 54a51921c..5f7c810b8 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -12,6 +12,8 @@ from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role from bot.pagination import LinePaginator +from fuzzywuzzy import process + log = logging.getLogger(__name__) TEST_CHANNELS = ( @@ -138,6 +140,23 @@ class Tags(Cog): title='Did you mean ...', description='\n'.join(tag['title'] for tag in founds[:10]) )) + else: + # No similar tag found, searching for a similar command + command_name = ctx.invoked_with + raw_commands = [cmd.name for cmd in self.bot.walk_commands()] + similar_command_data = process.extractOne(command_name, raw_commands) + # The match is not very similar, no need to suggest it + log.debug(similar_command_data) + if similar_command_data[1] < 65: + log.debug("No similar commands found") + return + similar_command = self.bot.get_command(similar_command_data[0]) + if similar_command.can_run(ctx): + misspelled_content = ctx.message.content + await ctx.send( + f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command.name)}**" + ) + return else: tags = self._cache.values() -- cgit v1.2.3 From dfe9dd8c4972e1a9e7cab1af0f0e33c44871893d Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 20:39:32 +0200 Subject: Seperated tags.get_command into a function and a wrapper Updated --- bot/cogs/tags.py | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 5f7c810b8..61cf55d65 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -12,8 +12,6 @@ from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role from bot.pagination import LinePaginator -from fuzzywuzzy import process - log = logging.getLogger(__name__) TEST_CHANNELS = ( @@ -94,9 +92,9 @@ class Tags(Cog): """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) -> None: - """Get a specified tag, or a list of all tags if no tag is specified.""" + async def _get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + log.debug(self, ctx, tag_name) + def _command_on_cooldown(tag_name: str) -> bool: """ Check if the command is currently on cooldown, on a per-tag, per-channel basis. @@ -120,7 +118,7 @@ class Tags(Cog): time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name]["time"]) log.warning(f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " f"Cooldown ends in {time_left:.1f} seconds.") - return + return False await self._get_tags() @@ -135,28 +133,13 @@ class Tags(Cog): "channel": ctx.channel.id } await ctx.send(embed=Embed.from_dict(tag['embed'])) + return True elif founds and len(tag_name) >= 3: await ctx.send(embed=Embed( title='Did you mean ...', description='\n'.join(tag['title'] for tag in founds[:10]) )) - else: - # No similar tag found, searching for a similar command - command_name = ctx.invoked_with - raw_commands = [cmd.name for cmd in self.bot.walk_commands()] - similar_command_data = process.extractOne(command_name, raw_commands) - # The match is not very similar, no need to suggest it - log.debug(similar_command_data) - if similar_command_data[1] < 65: - log.debug("No similar commands found") - return - similar_command = self.bot.get_command(similar_command_data[0]) - if similar_command.can_run(ctx): - misspelled_content = ctx.message.content - await ctx.send( - f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command.name)}**" - ) - return + return True else: tags = self._cache.values() @@ -165,6 +148,7 @@ class Tags(Cog): description="**There are no tags in the database!**", colour=Colour.red() )) + return True else: embed: Embed = Embed(title="**Current tags**") await LinePaginator.paginate( @@ -175,6 +159,14 @@ class Tags(Cog): empty=False, max_lines=15 ) + return True + + return False + + @tags_group.command(name='get', aliases=('show', 'g')) + 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.""" + await self._get_command(ctx, tag_name) @tags_group.command(name='set', aliases=('add', 's')) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From eb01f3f54a4a3c77f927769883af7d18efb8931c Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 20:40:25 +0200 Subject: Feature: suggest command usage for misspelt commands Migration to error_handler.py Suggesting misspelt commands, in progress --- bot/cogs/error_handler.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 52893b2ee..13221799b 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -1,4 +1,5 @@ -import contextlib +# import contextlib +import difflib import logging from discord.ext.commands import ( @@ -75,7 +76,7 @@ class ErrorHandler(Cog): if not ctx.channel.id == Channels.verification: tags_get_command = self.bot.get_command("tags get") ctx.invoked_from_error_handler = True - + command_name = ctx.invoked_with log_msg = "Cancelling attempt to fall back to a tag due to failed checks." try: if not await tags_get_command.can_run(ctx): @@ -87,9 +88,31 @@ class ErrorHandler(Cog): return # Return to not raise the exception - with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + log.debug("Calling...") + tags_cog = self.bot.get_cog("Tags") + sent = await tags_cog._get_command(ctx, command_name) + # sent = await tags_get_command.callback(tags_get_command.cog, ctx, ctx.invoked_with) + if sent: + log.debug("Found") return + # No similar tag found, or tag on cooldown - + # searching for a similar command + log.debug("Not Found") + raw_commands = [ + (cmd.name, *cmd.aliases) + for cmd in self.bot.walk_commands() + if not cmd.hidden + ] + raw_commands = [c for data in raw_commands for c in data] + similar_command_data = difflib.get_close_matches(command_name, raw_commands, 1) + log.debug(similar_command_data) + similar_command = self.bot.get_command(similar_command_data[0]) + if similar_command.can_run(ctx): + misspelled_content = ctx.message.content + await ctx.send( + f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command.name)}**" + ) + elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) -- cgit v1.2.3 From f192e02d5c3c1511107e683aac22235825a33806 Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 21:02:55 +0200 Subject: Deletion of the consuming rest (*) used in _get_command Since its used to get the input of the command, its not needed again In the function (callback) call --- bot/cogs/tags.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 61cf55d65..65d25124a 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -92,8 +92,7 @@ class Tags(Cog): """Show all known tags, a single tag, or run a subcommand.""" await ctx.invoke(self.get_command, tag_name=tag_name) - async def _get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - log.debug(self, ctx, tag_name) + async def _get_command(self, ctx: Context, tag_name: TagNameConverter = None) -> None: def _command_on_cooldown(tag_name: str) -> bool: """ -- cgit v1.2.3 From 8599c68ce7544520d50419ba9afe0f2b0b6fc63e Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 21:03:45 +0200 Subject: Delete_after added for suggestion message preventing message spam by the bot when commands are misspelt --- 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 13221799b..1a1914b93 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -110,7 +110,8 @@ class ErrorHandler(Cog): if similar_command.can_run(ctx): misspelled_content = ctx.message.content await ctx.send( - f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command.name)}**" + f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command.name)}**", + delete_after=7.0 ) elif isinstance(e, BadArgument): -- cgit v1.2.3 From 1625c18265192e9560b443a574e564241b6f6883 Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 21:12:49 +0200 Subject: Suggest aliases when invoked instead of full command name When found a similar command, the bot sent a suggestion about the full command name This was fixed by saving the matching alias and sending it instead of sending the full command name --- bot/cogs/error_handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 1a1914b93..fb5f59ebc 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -105,12 +105,12 @@ class ErrorHandler(Cog): ] raw_commands = [c for data in raw_commands for c in data] similar_command_data = difflib.get_close_matches(command_name, raw_commands, 1) - log.debug(similar_command_data) - similar_command = self.bot.get_command(similar_command_data[0]) + similar_command_name = similar_command_data[0] + similar_command = self.bot.get_command(similar_command_name) if similar_command.can_run(ctx): misspelled_content = ctx.message.content await ctx.send( - f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command.name)}**", + f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command_name)}**", delete_after=7.0 ) -- cgit v1.2.3 From 21a3486b21e4f28bdfad24d7ebfa7e88b24a3140 Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 21:20:39 +0200 Subject: Removal of unused logs --- bot/cogs/error_handler.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index fb5f59ebc..ba27d8e65 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -88,16 +88,13 @@ class ErrorHandler(Cog): return # Return to not raise the exception - log.debug("Calling...") tags_cog = self.bot.get_cog("Tags") sent = await tags_cog._get_command(ctx, command_name) # sent = await tags_get_command.callback(tags_get_command.cog, ctx, ctx.invoked_with) if sent: - log.debug("Found") return # No similar tag found, or tag on cooldown - # searching for a similar command - log.debug("Not Found") raw_commands = [ (cmd.name, *cmd.aliases) for cmd in self.bot.walk_commands() -- cgit v1.2.3 From 337374ed2b3e10db6f34f378a73cc6d7c0d1f73f Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 23:18:24 +0200 Subject: Rearrange, exception and NoneTypes handling --- bot/cogs/error_handler.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index ba27d8e65..56294e8f9 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -1,4 +1,4 @@ -# import contextlib +import contextlib import difflib import logging @@ -74,9 +74,13 @@ class ErrorHandler(Cog): # 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: + command_name = ctx.invoked_with tags_get_command = self.bot.get_command("tags get") + tags_cog = self.bot.get_cog("Tags") + if not all(tags_cog, tags_get_command): + return + ctx.invoked_from_error_handler = True - command_name = ctx.invoked_with log_msg = "Cancelling attempt to fall back to a tag due to failed checks." try: if not await tags_get_command.can_run(ctx): @@ -87,12 +91,10 @@ class ErrorHandler(Cog): await self.on_command_error(ctx, tag_error) return - # Return to not raise the exception - tags_cog = self.bot.get_cog("Tags") - sent = await tags_cog._get_command(ctx, command_name) - # sent = await tags_get_command.callback(tags_get_command.cog, ctx, ctx.invoked_with) + sent = await tags_cog.get_command(ctx, command_name) if sent: return + # No similar tag found, or tag on cooldown - # searching for a similar command raw_commands = [ @@ -104,12 +106,14 @@ class ErrorHandler(Cog): similar_command_data = difflib.get_close_matches(command_name, raw_commands, 1) similar_command_name = similar_command_data[0] similar_command = self.bot.get_command(similar_command_name) - if similar_command.can_run(ctx): - misspelled_content = ctx.message.content - await ctx.send( - f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command_name)}**", - delete_after=7.0 - ) + + with contextlib.suppress(CommandError): + if similar_command.can_run(ctx): + misspelled_content = ctx.message.content + await ctx.send( + f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command_name)}**", + delete_after=7.0 + ) elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") -- cgit v1.2.3 From e570b2960839e696a5f57a7acdd968d766894cf4 Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 23:19:05 +0200 Subject: Rearrange, Function naming and Docstrings --- bot/cogs/tags.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 65d25124a..4ee40a7e3 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -92,7 +92,18 @@ class Tags(Cog): """Show all known tags, a single tag, or run a subcommand.""" await ctx.invoke(self.get_command, tag_name=tag_name) - async def _get_command(self, ctx: Context, tag_name: TagNameConverter = None) -> None: + async def get_command(self, ctx: Context, tag_name: str = None) -> bool: + """ + Shows a specific tag or a suggestion within a given context. + + params: + ctx - a Context object , being used to send and choose the right tag + tag_name - a string representing the searched tag name + + Returns False if no tag is not found or the tag is on cooldown + + Return True if found and sent a tag + """ def _command_on_cooldown(tag_name: str) -> bool: """ @@ -160,12 +171,12 @@ class Tags(Cog): ) return True - return False + return True @tags_group.command(name='get', aliases=('show', 'g')) - 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.""" - await self._get_command(ctx, tag_name) + async def _get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Get a specified tag, or a list of related tags if no tag is specified.""" + await self.get_command(ctx, tag_name) @tags_group.command(name='set', aliases=('add', 's')) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 72be687ccad32558f7bafeb81813b1272d5b472b Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 23:48:25 +0200 Subject: Rearrange, NoneTypes handling --- bot/cogs/error_handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 56294e8f9..cf514e5c6 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -74,13 +74,13 @@ class ErrorHandler(Cog): # 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: - command_name = ctx.invoked_with - tags_get_command = self.bot.get_command("tags get") tags_cog = self.bot.get_cog("Tags") - if not all(tags_cog, tags_get_command): + tags_get_command = self.bot.get_command("tags get") + if not tags_cog and not tags_get_command: return ctx.invoked_from_error_handler = True + command_name = ctx.invoked_with log_msg = "Cancelling attempt to fall back to a tag due to failed checks." try: if not await tags_get_command.can_run(ctx): -- cgit v1.2.3 From 4ebefe42aca23532233136b0fd51e4ef9633d593 Mon Sep 17 00:00:00 2001 From: F4zii Date: Wed, 19 Feb 2020 23:50:49 +0200 Subject: Return Logic Fixed The returned value was changed from True to False If we didn't return True (sent the tag) throughout the code It means we didn't send anything, we should return False --- bot/cogs/tags.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 4ee40a7e3..8082a5fb7 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -101,7 +101,6 @@ class Tags(Cog): tag_name - a string representing the searched tag name Returns False if no tag is not found or the tag is on cooldown - Return True if found and sent a tag """ @@ -171,7 +170,7 @@ class Tags(Cog): ) return True - return True + return False @tags_group.command(name='get', aliases=('show', 'g')) async def _get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: -- cgit v1.2.3 From aceeba62363a9e2166bd8d3c897ffefdac9923bf Mon Sep 17 00:00:00 2001 From: F4zii Date: Thu, 20 Feb 2020 07:35:06 +0200 Subject: Rearrange - Function naming and Docstrings --- 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 cf514e5c6..a951dc712 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -91,7 +91,7 @@ class ErrorHandler(Cog): await self.on_command_error(ctx, tag_error) return - sent = await tags_cog.get_command(ctx, command_name) + sent = await tags_cog.display_tag(ctx, command_name) if sent: return -- cgit v1.2.3 From d0c3a0795c2490b7525646982c8d7d2b3c6dd755 Mon Sep 17 00:00:00 2001 From: F4zii Date: Thu, 20 Feb 2020 07:39:39 +0200 Subject: Rearrange - Function naming and Docstrings get_command was changed to - display_tag, the name didn't fit, since its not the command itself. command_on_cooldown was taken out of display_tag to get the option to reuse it in another scope Docstrings modified --- Pipfile.lock | 134 +++++++++++++++++++++++-------------------------------- bot/cogs/tags.py | 52 +++++++++++---------- 2 files changed, 81 insertions(+), 105 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index bf8ff47e9..9e09260f1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:a5837277e53755078db3a9e8c45bbca605c8ba9ecba7a02d74a7a1779f444723", - "sha256:fa32e33b4b7d0804dcf439ae6ff24d2f0a83d1ba280ee9f555e647d71d394ff5" + "sha256:4199122a450dffd8303b7857a9d82657bf1487fe329e489520833b40fbe92406", + "sha256:fe85c7456e5c060bce4eb9cffab5b2c4d3c563cb72177977b3556c54c8e3aeb6" ], "index": "pypi", - "version": "==6.4.1" + "version": "==6.5.2" }, "aiodns": { "hashes": [ @@ -52,10 +52,10 @@ }, "aiormq": { "hashes": [ - "sha256:8c215a970133ab5ee7c478decac55b209af7731050f52d11439fe910fa0f9e9d", - "sha256:9210f3389200aee7d8067f6435f4a9eff2d3a30b88beb5eaae406ccc11c0fc01" + "sha256:286e0b0772075580466e45f98f051b9728a9316b9c36f0c14c7bc1409be375b0", + "sha256:7ed7d6df6b57af7f8bce7d1ebcbdfc32b676192e46703e81e9e217316e56b5bd" ], - "version": "==3.2.0" + "version": "==3.2.1" }, "alabaster": { "hashes": [ @@ -164,18 +164,18 @@ }, "fuzzywuzzy": { "hashes": [ - "sha256:5ac7c0b3f4658d2743aa17da53a55598144edbc5bee3c6863840636e6926f254", - "sha256:6f49de47db00e1c71d40ad16da42284ac357936fa9b66bea1df63fed07122d62" + "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8", + "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993" ], "index": "pypi", - "version": "==0.17.0" + "version": "==0.18.0" }, "idna": { "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" ], - "version": "==2.8" + "version": "==2.9" }, "imagesize": { "hashes": [ @@ -420,11 +420,11 @@ }, "requests": { "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" ], "index": "pypi", - "version": "==2.22.0" + "version": "==2.23.0" }, "six": { "hashes": [ @@ -449,11 +449,11 @@ }, "sphinx": { "hashes": [ - "sha256:298537cb3234578b2d954ff18c5608468229e116a9757af3b831c2b2b4819159", - "sha256:e6e766b74f85f37a5f3e0773a1e1be8db3fcb799deb58ca6d18b70b0b44542a5" + "sha256:525527074f2e0c2585f68f73c99b4dc257c34bbe308b27f5f8c7a6e20642742f", + "sha256:543d39db5f82d83a5c1aa0c10c88f2b6cff2da3e711aa849b2c627b4b403bbd9" ], "index": "pypi", - "version": "==2.3.1" + "version": "==2.4.2" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -556,6 +556,13 @@ } }, "develop": { + "appdirs": { + "hashes": [ + "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", + "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" + ], + "version": "==1.4.3" + }, "aspy.yaml": { "hashes": [ "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc", @@ -579,10 +586,10 @@ }, "cfgv": { "hashes": [ - "sha256:edb387943b665bf9c434f717bf630fa78aecd53d5900d2e05da6ad6048553144", - "sha256:fbd93c9ab0a523bf7daec408f3be2ed99a980e20b2d19b50fc184ca6b820d289" + "sha256:04b093b14ddf9fd4d17c53ebfd55582d27b76ed30050193c14e560770c5360eb", + "sha256:f22b426ed59cd2ab2b54ff96608d846c33dfb8766a67f0b4a6ce130ce244414f" ], - "version": "==2.0.1" + "version": "==3.0.0" }, "chardet": { "hashes": [ @@ -636,6 +643,12 @@ "index": "pypi", "version": "==4.5.4" }, + "distlib": { + "hashes": [ + "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21" + ], + "version": "==0.3.0" + }, "dodgy": { "hashes": [ "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a", @@ -658,6 +671,13 @@ ], "version": "==0.3" }, + "filelock": { + "hashes": [ + "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", + "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" + ], + "version": "==3.0.12" + }, "flake8": { "hashes": [ "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", @@ -668,11 +688,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:05b85538014c850a86dce7374bb6621c64481c24e35e8e90af1315f4d7a3dbaa", - "sha256:43e5233a76fda002b91a54a7cc4510f099c4bfd6279502ec70164016250eebd1" + "sha256:47705be09c6e56e9e3ac1656e8f5ed70862a4657116dc472f5a56c1bdc5172b1", + "sha256:564702ace354e1059252755be79d082a70ae1851c86044ae1a96d0f5453280e9" ], "index": "pypi", - "version": "==1.1.3" + "version": "==1.2.0" }, "flake8-bugbear": { "hashes": [ @@ -700,11 +720,11 @@ }, "flake8-string-format": { "hashes": [ - "sha256:68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2", - "sha256:774d56103d9242ed968897455ef49b7d6de272000cfa83de5814273a868832f1" + "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2", + "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af" ], "index": "pypi", - "version": "==0.2.3" + "version": "==0.3.0" }, "flake8-tidy-imports": { "hashes": [ @@ -730,18 +750,10 @@ }, "idna": { "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" ], - "version": "==2.8" - }, - "importlib-metadata": { - "hashes": [ - "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", - "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" - ], - "markers": "python_version < '3.8'", - "version": "==1.5.0" + "version": "==2.9" }, "mccabe": { "hashes": [ @@ -818,11 +830,11 @@ }, "requests": { "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" ], "index": "pypi", - "version": "==2.22.0" + "version": "==2.23.0" }, "safety": { "hashes": [ @@ -853,33 +865,6 @@ ], "version": "==0.10.0" }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "markers": "python_version < '3.8'", - "version": "==1.4.1" - }, "unittest-xml-reporting": { "hashes": [ "sha256:358bbdaf24a26d904cc1c26ef3078bca7fc81541e0a54c8961693cc96a6f35e0", @@ -898,17 +883,10 @@ }, "virtualenv": { "hashes": [ - "sha256:0d62c70883c0342d59c11d0ddac0d954d0431321a41ab20851facf2b222598f3", - "sha256:55059a7a676e4e19498f1aad09b8313a38fcc0cdbe4fdddc0e9b06946d21b4bb" - ], - "version": "==16.7.9" - }, - "zipp": { - "hashes": [ - "sha256:ccc94ed0909b58ffe34430ea5451f07bc0c76467d7081619a454bf5c98b89e28", - "sha256:feae2f18633c32fc71f2de629bfb3bd3c9325cd4419642b1f1da42ee488d9b98" + "sha256:08f3623597ce73b85d6854fb26608a6f39ee9d055c81178dc6583803797f8994", + "sha256:de2cbdd5926c48d7b84e0300dea9e8f276f61d186e8e49223d71d91250fbaebd" ], - "version": "==2.1.0" + "version": "==20.0.4" } } } diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 8082a5fb7..5124fb5e7 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -92,38 +92,36 @@ class Tags(Cog): """Show all known tags, a single tag, or run a subcommand.""" await ctx.invoke(self.get_command, tag_name=tag_name) - async def get_command(self, ctx: Context, tag_name: str = None) -> bool: + def command_on_cooldown(self, ctx: Context, tag_name: str) -> bool: """ - Shows a specific tag or a suggestion within a given context. + Check if the command is currently on cooldown, on a per-tag, per-channel basis. - params: - ctx - a Context object , being used to send and choose the right tag - tag_name - a string representing the searched tag name - - Returns False if no tag is not found or the tag is on cooldown - Return True if found and sent a tag + The cooldown duration is set in constants.py. """ + now = time.time() - def _command_on_cooldown(tag_name: str) -> bool: - """ - Check if the command is currently on cooldown, on a per-tag, per-channel basis. + cooldown_conditions = ( + tag_name + and tag_name in self.tag_cooldowns + and (now - self.tag_cooldowns[tag_name]["time"]) < Cooldowns.tags + and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id + ) - The cooldown duration is set in constants.py. - """ - now = time.time() + if cooldown_conditions: + return True + return False - cooldown_conditions = ( - tag_name - and tag_name in self.tag_cooldowns - and (now - self.tag_cooldowns[tag_name]["time"]) < Cooldowns.tags - and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id - ) + async def display_tag(self, ctx: Context, tag_name: str = None) -> bool: + """ + Show contents of the tag `tag_name` in `ctx` and return True if something is shown. - if cooldown_conditions: - return True - return False + If a tag is not found, display similar tag names as suggestions. If a tag is not specified, + display a paginated embed of all tags. - if _command_on_cooldown(tag_name): + Tags are on cooldowns on a per-tag, per-channel basis. If a tag is on cooldown, display + nothing and return False. + """ + if self.command_on_cooldown(ctx, tag_name): time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name]["time"]) log.warning(f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " f"Cooldown ends in {time_left:.1f} seconds.") @@ -173,9 +171,9 @@ class Tags(Cog): return False @tags_group.command(name='get', aliases=('show', 'g')) - async def _get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - """Get a specified tag, or a list of related tags if no tag is specified.""" - await self.get_command(ctx, tag_name) + 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.""" + await self.display_tag(ctx, tag_name) @tags_group.command(name='set', aliases=('add', 's')) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From ec1ffabc926a3a6f368f024428aa57eefa925476 Mon Sep 17 00:00:00 2001 From: F4zi <44242259+F4zi780@users.noreply.github.com> Date: Thu, 20 Feb 2020 22:08:08 +0200 Subject: Command suggestion message reformat. The message sent when suggesting a command is now wrapped inside an embed --- bot/cogs/error_handler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index a951dc712..39b868e62 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -2,6 +2,7 @@ import contextlib import difflib import logging +from discord import Embed from discord.ext.commands import ( BadArgument, BotMissingPermissions, @@ -19,7 +20,7 @@ from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels +from bot.constants import Channels, Icons from bot.decorators import InChannelCheckFailure log = logging.getLogger(__name__) @@ -110,8 +111,11 @@ class ErrorHandler(Cog): with contextlib.suppress(CommandError): if similar_command.can_run(ctx): misspelled_content = ctx.message.content + e = Embed() + e.set_author(name="Did you mean:", icon_url=Icons.questionmark) + e.description = f"{misspelled_content.replace(command_name, similar_command_name)}" await ctx.send( - f"Did you mean:\n**{misspelled_content.replace(command_name, similar_command_name)}**", + embed=e, delete_after=7.0 ) -- cgit v1.2.3 From 7fd9225d964ff90f972afd1e780ebb26df1a365c Mon Sep 17 00:00:00 2001 From: F4zii Date: Fri, 21 Feb 2020 21:10:00 +0200 Subject: Handling and logging CommandError the CommandError exception was ignored, We now catch the exception to handle and log the event Also added a little code reformat --- bot/cogs/error_handler.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 39b868e62..19d6e6946 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -108,16 +108,20 @@ class ErrorHandler(Cog): similar_command_name = similar_command_data[0] similar_command = self.bot.get_command(similar_command_name) - with contextlib.suppress(CommandError): - if similar_command.can_run(ctx): - misspelled_content = ctx.message.content - e = Embed() - e.set_author(name="Did you mean:", icon_url=Icons.questionmark) - e.description = f"{misspelled_content.replace(command_name, similar_command_name)}" - await ctx.send( - embed=e, - delete_after=7.0 - ) + log_msg = "Cancelling attempt to suggest a command due to failed checks." + try: + if not similar_command.can_run(ctx): + log.debug(log_msg) + return + except CommandError as cmd_error: + log.debug(log_msg) + await self.on_command_error(ctx, cmd_error) + + misspelled_content = ctx.message.content + e = Embed() + e.set_author(name="Did you mean:", icon_url=Icons.questionmark) + e.description = f"{misspelled_content.replace(command_name, similar_command_name)}" + await ctx.send(embed=e, delete_after=7.0) elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") -- cgit v1.2.3 From 7eb1acd41bddf0377a3e211a87962e3af87be07e Mon Sep 17 00:00:00 2001 From: F4zii Date: Fri, 21 Feb 2020 21:11:14 +0200 Subject: Only replace the misspelt command, and not the whole occurrences of it --- 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 19d6e6946..a642c3b7e 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -120,7 +120,7 @@ class ErrorHandler(Cog): misspelled_content = ctx.message.content e = Embed() e.set_author(name="Did you mean:", icon_url=Icons.questionmark) - e.description = f"{misspelled_content.replace(command_name, similar_command_name)}" + e.description = f"{misspelled_content.replace(command_name, similar_command_name, 1)}" await ctx.send(embed=e, delete_after=7.0) elif isinstance(e, BadArgument): -- cgit v1.2.3 From 093fb2c7a234dbea171f439871f082fcf432b524 Mon Sep 17 00:00:00 2001 From: F4zii Date: Sat, 22 Feb 2020 13:30:41 +0200 Subject: Handling CommandError and Optimization After CommandError was catched, the embed was sent, now we return after the exception was catched Modified the way raw_commands are collected --- bot/cogs/error_handler.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index a642c3b7e..687eee9c7 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -98,12 +98,10 @@ class ErrorHandler(Cog): # No similar tag found, or tag on cooldown - # searching for a similar command - raw_commands = [ - (cmd.name, *cmd.aliases) - for cmd in self.bot.walk_commands() - if not cmd.hidden - ] - raw_commands = [c for data in raw_commands for c in data] + raw_commands = [] + for cmd in self.bot.walk_commands(): + if not cmd.hidden: + raw_commands += (cmd.name, *cmd.aliases) similar_command_data = difflib.get_close_matches(command_name, raw_commands, 1) similar_command_name = similar_command_data[0] similar_command = self.bot.get_command(similar_command_name) @@ -116,6 +114,7 @@ class ErrorHandler(Cog): except CommandError as cmd_error: log.debug(log_msg) await self.on_command_error(ctx, cmd_error) + return misspelled_content = ctx.message.content e = Embed() -- cgit v1.2.3 From 5a6a5b6eef7d67901181b569b4cc5b298e06d807 Mon Sep 17 00:00:00 2001 From: F4zii Date: Sat, 22 Feb 2020 19:49:22 +0200 Subject: awaited can_run Coroutine --- 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 687eee9c7..4b08546cc 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -108,7 +108,7 @@ class ErrorHandler(Cog): log_msg = "Cancelling attempt to suggest a command due to failed checks." try: - if not similar_command.can_run(ctx): + if not await similar_command.can_run(ctx): log.debug(log_msg) return except CommandError as cmd_error: -- cgit v1.2.3 From bff2ea57578fde3a389e286bb9254c232a0123e4 Mon Sep 17 00:00:00 2001 From: F4zii Date: Sat, 22 Feb 2020 19:52:23 +0200 Subject: Return False (not sent) at the end of display_tag If the code ran to the last line of the function without returning (we return when we send a tag) It means we didn't send the tag and need to return False --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 5124fb5e7..e36761f85 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -146,7 +146,7 @@ class Tags(Cog): title='Did you mean ...', description='\n'.join(tag['title'] for tag in founds[:10]) )) - return True + return False else: tags = self._cache.values() -- cgit v1.2.3 From 53753f0c9e614789221aa5145511c0fce05d75ad Mon Sep 17 00:00:00 2001 From: F4zii Date: Sat, 22 Feb 2020 19:58:08 +0200 Subject: (display_tag) Return False if no tags were found. Since no tags were found, we can return false since we didn't send any matching tag --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index e36761f85..c3bc9861f 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -155,7 +155,7 @@ class Tags(Cog): description="**There are no tags in the database!**", colour=Colour.red() )) - return True + return False else: embed: Embed = Embed(title="**Current tags**") await LinePaginator.paginate( -- cgit v1.2.3 From de5360585451ea7e3a708eba3e8315906e6a9904 Mon Sep 17 00:00:00 2001 From: F4zii Date: Sat, 22 Feb 2020 22:20:30 +0200 Subject: Nontype handling Logic fix we cannot perform anything without the tags cog and commands --- 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 5af5974cb..dab3102cf 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -77,7 +77,7 @@ class ErrorHandler(Cog): if not ctx.channel.id == Channels.verification: tags_cog = self.bot.get_cog("Tags") tags_get_command = self.bot.get_command("tags get") - if not tags_cog and not tags_get_command: + if not tags_cog or not tags_get_command: return ctx.invoked_from_error_handler = True -- cgit v1.2.3 From dc0bc87afb14f75037aa695dd6936bb061054a2d Mon Sep 17 00:00:00 2001 From: F4zii Date: Sat, 22 Feb 2020 22:22:13 +0200 Subject: Boolean Logic fix If we sent a message, we need to return True, the error_handler (in case it called the func) will not send another message if already sent one. --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index c3bc9861f..e36761f85 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -155,7 +155,7 @@ class Tags(Cog): description="**There are no tags in the database!**", colour=Colour.red() )) - return False + return True else: embed: Embed = Embed(title="**Current tags**") await LinePaginator.paginate( -- cgit v1.2.3 From 5f4b04b7a8631bd93627cdc61ed39bd998e15249 Mon Sep 17 00:00:00 2001 From: F4zii Date: Sat, 7 Mar 2020 09:33:36 +0200 Subject: Return True after a tag suggestion was sent After we send a tag or a suggestion we should return True for the error handler to know we handled the misspelt invoke --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index e36761f85..5124fb5e7 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -146,7 +146,7 @@ class Tags(Cog): title='Did you mean ...', description='\n'.join(tag['title'] for tag in founds[:10]) )) - return False + return True else: tags = self._cache.values() -- cgit v1.2.3 From 34ef0991c9418ac04d9b6cf24a8381d9c4918540 Mon Sep 17 00:00:00 2001 From: Iddo Soreq <44242259+F4zii@users.noreply.github.com> Date: Sat, 7 Mar 2020 09:35:40 +0200 Subject: Update bot/cogs/tags.py, cleaner return logic Co-Authored-By: Shirayuki Nekomata --- bot/cogs/tags.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index e36761f85..fdd7bb8ec 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -100,16 +100,14 @@ class Tags(Cog): """ now = time.time() - cooldown_conditions = ( + is_on_cooldown = ( tag_name and tag_name in self.tag_cooldowns and (now - self.tag_cooldowns[tag_name]["time"]) < Cooldowns.tags and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id ) - if cooldown_conditions: - return True - return False + return is_on_cooldown async def display_tag(self, ctx: Context, tag_name: str = None) -> bool: """ -- cgit v1.2.3 From b9d483c15464f4b11575090b27306f2accc47acf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 20 Jun 2020 08:47:24 +0300 Subject: Watchchannel: Moved message consuming task cancelling exception Moved exception logging when cog is being unloaded and messages is still not consumed from `cog_unload` to `consume_messages` itself in try-except block to avoid case when requesting result too early (before cancel finished). --- bot/cogs/watchchannels/watchchannel.py | 53 +++++++++++++++++----------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 436778c46..d78d45f26 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -169,32 +169,38 @@ class WatchChannel(metaclass=CogABCMeta): 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(BigBrotherConfig.log_delay) + try: + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) - self.log.trace("Started consuming the message queue") + self.log.trace("Started consuming the message queue") - # 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() + # 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() - for user_channel_queues in self.consumption_queue.values(): - for channel_queue in user_channel_queues.values(): - while channel_queue: - msg = channel_queue.popleft() + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() - self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") - await self.relay_message(msg) + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) - self.consumption_queue.clear() + self.consumption_queue.clear() - 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)) - else: - self.log.trace("Done consuming messages.") + 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)) + else: + self.log.trace("Done consuming messages.") + except asyncio.CancelledError as e: + self.log.exception( + "The consume task was canceled. Messages may be lost.", + exc_info=e + ) async def webhook_send( self, @@ -330,10 +336,3 @@ class WatchChannel(metaclass=CogABCMeta): self.log.trace("Unloading the cog") if self._consume_task and not self._consume_task.done(): self._consume_task.cancel() - try: - self._consume_task.result() - except asyncio.CancelledError as e: - self.log.exception( - "The consume task was canceled. Messages may be lost.", - exc_info=e - ) -- cgit v1.2.3 From ac302d3d2360c3b379632ce033884127321a76b5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 20 Jun 2020 12:08:29 +0300 Subject: Infractions: Fix cases when user leave from guild before assigning roles When user left from guild before bot can add Muted role, then catch this error and log. --- bot/cogs/moderation/infractions.py | 11 +++++++---- bot/cogs/moderation/scheduler.py | 9 +++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3b28526b2..c03c8d974 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -223,10 +223,13 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) async def action() -> None: - await user.add_roles(self._muted_role, reason=reason) - - log.trace(f"Attempting to kick {user} from voice because they've been muted.") - await user.move_to(None, reason=reason) + try: + await user.add_roles(self._muted_role, reason=reason) + except discord.NotFound: + log.info(f"User {user} ({user.id}) left from guild. Can't give Muted role.") + else: + log.trace(f"Attempting to kick {user} from voice because they've been muted.") + await user.move_to(None, reason=reason) await self.apply_infraction(ctx, infraction, user, action()) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index d75a72ddb..28547545e 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -71,8 +71,13 @@ class InfractionScheduler(Scheduler): return # Allowing mod log since this is a passive action that should be logged. - await apply_coro - log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") + try: + await apply_coro + except discord.NotFound: + # When user joined and then right after this left again before action completed, this can't add roles + log.info(f"Can't reapply {infraction['type']} to user {infraction['user']} because user left again.") + else: + log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") async def apply_infraction( self, -- cgit v1.2.3 From 429cc865309242f0cf37147f9c3f05036972eb8c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 20 Jun 2020 21:33:36 +0300 Subject: Implement bot closing tasks waiting + breaking `close` to multiple parts Made to resolve problem with Reddit cog that revoking access token raise exception because session is closed. To solve this, I made `Bot.closing_tasks` that bot wait before closing. Moved all extensions and cogs removing to `remove_extension` what is called before closing everything else because need to call `cog_unload`. --- bot/bot.py | 30 ++++++++++++++++++++++++++++-- bot/cogs/reddit.py | 4 +++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 313652d11..c9eb24bb5 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -2,7 +2,7 @@ import asyncio import logging import socket import warnings -from typing import Optional +from typing import List, Optional import aiohttp import aioredis @@ -49,6 +49,9 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") + # All tasks that need to block closing until finished + self.closing_tasks: List[asyncio.Task] = [] + async def _create_redis_session(self) -> None: """ Create the Redis connection pool, and then open the redis event gate. @@ -89,9 +92,32 @@ class Bot(commands.Bot): self._recreate() super().clear() + def remove_extensions(self) -> None: + """Remove all extensions and Cog to close bot. Copy from discord.py's own `close` for right closing order.""" + for extension in tuple(self.extensions): + try: + self.unload_extension(extension) + except Exception: + pass + + for cog in tuple(self.cogs): + try: + self.remove_cog(cog) + except Exception: + pass + async def close(self) -> None: """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" - await super().close() + # Remove extensions and cogs before calling super().close() to allow task finish before HTTP session close + self.remove_extensions() + + # Wait until all tasks that have to be completed before bot is closing is done + for task in self.closing_tasks: + log.trace(f"Waiting for task {task.get_name()} before closing.") + await task + + # Now actually do full close of bot + await super(commands.Bot, self).close() await self.api_client.close() diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 3b77538a0..5a63d71fc 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -44,7 +44,9 @@ class Reddit(Cog): """Stop the loop task and revoke the access token when the cog is unloaded.""" self.auto_poster_loop.cancel() if self.access_token and self.access_token.expires_at > datetime.utcnow(): - asyncio.create_task(self.revoke_access_token()) + task = asyncio.create_task(self.revoke_access_token()) + task.set_name("revoke_reddit_access_token") + self.bot.closing_tasks.append(task) async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" -- cgit v1.2.3 From 177e4d4f68f407ac2808b18badd32a29d26034ff Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 21 Jun 2020 08:22:56 +0300 Subject: Reddit: Remove unnecessary revoke task name changing --- bot/cogs/reddit.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5a63d71fc..681d1997f 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -45,7 +45,6 @@ class Reddit(Cog): self.auto_poster_loop.cancel() if self.access_token and self.access_token.expires_at > datetime.utcnow(): task = asyncio.create_task(self.revoke_access_token()) - task.set_name("revoke_reddit_access_token") self.bot.closing_tasks.append(task) async def init_reddit_ready(self) -> None: -- cgit v1.2.3 From 1fd30faaeaa2dfc3e38426db9112628bfdba0f04 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 21 Jun 2020 09:29:22 +0300 Subject: Reddit: Don't define revoke task as variable but instantly append --- bot/cogs/reddit.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 681d1997f..850d3afb2 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -44,8 +44,7 @@ class Reddit(Cog): """Stop the loop task and revoke the access token when the cog is unloaded.""" self.auto_poster_loop.cancel() if self.access_token and self.access_token.expires_at > datetime.utcnow(): - task = asyncio.create_task(self.revoke_access_token()) - self.bot.closing_tasks.append(task) + self.bot.closing_tasks.append(asyncio.create_task(self.revoke_access_token())) async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" -- cgit v1.2.3 From f4004d814c1babfb5906afb8cd9944ceef90a2a3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 21 Jun 2020 09:30:47 +0300 Subject: Silence: Add mod alert sending to `closing_tasks` to avoid error --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c8ab6443b..34baa2bcb 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -176,7 +176,7 @@ class Silence(Scheduler, commands.Cog): if self.muted_channels: channels_string = ''.join(channel.mention for channel in self.muted_channels) message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" - asyncio.create_task(self._mod_alerts_channel.send(message)) + self.bot.closing_tasks.append(asyncio.create_task(self._mod_alerts_channel.send(message))) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From b040a38ea1e3c7baddb54395a1f09d11fdd4e818 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 23 Jul 2020 07:51:56 +0300 Subject: Add copyright about `_remove_extension` + make function private --- bot/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index c9eb24bb5..f5f76b7f8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -92,8 +92,8 @@ class Bot(commands.Bot): self._recreate() super().clear() - def remove_extensions(self) -> None: - """Remove all extensions and Cog to close bot. Copy from discord.py's own `close` for right closing order.""" + def _remove_extensions(self) -> None: + """Remove all extensions and Cog to close bot. Copyright (c) 2015-2020 Rapptz (discord.py, MIT License).""" for extension in tuple(self.extensions): try: self.unload_extension(extension) -- cgit v1.2.3 From 360ce808bdc12ab8dfc998927d6a07658aa2b633 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 23 Jul 2020 07:56:12 +0300 Subject: Improve extension + cogs removing comment on `close` Co-authored-by: Mark --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index f5f76b7f8..7a8f9932c 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -108,7 +108,7 @@ class Bot(commands.Bot): async def close(self) -> None: """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" - # Remove extensions and cogs before calling super().close() to allow task finish before HTTP session close + # Done before super().close() to allow tasks finish before the HTTP session closes. self.remove_extensions() # Wait until all tasks that have to be completed before bot is closing is done -- cgit v1.2.3 From 65c4312515de65a59b7553b0581c31d0d9fa098b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 31 Jul 2020 18:00:36 +0300 Subject: Simplify bot shutdown cogs removing Unloading extensions already remove all cogs that is inside it and this is enough good for this case, because bot still call dpy's internal function later to remove cogs not related with extensions (when exist). --- bot/bot.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 7a8f9932c..5e05d1596 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -93,16 +93,10 @@ class Bot(commands.Bot): super().clear() def _remove_extensions(self) -> None: - """Remove all extensions and Cog to close bot. Copyright (c) 2015-2020 Rapptz (discord.py, MIT License).""" - for extension in tuple(self.extensions): + """Remove all extensions to trigger cog unloads.""" + for ext in self.extensions.keys(): try: - self.unload_extension(extension) - except Exception: - pass - - for cog in tuple(self.cogs): - try: - self.remove_cog(cog) + self.unload_extension(ext) except Exception: pass -- cgit v1.2.3 From 2dc0ee180330bcf2687d62e174abeea79e963775 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 31 Jul 2020 18:04:38 +0300 Subject: Use asyncio.gather instead manual looping and awaiting --- bot/bot.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 5e05d1596..2f366a3ef 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -106,9 +106,8 @@ class Bot(commands.Bot): self.remove_extensions() # Wait until all tasks that have to be completed before bot is closing is done - for task in self.closing_tasks: - log.trace(f"Waiting for task {task.get_name()} before closing.") - await task + log.trace("Waiting for tasks before closing.") + await asyncio.gather(*self.closing_tasks) # Now actually do full close of bot await super(commands.Bot, self).close() -- cgit v1.2.3 From 7b83b7c67b0d00fce0c7c88b9b306e82ca2f5622 Mon Sep 17 00:00:00 2001 From: Eivind Teig Date: Sat, 12 Sep 2020 01:28:39 +0200 Subject: Make nomination reason optional. We want to make the nominate command more attractive for our members of staff. --- 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 76d6fe9bd..6ba397308 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -1,7 +1,7 @@ import logging import textwrap from collections import ChainMap -from typing import Union +from typing import Union, Optional from discord import Color, Embed, Member, User from discord.ext.commands import Cog, Context, group @@ -65,7 +65,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) @with_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: Optional[str] = '') -> None: """ Relay messages sent by the given `user` to the `#talent-pool` channel. -- cgit v1.2.3 From d75e1bc101578d3ce318ab0614aa448053daba7d Mon Sep 17 00:00:00 2001 From: Eivind Teig Date: Sat, 12 Sep 2020 01:30:50 +0200 Subject: Only show reason if it exist. Reduce the footer to not include reason if it does not exist. --- bot/cogs/watchchannels/watchchannel.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index a58b604c0..eb6d6f992 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -285,7 +285,10 @@ class WatchChannel(metaclass=CogABCMeta): else: message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" - footer = f"Added {time_delta} by {actor} | Reason: {reason}" + # Add reason to the footer if it exists. + footer = f"Added {time_delta} by {actor}" + if reason: + footer += f" | Reason: {reason}" embed = Embed(description=f"{msg.author.mention} {message_jump}") embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) -- cgit v1.2.3 From 0e24d104ac47685df4dbec2e8262da4a8c6c61d9 Mon Sep 17 00:00:00 2001 From: Eivind Teig Date: Sat, 12 Sep 2020 01:31:17 +0200 Subject: Update user cache after nomination reason edit. --- 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 6ba397308..7e3aed971 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -203,7 +203,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): f"{self.api_endpoint}/{nomination_id}", json={field: reason} ) - + await self.fetch_user_cache() # Update cache. await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") @Cog.listener() -- cgit v1.2.3 From 057efd069a7fc060a82d3a88afcce157d33c6267 Mon Sep 17 00:00:00 2001 From: Eivind Teig Date: Sat, 12 Sep 2020 01:38:57 +0200 Subject: Fix import order to pass linting tests. --- 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 7e3aed971..66de567f9 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -1,7 +1,7 @@ import logging import textwrap from collections import ChainMap -from typing import Union, Optional +from typing import Optional, Union from discord import Color, Embed, Member, User from discord.ext.commands import Cog, Context, group -- cgit v1.2.3 From 4615571c71b1cc4571dde3fdb08223f8e3355d57 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 21 Sep 2020 16:56:39 +0800 Subject: Move Channels information to embed field. --- bot/exts/info/information.py | 28 +++++++++++++--------------- tests/bot/exts/info/test_information.py | 20 +++++++++++++------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 55ecb2836..b7c96acbd 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,6 @@ import logging import pprint import textwrap from collections import Counter, defaultdict -from string import Template from typing import Any, Mapping, Optional, Tuple, Union from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils @@ -143,37 +142,37 @@ class Information(Cog): @command(name="server", aliases=["server_info", "guild", "guild_info"]) async def server_info(self, ctx: Context) -> None: """Returns an embed full of server information.""" + embed = Embed( + colour=Colour.blurple(), + title="Server Information", + ) + created = time_since(ctx.guild.created_at, precision="days") features = ", ".join(ctx.guild.features) region = ctx.guild.region roles = len(ctx.guild.roles) member_count = ctx.guild.member_count - channel_counts = self.get_channel_type_counts(ctx.guild) # How many of each user status? statuses = Counter(member.status for member in ctx.guild.members) - embed = Embed(colour=Colour.blurple()) # How many staff members and staff channels do we have? staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) staff_channel_count = self.get_staff_channel_count(ctx.guild) - # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting - # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts - # after the dedent is made. - embed.description = Template( - textwrap.dedent(f""" - **Server information** + total_channels = len(ctx.guild.channels) + channel_counts = ( + f"{self.get_channel_type_counts(ctx.guild)}\n" + f"Staff channels: {staff_channel_count}" + ) + embed.add_field(name=f"Channels: {total_channels}", value=channel_counts) + + embed.description = textwrap.dedent(f""" Created: {created} Voice region: {region} Features: {features} - **Channel counts** - $channel_counts - Staff channels: {staff_channel_count} - **Member counts** Members: {member_count:,} Staff members: {staff_member_count} @@ -185,7 +184,6 @@ class Information(Cog): {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} {constants.Emojis.status_offline} {statuses[Status.offline]:,} """) - ).substitute({"channel_counts": channel_counts}) embed.set_thumbnail(url=ctx.guild.icon_url) await ctx.send(embed=embed) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index ba8d5d608..7a65d6c2b 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -139,21 +139,15 @@ class InformationCogTests(unittest.TestCase): _, kwargs = self.ctx.send.call_args embed = kwargs.pop('embed') self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.title, "Server Information") self.assertEqual( embed.description, textwrap.dedent( f""" - **Server information** Created: {time_since_patch.return_value} Voice region: {self.ctx.guild.region} Features: {', '.join(self.ctx.guild.features)} - **Channel counts** - Category channels: 1 - Text channels: 1 - Voice channels: 1 - Staff channels: 0 - **Member counts** Members: {self.ctx.guild.member_count:,} Staff members: 0 @@ -167,6 +161,18 @@ class InformationCogTests(unittest.TestCase): """ ) ) + + channel_field = embed.fields[0] + self.assertEqual(channel_field.name, "Channels: 3") + self.assertEqual( + channel_field.value, + textwrap.dedent(""" + Category channels: 1 + Text channels: 1 + Voice channels: 1 + Staff channels: 0 + """).strip(), + ) self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') -- cgit v1.2.3 From 6b423808fac9a3f2814ebda889830c505c2d277e Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 21 Sep 2020 17:09:15 +0800 Subject: Move member status information to embed field. --- bot/exts/info/information.py | 21 ++++++++++----------- tests/bot/exts/info/test_information.py | 17 +++++++++++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index b7c96acbd..25bde0888 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -20,9 +20,10 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) STATUS_EMOTES = { - Status.offline: constants.Emojis.status_offline, + Status.online: constants.Emojis.status_online, + Status.idle: constants.Emojis.status_idle, Status.dnd: constants.Emojis.status_dnd, - Status.idle: constants.Emojis.status_idle + Status.offline: constants.Emojis.status_offline, } @@ -154,9 +155,6 @@ class Information(Cog): roles = len(ctx.guild.roles) member_count = ctx.guild.member_count - # How many of each user status? - statuses = Counter(member.status for member in ctx.guild.members) - # How many staff members and staff channels do we have? staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) staff_channel_count = self.get_staff_channel_count(ctx.guild) @@ -168,6 +166,13 @@ class Information(Cog): ) embed.add_field(name=f"Channels: {total_channels}", value=channel_counts) + # Member status + status_count = Counter(member.status for member in ctx.guild.members) + member_status = " ".join( + f"{emoji} {status_count[status]:,}" for status, emoji in STATUS_EMOTES.items() + ) + embed.add_field(name="Member Status:", value=member_status, inline=False) + embed.description = textwrap.dedent(f""" Created: {created} Voice region: {region} @@ -177,12 +182,6 @@ class Information(Cog): Members: {member_count:,} Staff members: {staff_member_count} Roles: {roles} - - **Member statuses** - {constants.Emojis.status_online} {statuses[Status.online]:,} - {constants.Emojis.status_idle} {statuses[Status.idle]:,} - {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} - {constants.Emojis.status_offline} {statuses[Status.offline]:,} """) embed.set_thumbnail(url=ctx.guild.icon_url) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 7a65d6c2b..f09f815eb 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -152,16 +152,11 @@ class InformationCogTests(unittest.TestCase): Members: {self.ctx.guild.member_count:,} Staff members: 0 Roles: {len(self.ctx.guild.roles)} - - **Member statuses** - {constants.Emojis.status_online} 2 - {constants.Emojis.status_idle} 1 - {constants.Emojis.status_dnd} 4 - {constants.Emojis.status_offline} 3 """ ) ) + # Channels channel_field = embed.fields[0] self.assertEqual(channel_field.name, "Channels: 3") self.assertEqual( @@ -173,6 +168,16 @@ class InformationCogTests(unittest.TestCase): Staff channels: 0 """).strip(), ) + + # Member status + status_field = embed.fields[1] + self.assertEqual(status_field.name, "Member Status:") + self.assertEqual( + status_field.value, + f"{constants.Emojis.status_online} 2 {constants.Emojis.status_idle} 1 " + f"{constants.Emojis.status_dnd} 4 {constants.Emojis.status_offline} 3" + ) + self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') -- cgit v1.2.3 From 78cac100cfc634b6d4c3c45c06da3dec944e9cbb Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 21 Sep 2020 19:59:15 +0800 Subject: Simplify channel counting. Rather than do two passes over the channels, a single loop is used to collect all the channel counts into a single dictionary. The get_channel_type_counts method now returns a dictionary of channel to count, allowing the caller liberty to format the values. --- bot/exts/info/information.py | 86 ++++++++++++++------------------- tests/bot/exts/info/test_information.py | 7 ++- 2 files changed, 38 insertions(+), 55 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 25bde0888..4b1e0910d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap from collections import Counter, defaultdict -from typing import Any, Mapping, Optional, Tuple, Union +from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel @@ -34,47 +34,31 @@ class Information(Cog): self.bot = bot @staticmethod - def role_can_read(channel: GuildChannel, role: Role) -> bool: - """Return True if `role` can read messages in `channel`.""" - overwrites = channel.overwrites_for(role) - return overwrites.read_messages is True - - def get_staff_channel_count(self, guild: Guild) -> int: - """ - Get the number of channels that are staff-only. - - We need to know two things about a channel: - - Does the @everyone role have explicit read deny permissions? - - Do staff roles have explicit read allow permissions? - - If the answer to both of these questions is yes, it's a staff channel. - """ - channel_ids = set() - for channel in guild.channels: - if channel.type is ChannelType.category: - continue - - everyone_can_read = self.role_can_read(channel, guild.default_role) - - for role in constants.STAFF_ROLES: - role_can_read = self.role_can_read(channel, guild.get_role(role)) - if role_can_read and not everyone_can_read: - channel_ids.add(channel.id) - break - - return len(channel_ids) + def is_staff_channel(guild: Guild, channel: GuildChannel) -> bool: + """Determines if a given channel is staff-only.""" + if channel.type is ChannelType.category: + return False + + # Channel is staff-only if staff have explicit read allow perms + # and @everyone has explicit read deny perms + return any( + channel.overwrites_for(guild.get_role(staff_role)).read_messages is True + and channel.overwrites_for(guild.default_role).read_messages is False + for staff_role in constants.STAFF_ROLES + ) @staticmethod - def get_channel_type_counts(guild: Guild) -> str: + def get_channel_type_counts(guild: Guild) -> DefaultDict[str, int]: """Return the total amounts of the various types of channels in `guild`.""" - channel_counter = Counter(c.type for c in guild.channels) - channel_type_list = [] - for channel, count in channel_counter.items(): - channel_type = str(channel).title() - channel_type_list.append(f"{channel_type} channels: {count}") + channel_counter = defaultdict(int) + + for channel in guild.channels: + if Information.is_staff_channel(guild, channel): + channel_counter["staff"] += 1 + else: + channel_counter[str(channel.type)] += 1 - channel_type_list = sorted(channel_type_list) - return "\n".join(channel_type_list) + return channel_counter @with_role(*constants.MODERATION_ROLES) @command(name="roles") @@ -157,14 +141,14 @@ class Information(Cog): # How many staff members and staff channels do we have? staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) - staff_channel_count = self.get_staff_channel_count(ctx.guild) + # Channels total_channels = len(ctx.guild.channels) - channel_counts = ( - f"{self.get_channel_type_counts(ctx.guild)}\n" - f"Staff channels: {staff_channel_count}" + channel_counts = self.get_channel_type_counts(ctx.guild) + channel_info = "\n".join( + f"{channel.title()}: {count}" for channel, count in sorted(channel_counts.items()) ) - embed.add_field(name=f"Channels: {total_channels}", value=channel_counts) + embed.add_field(name=f"Channels: {total_channels}", value=channel_info) # Member status status_count = Counter(member.status for member in ctx.guild.members) @@ -174,14 +158,14 @@ class Information(Cog): embed.add_field(name="Member Status:", value=member_status, inline=False) embed.description = textwrap.dedent(f""" - Created: {created} - Voice region: {region} - Features: {features} - - **Member counts** - Members: {member_count:,} - Staff members: {staff_member_count} - Roles: {roles} + Created: {created} + Voice region: {region} + Features: {features} + + **Member counts** + Members: {member_count:,} + Staff members: {staff_member_count} + Roles: {roles} """) embed.set_thumbnail(url=ctx.guild.icon_url) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index f09f815eb..1fd3ef066 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -162,10 +162,9 @@ class InformationCogTests(unittest.TestCase): self.assertEqual( channel_field.value, textwrap.dedent(""" - Category channels: 1 - Text channels: 1 - Voice channels: 1 - Staff channels: 0 + Category: 1 + Text: 1 + Voice: 1 """).strip(), ) -- cgit v1.2.3 From dfb8568e9016698f048633e96d3855d71dfa84dc Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 21 Sep 2020 20:08:22 +0800 Subject: Move member count to embed field. --- bot/exts/info/information.py | 17 ++++++++--------- tests/bot/exts/info/test_information.py | 18 ++++++++++++------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 4b1e0910d..bb8ded4e4 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -136,11 +136,15 @@ class Information(Cog): features = ", ".join(ctx.guild.features) region = ctx.guild.region - roles = len(ctx.guild.roles) + # Members member_count = ctx.guild.member_count - - # How many staff members and staff channels do we have? staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) + roles = len(ctx.guild.roles) + member_info = textwrap.dedent(f""" + Staff members: {staff_member_count} + Roles: {roles} + """) + embed.add_field(name=f"Members: {member_count}", value=member_info) # Channels total_channels = len(ctx.guild.channels) @@ -161,12 +165,7 @@ class Information(Cog): Created: {created} Voice region: {region} Features: {features} - - **Member counts** - Members: {member_count:,} - Staff members: {staff_member_count} - Roles: {roles} - """) + """) embed.set_thumbnail(url=ctx.guild.icon_url) await ctx.send(embed=embed) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 1fd3ef066..261533847 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -147,17 +147,23 @@ class InformationCogTests(unittest.TestCase): Created: {time_since_patch.return_value} Voice region: {self.ctx.guild.region} Features: {', '.join(self.ctx.guild.features)} + """ + ) + ) - **Member counts** - Members: {self.ctx.guild.member_count:,} + # Members + member_field = embed.fields[0] + self.assertEqual(member_field.name, f"Members: {self.ctx.guild.member_count}") + self.assertEqual( + member_field.value, + textwrap.dedent(f""" Staff members: 0 Roles: {len(self.ctx.guild.roles)} - """ - ) + """), ) # Channels - channel_field = embed.fields[0] + channel_field = embed.fields[1] self.assertEqual(channel_field.name, "Channels: 3") self.assertEqual( channel_field.value, @@ -169,7 +175,7 @@ class InformationCogTests(unittest.TestCase): ) # Member status - status_field = embed.fields[1] + status_field = embed.fields[2] self.assertEqual(status_field.name, "Member Status:") self.assertEqual( status_field.value, -- cgit v1.2.3 From 0f074d53eb50bddae69f775c1bb0a45b89774e12 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 21 Sep 2020 22:27:51 +0800 Subject: Add more role information to server embed. --- bot/exts/info/information.py | 29 ++++++++++++++++++++--------- tests/bot/exts/info/test_information.py | 18 +++++++++++++++--- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index bb8ded4e4..d68dc06da 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap from collections import Counter, defaultdict -from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union +from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel @@ -60,6 +60,19 @@ class Information(Cog): return channel_counter + @staticmethod + def get_member_counts(guild: Guild) -> Dict[str, int]: + """Return the total number of members per role in `guild`, and the total number of roles.""" + roles = ( + guild.get_role(role_id) for role_id in ( + constants.Roles.helpers, constants.Roles.moderators, + constants.Roles.admins, constants.Roles.contributors, + ) + ) + member_counts = {role.name: len(role.members) for role in roles} + member_counts["roles"] = len(guild.roles) - 1 # Exclude @everyone + return member_counts + @with_role(*constants.MODERATION_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: @@ -137,14 +150,12 @@ class Information(Cog): region = ctx.guild.region # Members - member_count = ctx.guild.member_count - staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members) - roles = len(ctx.guild.roles) - member_info = textwrap.dedent(f""" - Staff members: {staff_member_count} - Roles: {roles} - """) - embed.add_field(name=f"Members: {member_count}", value=member_info) + total_members = ctx.guild.member_count + member_counts = self.get_member_counts(ctx.guild) + member_info = "\n".join( + f"{role.title()}: {count}" for role, count in member_counts.items() + ) + embed.add_field(name=f"Members: {total_members}", value=member_info) # Channels total_channels = len(ctx.guild.channels) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 261533847..b5c71f87c 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -132,6 +132,15 @@ class InformationCogTests(unittest.TestCase): icon_url='a-lemon.jpg', ) + self.ctx.guild.get_role = unittest.mock.Mock() + self.ctx.guild.get_role.side_effect = lambda id: { + constants.Roles.helpers: helpers.MockRole(name="Helpers", id=id, members=[]), + constants.Roles.moderators: helpers.MockRole(name="Moderators", id=id, members=[]), + constants.Roles.admins: helpers.MockRole(name="Admins", id=id, members=[]), + constants.Roles.owners: helpers.MockRole(name="Owners", id=id, members=[]), + constants.Roles.contributors: helpers.MockRole(name="Contributors", id=id, members=[]), + }[id] + coroutine = self.cog.server_info.callback(self.cog, self.ctx) self.assertIsNone(asyncio.run(coroutine)) @@ -157,9 +166,12 @@ class InformationCogTests(unittest.TestCase): self.assertEqual( member_field.value, textwrap.dedent(f""" - Staff members: 0 - Roles: {len(self.ctx.guild.roles)} - """), + Helpers: 0 + Moderators: 0 + Admins: 0 + Contributors: 0 + Roles: {len(self.ctx.guild.roles) - 1} + """).strip(), ) # Channels -- cgit v1.2.3 From dcc1f5496765e761e91306cac69916cb9f11b4b0 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Mon, 21 Sep 2020 23:01:52 +0800 Subject: Add extended information to !server. Includes useful information like the number of nominated/watched members, number of unverified members, defcon status etc. Will not show up if the command is ran outside of a moderation channel. --- bot/exts/info/information.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index d68dc06da..82bccb84a 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -73,6 +73,27 @@ class Information(Cog): member_counts["roles"] = len(guild.roles) - 1 # Exclude @everyone return member_counts + def get_extended_server_info(self, guild: Guild) -> str: + """Return additional server info only visible in moderation channels.""" + unverified_count = guild.member_count - len(guild.get_role(constants.Roles.verified).members) + talentpool_count = len(self.bot.get_cog("Talentpool").watched_users) + bb_count = len(self.bot.get_cog("Big Brother").watched_users) + + defcon_cog = self.bot.get_cog("Defcon") + defcon_status = "Enabled" if defcon_cog.enabled else "Disabled" + defcon_days = defcon_cog.days.days if defcon_cog.enabled else "-" + python_general = self.bot.get_channel(constants.Channels.python_discussion) + + return textwrap.dedent(f""" + Unverified: {unverified_count:,} + Nominated: {talentpool_count} + BB-watched: {bb_count} + + Defcon status: {defcon_status} + Defcon days: {defcon_days} + {python_general.mention} cooldown: {python_general.slowmode_delay}s + """) + @with_role(*constants.MODERATION_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: @@ -149,6 +170,13 @@ class Information(Cog): features = ", ".join(ctx.guild.features) region = ctx.guild.region + embed.description = textwrap.dedent(f""" + Created: {created} + Voice region: {region} + Features: {features} + """) + embed.set_thumbnail(url=ctx.guild.icon_url) + # Members total_members = ctx.guild.member_count member_counts = self.get_member_counts(ctx.guild) @@ -172,12 +200,11 @@ class Information(Cog): ) embed.add_field(name="Member Status:", value=member_status, inline=False) - embed.description = textwrap.dedent(f""" - Created: {created} - Voice region: {region} - Features: {features} - """) - embed.set_thumbnail(url=ctx.guild.icon_url) + # Additional info if ran in moderation channels + if ctx.channel.id in constants.MODERATION_CHANNELS: + embed.add_field( + name="Moderation Information:", value=self.get_extended_server_info(ctx.guild) + ) await ctx.send(embed=embed) -- cgit v1.2.3 From 185437573421e26b435c4294c34ecffd47aca014 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 23 Sep 2020 11:54:47 +0800 Subject: Move number of roles to embed description. --- bot/exts/info/information.py | 8 ++++---- tests/bot/exts/info/test_information.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 82bccb84a..96c2c9629 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -62,16 +62,14 @@ class Information(Cog): @staticmethod def get_member_counts(guild: Guild) -> Dict[str, int]: - """Return the total number of members per role in `guild`, and the total number of roles.""" + """Return the total number of members for certain roles in `guild`.""" roles = ( guild.get_role(role_id) for role_id in ( constants.Roles.helpers, constants.Roles.moderators, constants.Roles.admins, constants.Roles.contributors, ) ) - member_counts = {role.name: len(role.members) for role in roles} - member_counts["roles"] = len(guild.roles) - 1 # Exclude @everyone - return member_counts + return {role.name: len(role.members) for role in roles} def get_extended_server_info(self, guild: Guild) -> str: """Return additional server info only visible in moderation channels.""" @@ -169,11 +167,13 @@ class Information(Cog): created = time_since(ctx.guild.created_at, precision="days") features = ", ".join(ctx.guild.features) region = ctx.guild.region + num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone embed.description = textwrap.dedent(f""" Created: {created} Voice region: {region} Features: {features} + Roles: {num_roles} """) embed.set_thumbnail(url=ctx.guild.icon_url) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index b5c71f87c..38ffb2f16 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -156,6 +156,7 @@ class InformationCogTests(unittest.TestCase): Created: {time_since_patch.return_value} Voice region: {self.ctx.guild.region} Features: {', '.join(self.ctx.guild.features)} + Roles: {len(self.ctx.guild.roles) - 1} """ ) ) @@ -165,12 +166,11 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(member_field.name, f"Members: {self.ctx.guild.member_count}") self.assertEqual( member_field.value, - textwrap.dedent(f""" + textwrap.dedent(""" Helpers: 0 Moderators: 0 Admins: 0 Contributors: 0 - Roles: {len(self.ctx.guild.roles) - 1} """).strip(), ) -- cgit v1.2.3 From 2c2dbe41dcc1e8d72322532a3709bdcec5abc028 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 23 Sep 2020 11:59:39 +0800 Subject: Add owners count to server embed --- bot/exts/info/information.py | 4 ++-- tests/bot/exts/info/test_information.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 96c2c9629..d6973abd4 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -65,8 +65,8 @@ class Information(Cog): """Return the total number of members for certain roles in `guild`.""" roles = ( guild.get_role(role_id) for role_id in ( - constants.Roles.helpers, constants.Roles.moderators, - constants.Roles.admins, constants.Roles.contributors, + constants.Roles.helpers, constants.Roles.moderators, constants.Roles.admins, + constants.Roles.owners, constants.Roles.contributors, ) ) return {role.name: len(role.members) for role in roles} diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 38ffb2f16..1978bd7e2 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -170,6 +170,7 @@ class InformationCogTests(unittest.TestCase): Helpers: 0 Moderators: 0 Admins: 0 + Owners: 0 Contributors: 0 """).strip(), ) -- cgit v1.2.3 From 13df7440b008c41037ab1009852711748ffc07fc Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 23 Sep 2020 12:12:04 +0800 Subject: Only show server features in certain channels. --- bot/exts/info/information.py | 12 +++++++++--- tests/bot/exts/info/test_information.py | 1 - 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index d6973abd4..868e22417 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -165,14 +165,20 @@ class Information(Cog): ) created = time_since(ctx.guild.created_at, precision="days") - features = ", ".join(ctx.guild.features) region = ctx.guild.region num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone + # Server Features are only useful in certain channels + if ctx.channel.id in ( + *constants.STAFF_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib + ): + features = f"\nFeatures: {', '.join(ctx.guild.features)}" + else: + features = "" + embed.description = textwrap.dedent(f""" Created: {created} - Voice region: {region} - Features: {features} + Voice region: {region}{features} Roles: {num_roles} """) embed.set_thumbnail(url=ctx.guild.icon_url) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 1978bd7e2..74cbac4b6 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -155,7 +155,6 @@ class InformationCogTests(unittest.TestCase): f""" Created: {time_since_patch.return_value} Voice region: {self.ctx.guild.region} - Features: {', '.join(self.ctx.guild.features)} Roles: {len(self.ctx.guild.roles) - 1} """ ) -- cgit v1.2.3 From 0b83a7c19392804a436efcc6ba8715341e98cfc4 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 23 Sep 2020 12:23:28 +0800 Subject: Update relevant channels for features. --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 868e22417..5476373c2 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -170,7 +170,7 @@ class Information(Cog): # Server Features are only useful in certain channels if ctx.channel.id in ( - *constants.STAFF_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib + *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib ): features = f"\nFeatures: {', '.join(ctx.guild.features)}" else: -- cgit v1.2.3 From db5cf7d18d6992165abc15cd27ed50a4713af124 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 16:26:29 +0800 Subject: Add append subcommand for infraction group --- bot/exts/moderation/infraction/management.py | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index de4fb4175..4e31947d4 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -45,6 +45,56 @@ class ModManagement(commands.Cog): """Infraction manipulation commands.""" await ctx.send_help(ctx.command) + @infraction_group.command(name="append", aliases=("amend", "add")) + async def infraction_append( + self, + ctx: Context, + infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 + *, + reason: str = None + ) -> None: + """ + Append text and/or edit the duration of an infraction. + + Durations are relative to the time of updating and should be appended with a unit of time. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction + authored by the command invoker should be edited. + + Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 + timestamp can be provided for the duration. + """ + if isinstance(infraction_id, str): + params = { + "actor__id": ctx.author.id, + "ordering": "-inserted_at" + } + infractions = await self.bot.api_client.get("bot/infractions", params=params) + + if infractions: + old_infraction = infractions[0] + infraction_id = old_infraction["id"] + else: + await ctx.send( + ":x: Couldn't find most recent infraction; you have never given an infraction." + ) + return + else: + old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") + + reason = f"{old_infraction['reason']} **Edit:** {reason}" + + await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) + @infraction_group.command(name='edit') async def infraction_edit( self, -- cgit v1.2.3 From f9971be6251dc083332c2adc4bffb746f2c8ccf2 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:05:22 +0800 Subject: Add get_latest_infraction utility function --- bot/exts/moderation/infraction/management.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 4e31947d4..7596d2ec1 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -338,6 +338,20 @@ class ModManagement(commands.Cog): return lines.strip() + async def get_latest_infraction(self, actor: int) -> t.Optional[dict]: + """Obtains the latest infraction from an actor.""" + params = { + "actor__id": actor, + "ordering": "-inserted_at" + } + + infractions = await self.bot.api_client.get("bot/infractions", params=params) + + if infractions: + return infractions[0] + + return None + # endregion # This cannot be static (must have a __func__ attribute). -- cgit v1.2.3 From 1ab0084581859a0d8e938b9292bdb86ce9caf523 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:11:21 +0800 Subject: Refactor routine for obtaining latest infraction --- bot/exts/moderation/infraction/management.py | 36 +++++++++++----------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 7596d2ec1..b841b11c3 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -74,20 +74,16 @@ class ModManagement(commands.Cog): timestamp can be provided for the duration. """ if isinstance(infraction_id, str): - params = { - "actor__id": ctx.author.id, - "ordering": "-inserted_at" - } - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - old_infraction = infractions[0] - infraction_id = old_infraction["id"] - else: - await ctx.send( + old_infraction = await self.get_latest_infraction(ctx.author.id) + + if old_infraction is None: + ctx.send( ":x: Couldn't find most recent infraction; you have never given an infraction." ) return + + infraction_id = old_infraction["id"] + else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") @@ -129,20 +125,16 @@ class ModManagement(commands.Cog): # Retrieve the previous infraction for its information. if isinstance(infraction_id, str): - params = { - "actor__id": ctx.author.id, - "ordering": "-inserted_at" - } - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - old_infraction = infractions[0] - infraction_id = old_infraction["id"] - else: - await ctx.send( + old_infraction = await self.get_latest_infraction(ctx.author.id) + + if old_infraction is None: + ctx.send( ":x: Couldn't find most recent infraction; you have never given an infraction." ) return + + infraction_id = old_infraction["id"] + else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") -- cgit v1.2.3 From 4ce59c673c035550ba4f2e55e79faba17002d40c Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:46:10 +0800 Subject: Fix unawaited coroutine --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b841b11c3..e35ebcbef 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -77,7 +77,7 @@ class ModManagement(commands.Cog): old_infraction = await self.get_latest_infraction(ctx.author.id) if old_infraction is None: - ctx.send( + await ctx.send( ":x: Couldn't find most recent infraction; you have never given an infraction." ) return @@ -128,7 +128,7 @@ class ModManagement(commands.Cog): old_infraction = await self.get_latest_infraction(ctx.author.id) if old_infraction is None: - ctx.send( + await ctx.send( ":x: Couldn't find most recent infraction; you have never given an infraction." ) return -- cgit v1.2.3 From abbb62a0720f68cbd0a0226f4abeb9c3b337de3c Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:47:50 +0800 Subject: Add "a" alias for append --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index e35ebcbef..78dc16b23 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -45,7 +45,7 @@ class ModManagement(commands.Cog): """Infraction manipulation commands.""" await ctx.send_help(ctx.command) - @infraction_group.command(name="append", aliases=("amend", "add")) + @infraction_group.command(name="append", aliases=("amend", "add", "a")) async def infraction_append( self, ctx: Context, -- cgit v1.2.3 From 1dd93784d09baf73a670621f22b96c24ffb6c762 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:55:03 +0800 Subject: Add visual buffer for appended reason --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 78dc16b23..ba1485978 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - reason = f"{old_infraction['reason']} **Edit:** {reason}" + reason = f"{old_infraction['reason']} || **Edit:** {reason}" await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) -- cgit v1.2.3 From b65c64575f12ddee57bd6bf9bfaffafe6131890a Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 18:59:50 +0800 Subject: Make vertical bar separators escaped --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index ba1485978..bdb0e8ffa 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - reason = f"{old_infraction['reason']} || **Edit:** {reason}" + reason = fr"{old_infraction['reason']} \|\| **Edit:** {reason}" await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) -- cgit v1.2.3 From 84671acaeb251939722a9b93f217b6256637a998 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 27 Sep 2020 13:30:32 +0800 Subject: Remove prefix when appending a reason --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index bdb0e8ffa..6b3e701c7 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - reason = fr"{old_infraction['reason']} \|\| **Edit:** {reason}" + reason = fr"{old_infraction['reason']} \|\| {reason}" await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) -- cgit v1.2.3 From 7e006192d9e8569c614e7466def59682b228c00c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 27 Sep 2020 09:23:59 +0300 Subject: Help: Add handling of disabled commands to avoid DisabledCommand error --- bot/exts/info/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 99d503f5c..5d83f8f86 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -174,7 +174,7 @@ class CustomHelpCommand(HelpCommand): command_details += f"**Can also use:** {aliases}\n\n" # check if the user is allowed to run this command - if not await command.can_run(self.context): + if not command.enabled or not await command.can_run(self.context): command_details += "***You cannot run this command.***\n\n" command_details += f"*{command.help or 'No details provided.'}*\n" -- cgit v1.2.3 From fe4eaeb182553f81afc7451c4aeaf1d865a9b18c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 28 Sep 2020 15:36:02 +0300 Subject: Help: Show different message for case when command is disabled --- bot/exts/info/help.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 5d83f8f86..803fcb2bb 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -173,8 +173,11 @@ class CustomHelpCommand(HelpCommand): if aliases: command_details += f"**Can also use:** {aliases}\n\n" - # check if the user is allowed to run this command - if not command.enabled or not await command.can_run(self.context): + # when command is disabled, show message about it + # otherwise check if the user is allowed to run this command + if not command.enabled: + command_details += "***This command is disabled.***\n\n" + elif not await command.can_run(self.context): command_details += "***You cannot run this command.***\n\n" command_details += f"*{command.help or 'No details provided.'}*\n" -- cgit v1.2.3 From e18893760b115600b7b03a60ce5bfb80e59fb882 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 1 Oct 2020 04:01:58 +0800 Subject: Add bold styling for vertical bar separators --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 6b3e701c7..1cdcf6568 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - reason = fr"{old_infraction['reason']} \|\| {reason}" + reason = fr"{old_infraction['reason']} **\|\|** {reason}" await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) -- cgit v1.2.3 From 929e8352553bbe90a196548c9afabd0ef63bd98e Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 1 Oct 2020 12:55:00 +0800 Subject: Fuzzy match roles for `!role` command. An arbitrary cutoff score of 80 is chosen because it works. A bug in the test for the same command is also fixed. --- bot/exts/info/information.py | 10 +++++++--- tests/bot/exts/info/test_information.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f6ed176f1..0386a2909 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,7 +6,8 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils +import fuzzywuzzy +from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from discord.utils import escape_markdown @@ -108,18 +109,21 @@ class Information(Cog): parsed_roles = [] failed_roles = [] + all_roles = {role.id: role.name for role in ctx.guild.roles} for role_name in roles: if isinstance(role_name, Role): # Role conversion has already succeeded parsed_roles.append(role_name) continue - role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) + match = fuzzywuzzy.process.extractOne(role_name, all_roles, score_cutoff=80) - if not role: + if not match: failed_roles.append(role_name) continue + # `match` is a (role name, score, role id) tuple + role = ctx.guild.get_role(match[2]) parsed_roles.append(role) if failed_roles: diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d3f2995fb..7bc7dbb5d 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -68,7 +68,7 @@ class InformationCogTests(unittest.TestCase): permissions=discord.Permissions(0), ) - self.ctx.guild.roles.append([dummy_role, admin_role]) + self.ctx.guild.roles.extend([dummy_role, admin_role]) self.cog.role_info.can_run = unittest.mock.AsyncMock() self.cog.role_info.can_run.return_value = True -- cgit v1.2.3 From 063fb4cbed635e91404e3be833629d5ad1cdf136 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 1 Oct 2020 13:08:55 +0800 Subject: Use basic scorer to fuzz a bit stricter. This prevents weird fuzz matches like `!role a b c d` working. --- bot/exts/info/information.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 0386a2909..bfc05cea9 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -116,7 +116,10 @@ class Information(Cog): parsed_roles.append(role_name) continue - match = fuzzywuzzy.process.extractOne(role_name, all_roles, score_cutoff=80) + match = fuzzywuzzy.process.extractOne( + role_name, all_roles, score_cutoff=80, + scorer=fuzzywuzzy.fuzz.ratio + ) if not match: failed_roles.append(role_name) -- cgit v1.2.3 From c57b9dd7db59ed39ff1fb2fe99ba515bf3e51815 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 1 Oct 2020 13:11:02 +0800 Subject: Avoid duplicate roles. --- bot/exts/info/information.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index bfc05cea9..0f074c45d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -106,14 +106,14 @@ class Information(Cog): To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. """ - parsed_roles = [] - failed_roles = [] + parsed_roles = set() + failed_roles = set() all_roles = {role.id: role.name for role in ctx.guild.roles} for role_name in roles: if isinstance(role_name, Role): # Role conversion has already succeeded - parsed_roles.append(role_name) + parsed_roles.add(role_name) continue match = fuzzywuzzy.process.extractOne( @@ -122,12 +122,12 @@ class Information(Cog): ) if not match: - failed_roles.append(role_name) + failed_roles.add(role_name) continue # `match` is a (role name, score, role id) tuple role = ctx.guild.get_role(match[2]) - parsed_roles.append(role) + parsed_roles.add(role) if failed_roles: await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") -- cgit v1.2.3 From c1b46ecc916970ec95e267f017958d30e4773c2a Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sat, 3 Oct 2020 12:52:24 +0800 Subject: Invoke infraction_edit directly with method call --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 1cdcf6568..2cb9bce8b 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -89,7 +89,7 @@ class ModManagement(commands.Cog): reason = fr"{old_infraction['reason']} **\|\|** {reason}" - await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) + await self.infraction_edit(infraction_id=infraction_id, duration=duration, reason=reason) @infraction_group.command(name='edit') async def infraction_edit( -- cgit v1.2.3 From 2de1a6a4091234703d99a26ad2884b586d7204f4 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sat, 3 Oct 2020 15:42:23 +0800 Subject: Add Infraction converter This adds the Infraction converter to be used in infraction_edit and infraction_append. --- bot/converters.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 2e118d476..962416238 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -549,6 +549,36 @@ def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: return int(match.group(1)) +class Infraction(Converter): + """ + Attempts to convert a given infraction ID into an infraction. + + Alternatively, `l`, `last`, or `recent` can be passed in order to + obtain the most recent infraction by the actor. + """ + + async def convert(self, ctx: Context, arg: str) -> t.Optional[dict]: + """Attempts to convert `arg` into an infraction `dict`.""" + if arg in ("l", "last", "recent"): + params = { + "actor__id": ctx.author.id, + "ordering": "-inserted_at" + } + + infractions = await ctx.bot.api_client.get("bot/infractions", params=params) + + if not infractions: + await ctx.send( + ":x: Couldn't find most recent infraction; you have never given an infraction." + ) + return None + + return infractions[0] + + else: + return ctx.bot.api_client.get(f"bot/infractions/{arg}") + + Expiry = t.Union[Duration, ISODateTime] FetchedMember = t.Union[discord.Member, FetchedUser] UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) -- cgit v1.2.3 From 1ae9b15d7718d7e2f96b4406de99b89f1778b971 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sat, 3 Oct 2020 15:43:51 +0800 Subject: Refactor infraction_edit and infraction_append This refactors the infraction_edit and infraction_append commands to utilize the Infraction converter. --- bot/exts/moderation/infraction/management.py | 47 +++++++++------------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 2cb9bce8b..8aeb45f96 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -10,7 +10,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user +from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -49,7 +49,7 @@ class ModManagement(commands.Cog): async def infraction_append( self, ctx: Context, - infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + infraction: Infraction, # noqa: F821 duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 *, reason: str = None @@ -73,29 +73,21 @@ class ModManagement(commands.Cog): Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - if isinstance(infraction_id, str): - old_infraction = await self.get_latest_infraction(ctx.author.id) - - if old_infraction is None: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." - ) - return - - infraction_id = old_infraction["id"] - - else: - old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - - reason = fr"{old_infraction['reason']} **\|\|** {reason}" + if not infraction: + return - await self.infraction_edit(infraction_id=infraction_id, duration=duration, reason=reason) + await self.infraction_edit( + ctx=ctx, + infraction=infraction, + duration=duration, + reason=fr"{infraction['reason']} **\|\|** {reason}", + ) @infraction_group.command(name='edit') async def infraction_edit( self, ctx: Context, - infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + infraction: Infraction, # noqa: F821 duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 *, reason: str = None @@ -123,20 +115,11 @@ class ModManagement(commands.Cog): # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - # Retrieve the previous infraction for its information. - if isinstance(infraction_id, str): - old_infraction = await self.get_latest_infraction(ctx.author.id) - - if old_infraction is None: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." - ) - return - - infraction_id = old_infraction["id"] + if not infraction: + return - else: - old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") + old_infraction = infraction + infraction_id = infraction["id"] request_data = {} confirm_messages = [] -- cgit v1.2.3 From 776825d09530be6b57759201795c436823002007 Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Mon, 5 Oct 2020 18:11:34 +0200 Subject: fix(statsd): Gracefully handle gaierro Per issue #1185 the bot might go down if the statsd client fails to connect during instantiation. This can be caused by an outage on their part, or network issues. If this happens getaddrinfo will raise a gaierror. This PR catched the error, sets self.stats to None for the time being, and handles that elsewhere. In addition a fallback logic was added to attempt to reconnect, in the off-chance it's a temporary outage --- bot/bot.py | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index b2e5237fe..0b842d07a 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -46,7 +46,25 @@ class Bot(commands.Bot): # will effectively disable stats. statsd_url = "127.0.0.1" - self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") + try: + self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") + except socket.gaierror as socket_error: + self.stats = None + self.loop.call_later(30, self.retry_statsd_connection, statsd_url) + log.warning(f"Statsd client failed to instantiate with error:\n{socket_error}") + + def retry_statsd_connection(self, statsd_url: str, retry_after: int = 30, attempt: int = 1) -> None: + """Callback used to retry a connection to statsd if it should fail.""" + if attempt >= 10: + log.error("Reached 10 attempts trying to reconnect AsyncStatsClient. Aborting") + return + + try: + self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") + except socket.gaierror: + log.warning(f"Statsd client failed to reconnect (Retry attempt: {attempt})") + # Use a fallback strategy for retrying, up to 10 times. + self.loop.call_later(retry_after, self.retry_statsd_connection, statsd_url, retry_after * 2, attempt + 1) async def cache_filter_list_data(self) -> None: """Cache all the data in the FilterList on the site.""" @@ -146,7 +164,7 @@ class Bot(commands.Bot): if self._resolver: await self._resolver.close() - if self.stats._transport: + if self.stats and self.stats._transport: self.stats._transport.close() if self.redis_session: @@ -168,7 +186,12 @@ class Bot(commands.Bot): async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" self._recreate() - await self.stats.create_socket() + + if self.stats: + await self.stats.create_socket() + else: + log.info("self.stats is not defined, skipping create_socket step in login") + await super().login(*args, **kwargs) async def on_guild_available(self, guild: discord.Guild) -> None: @@ -214,7 +237,10 @@ class Bot(commands.Bot): async def on_error(self, event: str, *args, **kwargs) -> None: """Log errors raised in event listeners rather than printing them to stderr.""" - self.stats.incr(f"errors.event.{event}") + if self.stats: + self.stats.incr(f"errors.event.{event}") + else: + log.info(f"self.stats is not defined, skipping errors.event.{event} increment in on_error") with push_scope() as scope: scope.set_tag("event", event) -- cgit v1.2.3 From ca761f04eb3353ae4e9a992d23d13d131d5a6ad0 Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Mon, 5 Oct 2020 19:30:58 +0200 Subject: fix(bot): Not assign stats to None self.stats is referred to as bot.stats in the project, which was overlooked. This should "disable" stats until it's successfully reconnected. The retry attempts will continue until it stops throwing or fails 10x --- bot/bot.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 0b842d07a..545efefe6 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -15,6 +15,7 @@ from bot import DEBUG_MODE, api, constants from bot.async_stats import AsyncStatsClient log = logging.getLogger('bot') +LOCALHOST = "127.0.0.1" class Bot(commands.Bot): @@ -44,12 +45,12 @@ class Bot(commands.Bot): # Since statsd is UDP, there are no errors for sending to a down port. # For this reason, setting the statsd host to 127.0.0.1 for development # will effectively disable stats. - statsd_url = "127.0.0.1" + statsd_url = LOCALHOST try: self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") except socket.gaierror as socket_error: - self.stats = None + self.stats = AsyncStatsClient(self.loop, LOCALHOST) self.loop.call_later(30, self.retry_statsd_connection, statsd_url) log.warning(f"Statsd client failed to instantiate with error:\n{socket_error}") -- cgit v1.2.3 From 897e714ec0ce8468f10e3c20b50e30bfc96e5c77 Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Mon, 5 Oct 2020 19:32:22 +0200 Subject: fix(bot): redundant false checks on self.stats --- bot/bot.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 545efefe6..fbf5eb761 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -165,7 +165,7 @@ class Bot(commands.Bot): if self._resolver: await self._resolver.close() - if self.stats and self.stats._transport: + if self.stats._transport: self.stats._transport.close() if self.redis_session: @@ -187,12 +187,7 @@ class Bot(commands.Bot): async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" self._recreate() - - if self.stats: - await self.stats.create_socket() - else: - log.info("self.stats is not defined, skipping create_socket step in login") - + await self.stats.create_socket() await super().login(*args, **kwargs) async def on_guild_available(self, guild: discord.Guild) -> None: @@ -238,10 +233,7 @@ class Bot(commands.Bot): async def on_error(self, event: str, *args, **kwargs) -> None: """Log errors raised in event listeners rather than printing them to stderr.""" - if self.stats: - self.stats.incr(f"errors.event.{event}") - else: - log.info(f"self.stats is not defined, skipping errors.event.{event} increment in on_error") + self.stats.incr(f"errors.event.{event}") with push_scope() as scope: scope.set_tag("event", event) -- cgit v1.2.3 From 7a817f8e088546b535c0a0d71c08f5abbeb4bb0c Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Mon, 5 Oct 2020 23:38:17 +0200 Subject: fix(bot): refactor of connect_statsd --- bot/bot.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index fbf5eb761..06827c7e6 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -47,14 +47,10 @@ class Bot(commands.Bot): # will effectively disable stats. statsd_url = LOCALHOST - try: - self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") - except socket.gaierror as socket_error: - self.stats = AsyncStatsClient(self.loop, LOCALHOST) - self.loop.call_later(30, self.retry_statsd_connection, statsd_url) - log.warning(f"Statsd client failed to instantiate with error:\n{socket_error}") + self.stats = AsyncStatsClient(self.loop, LOCALHOST) + self.connect_statsd(statsd_url) - def retry_statsd_connection(self, statsd_url: str, retry_after: int = 30, attempt: int = 1) -> None: + def connect_statsd(self, statsd_url: str, retry_after: int = 30, attempt: int = 1) -> None: """Callback used to retry a connection to statsd if it should fail.""" if attempt >= 10: log.error("Reached 10 attempts trying to reconnect AsyncStatsClient. Aborting") @@ -63,9 +59,9 @@ class Bot(commands.Bot): try: self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") except socket.gaierror: - log.warning(f"Statsd client failed to reconnect (Retry attempt: {attempt})") + log.warning(f"Statsd client failed to connect (Attempts: {attempt})") # Use a fallback strategy for retrying, up to 10 times. - self.loop.call_later(retry_after, self.retry_statsd_connection, statsd_url, retry_after * 2, attempt + 1) + self.loop.call_later(retry_after, self.retry_statsd_connection, statsd_url, retry_after ** 2, attempt + 1) async def cache_filter_list_data(self) -> None: """Cache all the data in the FilterList on the site.""" -- cgit v1.2.3 From 3e37bf88c86b0884b327d3eeb165b42860fa2fce Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Mon, 5 Oct 2020 23:39:54 +0200 Subject: fix(bot): better fallback logic --- bot/bot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 06827c7e6..eee940637 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -50,9 +50,9 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, LOCALHOST) self.connect_statsd(statsd_url) - def connect_statsd(self, statsd_url: str, retry_after: int = 30, attempt: int = 1) -> None: + def connect_statsd(self, statsd_url: str, retry_after: int = 2, attempt: int = 1) -> None: """Callback used to retry a connection to statsd if it should fail.""" - if attempt >= 10: + if attempt > 5: log.error("Reached 10 attempts trying to reconnect AsyncStatsClient. Aborting") return @@ -60,7 +60,7 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") except socket.gaierror: log.warning(f"Statsd client failed to connect (Attempts: {attempt})") - # Use a fallback strategy for retrying, up to 10 times. + # Use a fallback strategy for retrying, up to 5 times. self.loop.call_later(retry_after, self.retry_statsd_connection, statsd_url, retry_after ** 2, attempt + 1) async def cache_filter_list_data(self) -> None: -- cgit v1.2.3 From 040ac421a26a270e64d9ed745fe28ee886181fed Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 9 Oct 2020 18:59:58 +0300 Subject: Make bot shutdown remove all other non-extension cogs again --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index 2f366a3ef..10c4c901b 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -110,7 +110,7 @@ class Bot(commands.Bot): await asyncio.gather(*self.closing_tasks) # Now actually do full close of bot - await super(commands.Bot, self).close() + await super().close() await self.api_client.close() -- cgit v1.2.3 From ff5c90bf12f14abb4d0a5bc73af435e53ffc7e3e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 9 Oct 2020 19:35:33 +0300 Subject: Fix calling extensions removing function with wrong name --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index b51e41117..e6d77344e 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -145,7 +145,7 @@ class Bot(commands.Bot): async def close(self) -> None: """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" # Done before super().close() to allow tasks finish before the HTTP session closes. - self.remove_extensions() + self._remove_extensions() # Wait until all tasks that have to be completed before bot is closing is done log.trace("Waiting for tasks before closing.") -- cgit v1.2.3 From 5a5a948efd954c8e878db50b6a5ec480fd97b3ec Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 08:13:19 +0300 Subject: Fix name of extensions removing function --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index b51e41117..e6d77344e 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -145,7 +145,7 @@ class Bot(commands.Bot): async def close(self) -> None: """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" # Done before super().close() to allow tasks finish before the HTTP session closes. - self.remove_extensions() + self._remove_extensions() # Wait until all tasks that have to be completed before bot is closing is done log.trace("Waiting for tasks before closing.") -- cgit v1.2.3 From b702618d8a9189e19c3107c79e23105e288798b0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 08:38:49 +0300 Subject: Get all extensions first for unloading to avoid iteration error --- bot/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index e6d77344e..9a60474b3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -136,7 +136,9 @@ class Bot(commands.Bot): def _remove_extensions(self) -> None: """Remove all extensions to trigger cog unloads.""" - for ext in self.extensions.keys(): + extensions = list(self.extensions.keys()) + + for ext in extensions: try: self.unload_extension(ext) except Exception: -- cgit v1.2.3 From d0af250507371739c652abfcc47efa4a86ce1166 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 08:41:32 +0300 Subject: Use done callback instead of plain try-except inside function --- bot/exts/moderation/watchchannels/_watchchannel.py | 56 ++++++++++++---------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 4715dce14..b576f2888 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -171,38 +171,32 @@ class WatchChannel(metaclass=CogABCMeta): async def consume_messages(self, delay_consumption: bool = True) -> None: """Consumes the message queues to log watched users' messages.""" - try: - if delay_consumption: - self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") - await asyncio.sleep(BigBrotherConfig.log_delay) + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) - self.log.trace("Started consuming the message queue") + self.log.trace("Started consuming the message queue") - # 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() + # 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() - for user_channel_queues in self.consumption_queue.values(): - for channel_queue in user_channel_queues.values(): - while channel_queue: - msg = channel_queue.popleft() + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() - self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") - await self.relay_message(msg) + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) - self.consumption_queue.clear() + self.consumption_queue.clear() - 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)) - else: - self.log.trace("Done consuming messages.") - except asyncio.CancelledError as e: - self.log.exception( - "The consume task was canceled. Messages may be lost.", - exc_info=e - ) + 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)) + else: + self.log.trace("Done consuming messages.") async def webhook_send( self, @@ -348,4 +342,14 @@ class WatchChannel(metaclass=CogABCMeta): """Takes care of unloading the cog and canceling the consumption task.""" self.log.trace("Unloading the cog") if self._consume_task and not self._consume_task.done(): + def done_callback(task: asyncio.Task) -> None: + """Send exception when consuming task have been cancelled.""" + try: + task.exception() + except asyncio.CancelledError: + self.log.error( + f"The consume task of {type(self).__name__} was canceled. Messages may be lost." + ) + + self._consume_task.add_done_callback(done_callback) self._consume_task.cancel() -- cgit v1.2.3 From 8ed147c402a3a6b5e98b29c3ed385460f3216efd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 09:05:00 +0300 Subject: Catch HTTPException when muting user --- bot/exts/moderation/infraction/infractions.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index ccddd4530..b638f4dc6 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -242,8 +242,13 @@ class Infractions(InfractionScheduler, commands.Cog): async def action() -> None: try: await user.add_roles(self._muted_role, reason=reason) - except discord.NotFound: - log.info(f"User {user} ({user.id}) left from guild. Can't give Muted role.") + except discord.HTTPException as e: + if e.code == 10007: + log.info(f"User {user} ({user.id}) left from guild. Can't give Muted role.") + else: + log.warning( + f"Got response {e.code} (HTTP {e.status}) while giving muted role to {user} ({user.id})." + ) else: log.trace(f"Attempting to kick {user} from voice because they've been muted.") await user.move_to(None, reason=reason) -- cgit v1.2.3 From 382ad7708eb5dadff30a89da33f2fba9f53cd8c6 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Wed, 4 Nov 2020 15:43:22 -0800 Subject: User command gets verification time and message count. --- bot/exts/info/information.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 5aaf85e5a..c83dfadc5 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,6 +6,7 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union +from dateutil import parser from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role @@ -21,7 +22,6 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) - STATUS_EMOTES = { Status.offline: constants.Emojis.status_offline, Status.dnd: constants.Emojis.status_dnd, @@ -254,6 +254,7 @@ class Information(Cog): if is_mod_channel(ctx.channel): fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) + fields.append(await self.user_verification_and_messages(user)) else: fields.append(await self.basic_user_infraction_counts(user)) @@ -354,6 +355,25 @@ class Information(Cog): return "Nominations", "\n".join(output) + async def user_verification_and_messages(self, user: FetchedMember) -> Tuple[str, str]: + """Gets the time of verification and amount of messages for `member`.""" + user_activity = await self.bot.api_client.get(f'bot/users/{user.id}/metricity_data') + + activity_output = [] + + if user_activity['verified_at'] is not None: + verified_delta_formatted = time_since(parser.isoparse(user_activity['verified_at']), max_units=3) + activity_output.append(f'This user verified {verified_delta_formatted}') + else: + activity_output.append('This user is not verified.') + + if user_activity['total_messages']: + activity_output.append(f"This user has a total of {user_activity['total_messages']} messages.") + else: + activity_output.append(f"This user has not sent any messages on this server.") + + return "Activity", "\n".join(activity_output) + def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" # sorting is technically superfluous but nice if you want to look for a specific field -- cgit v1.2.3 From e42cdaed973408c0753366401adb946e8402d082 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Fri, 6 Nov 2020 18:12:23 -0800 Subject: Moved activity data further up in embed. --- bot/exts/info/information.py | 45 ++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c83dfadc5..c4c73efdf 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -12,6 +12,7 @@ from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from bot import constants +from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import FetchedMember from bot.decorators import in_whitelist @@ -235,14 +236,18 @@ class Information(Cog): roles = None membership = "The user is not a member of the server" + verified_at, activity = await self.user_verification_and_messages(user) + verified_at = f"Verified: {verified_at}" if is_mod_channel(ctx.channel) else "" + fields = [ ( "User information", textwrap.dedent(f""" Created: {created} + {verified_at} Profile: {user.mention} ID: {user.id} - """).strip() + """).strip().replace("\n\n", "\n") ), ( "Member information", @@ -252,9 +257,10 @@ class Information(Cog): # Show more verbose output in moderation channels for infractions and nominations if is_mod_channel(ctx.channel): + fields.append(activity) + fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) - fields.append(await self.user_verification_and_messages(user)) else: fields.append(await self.basic_user_infraction_counts(user)) @@ -355,24 +361,35 @@ class Information(Cog): return "Nominations", "\n".join(output) - async def user_verification_and_messages(self, user: FetchedMember) -> Tuple[str, str]: + async def user_verification_and_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: """Gets the time of verification and amount of messages for `member`.""" - user_activity = await self.bot.api_client.get(f'bot/users/{user.id}/metricity_data') - activity_output = [] - if user_activity['verified_at'] is not None: - verified_delta_formatted = time_since(parser.isoparse(user_activity['verified_at']), max_units=3) - activity_output.append(f'This user verified {verified_delta_formatted}') + try: + user_activity = await self.bot.api_client.get(f'bot/users/{user.id}/metricity_data') + except ResponseCodeError as e: + verified_at = False + activity_output = f"{e.status}: No activity" else: - activity_output.append('This user is not verified.') + if user_activity['verified_at'] is not None: + verified_at = time_since(parser.isoparse(user_activity["verified_at"]), max_units=3) + else: + verified_at = "Not verified" - if user_activity['total_messages']: - activity_output.append(f"This user has a total of {user_activity['total_messages']} messages.") - else: - activity_output.append(f"This user has not sent any messages on this server.") + if user_activity["total_messages"]: + activity_output.append(user_activity['total_messages']) + else: + activity_output.append("No messages") + + if user_activity["activity_blocks"]: + activity_output.append(user_activity["activity_blocks"]) + else: + activity_output.append("No activity") + + activity_output = "\n".join( + f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output)) - return "Activity", "\n".join(activity_output) + return verified_at, ("Activity", activity_output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" -- cgit v1.2.3 From 027666f95ccaf07dfc73d2bfb7487e5a61bcd2d2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 7 Nov 2020 09:45:19 +0200 Subject: Remove both cogs and extensions on closing --- bot/bot.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 9a60474b3..fbd97dc18 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -3,6 +3,7 @@ import logging import socket import warnings from collections import defaultdict +from contextlib import suppress from typing import Dict, List, Optional import aiohttp @@ -134,20 +135,12 @@ class Bot(commands.Bot): self._recreate() super().clear() - def _remove_extensions(self) -> None: - """Remove all extensions to trigger cog unloads.""" - extensions = list(self.extensions.keys()) - - for ext in extensions: - try: - self.unload_extension(ext) - except Exception: - pass - async def close(self) -> None: """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" # Done before super().close() to allow tasks finish before the HTTP session closes. - self._remove_extensions() + with suppress(Exception): + [self.unload_extension(ext) for ext in tuple(self.extensions)] + [self.remove_cog(cog) for cog in tuple(self.cogs)] # Wait until all tasks that have to be completed before bot is closing is done log.trace("Waiting for tasks before closing.") -- cgit v1.2.3 From d15c4fc004e73669014baa25c675a7bf7b8064f9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 7 Nov 2020 09:47:05 +0200 Subject: Use result instead exception for watchchannel closing task --- bot/exts/moderation/watchchannels/_watchchannel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index b576f2888..8894762f3 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -345,7 +345,7 @@ class WatchChannel(metaclass=CogABCMeta): def done_callback(task: asyncio.Task) -> None: """Send exception when consuming task have been cancelled.""" try: - task.exception() + task.result() except asyncio.CancelledError: self.log.error( f"The consume task of {type(self).__name__} was canceled. Messages may be lost." -- cgit v1.2.3 From 6a81f714c6648d7dd12982b38c7161cdee9e602e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 7 Nov 2020 09:57:23 +0200 Subject: Catch not found exception in scheduler --- bot/exts/moderation/infraction/_scheduler.py | 29 ++++++++++++++++++++++++--- bot/exts/moderation/infraction/infractions.py | 16 ++++----------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index ed67e3b26..6efa5b1e0 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -79,6 +79,16 @@ class InfractionScheduler: except discord.NotFound: # When user joined and then right after this left again before action completed, this can't add roles log.info(f"Can't reapply {infraction['type']} to user {infraction['user']} because user left again.") + except discord.HTTPException as e: + if e.code == 10007: + log.info(f"Can't reapply {infraction['type']} to user {infraction['user']} because user left again.") + else: + log.warning( + ( + f"Got unexpected HTTPException (HTTP {e.status}, Discord code {e.code})" + f"when awaiting {infraction['type']} coroutine for {infraction['user']}." + ) + ) else: log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") @@ -160,6 +170,8 @@ class InfractionScheduler: if expiry: # Schedule the expiration of the infraction. self.schedule_expiration(infraction) + except discord.NotFound: + log.info(f"Can't apply {infraction['type']} to user {infraction['user']} because user left from guild.") except discord.HTTPException as e: # Accordingly display that applying the infraction failed. # Don't use ctx.message.author; antispam only patches ctx.author. @@ -171,6 +183,10 @@ class InfractionScheduler: log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}" if isinstance(e, discord.Forbidden): log.warning(f"{log_msg}: bot lacks permissions.") + elif e.code == 10007: + log.info( + f"Can't apply {infraction['type']} to user {infraction['user']} because user left from guild." + ) else: log.exception(log_msg) failed = True @@ -342,10 +358,17 @@ class InfractionScheduler: log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" log_content = mod_role.mention + except discord.NotFound: + log.info(f"Can't pardon {infraction['type']} for user {infraction['user']} because user left from guild.") except discord.HTTPException as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") - log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." - log_content = mod_role.mention + if e.code == 10007: + log.info( + f"Can't pardon {infraction['type']} for user {infraction['user']} because user left from guild." + ) + else: + log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." + log_content = mod_role.mention # Check if the user is currently being watched by Big Brother. try: diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 8abb199db..746d4e154 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -277,18 +277,10 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) async def action() -> None: - try: - await user.add_roles(self._muted_role, reason=reason) - except discord.HTTPException as e: - if e.code == 10007: - log.info(f"User {user} ({user.id}) left from guild. Can't give Muted role.") - else: - log.warning( - f"Got response {e.code} (HTTP {e.status}) while giving muted role to {user} ({user.id})." - ) - else: - log.trace(f"Attempting to kick {user} from voice because they've been muted.") - await user.move_to(None, reason=reason) + await user.add_roles(self._muted_role, reason=reason) + + log.trace(f"Attempting to kick {user} from voice because they've been muted.") + await user.move_to(None, reason=reason) await self.apply_infraction(ctx, infraction, user, action()) -- cgit v1.2.3 From d2c1b270cebfcca5f081daceeda3dadfa28313e1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 7 Nov 2020 18:45:51 +0200 Subject: Catch CommandError for help command can_run await --- bot/exts/info/help.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 5e0651f8a..44bff1f88 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -5,7 +5,7 @@ from contextlib import suppress from typing import List, Union from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand +from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand, DisabledCommand, CommandError from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process @@ -173,12 +173,16 @@ class CustomHelpCommand(HelpCommand): if aliases: command_details += f"**Can also use:** {aliases}\n\n" - # when command is disabled, show message about it + # when command is disabled, show message about it, + # when other CommandError instance is raised, log warning about it # otherwise check if the user is allowed to run this command - if not command.enabled: + try: + if not await command.can_run(self.context): + command_details += "***You cannot run this command.***\n\n" + except DisabledCommand: command_details += "***This command is disabled.***\n\n" - elif not await command.can_run(self.context): - command_details += "***You cannot run this command.***\n\n" + except CommandError as e: + log.warning(f"An exception raised when trying to check {command.name} command running permission: {e}") command_details += f"*{command.help or 'No details provided.'}*\n" embed.description = command_details -- cgit v1.2.3 From 0390bb8936d5c8aaeb9f5ddf5fccc9970ae18cad Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 7 Nov 2020 19:39:03 +0200 Subject: Fix import order of help command file --- bot/exts/info/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 44bff1f88..9d7f0702e 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -5,7 +5,7 @@ from contextlib import suppress from typing import List, Union from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand, DisabledCommand, CommandError +from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process -- cgit v1.2.3 From 472faca4b19419f1101258c876fc5fbbd7da8f3a Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 8 Nov 2020 15:03:46 +0800 Subject: Remove unnecessary noqa pragma for flake8 --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 8aeb45f96..97fc7b1d8 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -49,7 +49,7 @@ class ModManagement(commands.Cog): async def infraction_append( self, ctx: Context, - infraction: Infraction, # noqa: F821 + infraction: Infraction, duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 *, reason: str = None @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): async def infraction_edit( self, ctx: Context, - infraction: Infraction, # noqa: F821 + infraction: Infraction, duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 *, reason: str = None -- cgit v1.2.3 From 3e73fd76d73b7e84888af46e0b6b47a1dd4003d3 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 8 Nov 2020 15:16:30 +0800 Subject: Raise BadArgument in the Infraction converter --- bot/converters.py | 9 ++++----- bot/exts/moderation/infraction/management.py | 6 ------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 962416238..f350e863e 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -568,12 +568,11 @@ class Infraction(Converter): infractions = await ctx.bot.api_client.get("bot/infractions", params=params) if not infractions: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." + raise BadArgument( + "Couldn't find most recent infraction; you have never given an infraction." ) - return None - - return infractions[0] + else: + return infractions[0] else: return ctx.bot.api_client.get(f"bot/infractions/{arg}") diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 97fc7b1d8..49ddfa473 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -73,9 +73,6 @@ class ModManagement(commands.Cog): Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - if not infraction: - return - await self.infraction_edit( ctx=ctx, infraction=infraction, @@ -115,9 +112,6 @@ class ModManagement(commands.Cog): # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - if not infraction: - return - old_infraction = infraction infraction_id = infraction["id"] -- cgit v1.2.3 From de0b6984cc1947f5454939b4e20a09e6eeaffa98 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 8 Nov 2020 15:23:03 +0800 Subject: Refactor redundant code in infraction_edit --- bot/exts/moderation/infraction/management.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 49ddfa473..88b2f98c6 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -112,14 +112,13 @@ class ModManagement(commands.Cog): # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - old_infraction = infraction infraction_id = infraction["id"] request_data = {} confirm_messages = [] log_text = "" - if duration is not None and not old_infraction['active']: + if duration is not None and not infraction['active']: if reason is None: await ctx.send(":x: Cannot edit the expiration of an expired infraction.") return @@ -138,7 +137,7 @@ class ModManagement(commands.Cog): request_data['reason'] = reason confirm_messages.append("set a new reason") log_text += f""" - Previous reason: {old_infraction['reason']} + Previous reason: {infraction['reason']} New reason: {reason} """.rstrip() else: @@ -153,7 +152,7 @@ class ModManagement(commands.Cog): # Re-schedule infraction if the expiration has been updated if 'expires_at' in request_data: # A scheduled task should only exist if the old infraction wasn't permanent - if old_infraction['expires_at']: + if infraction['expires_at']: self.infractions_cog.scheduler.cancel(new_infraction['id']) # If the infraction was not marked as permanent, schedule a new expiration task @@ -161,7 +160,7 @@ class ModManagement(commands.Cog): self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" - Previous expiry: {old_infraction['expires_at'] or "Permanent"} + Previous expiry: {infraction['expires_at'] or "Permanent"} New expiry: {new_infraction['expires_at'] or "Permanent"} """.rstrip() -- cgit v1.2.3 From af7cfd35945f7885a7cd36490aa0ffcf91218c8b Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 8 Nov 2020 16:03:11 +0800 Subject: Automatically add periods as visual buffers --- bot/exts/moderation/infraction/management.py | 17 +++++++++++------ bot/utils/regex.py | 2 ++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 88b2f98c6..e6513c32d 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -16,6 +16,7 @@ from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.checks import in_whitelist_check +from bot.utils.regex import END_PUNCTUATION_RE log = logging.getLogger(__name__) @@ -72,13 +73,17 @@ class ModManagement(commands.Cog): Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp can be provided for the duration. + + If a previous infraction reason does not end with an ending punctuation mark, this automatically + adds a period before the amended reason. """ - await self.infraction_edit( - ctx=ctx, - infraction=infraction, - duration=duration, - reason=fr"{infraction['reason']} **\|\|** {reason}", - ) + add_period = not END_PUNCTUATION_RE.match(infraction["reason"]) + + new_reason = "".join(( + infraction["reason"], ". " if add_period else " ", reason, + )) + + await self.infraction_edit(ctx, infraction, duration, reason=new_reason) @infraction_group.command(name='edit') async def infraction_edit( diff --git a/bot/utils/regex.py b/bot/utils/regex.py index 0d2068f90..cfce52bb3 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -10,3 +10,5 @@ INVITE_RE = re.compile( r"([a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE ) + +END_PUNCTUATION_RE = re.compile("^.+?[.?!]$") -- cgit v1.2.3 From fca8b814df974b4c30e14a72d48681da77259899 Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Mon, 9 Nov 2020 18:15:00 +0100 Subject: fix(bot): statds pr review suggestions --- bot/bot.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index eee940637..b097513f1 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -37,6 +37,7 @@ class Bot(commands.Bot): self._connector = None self._resolver = None + self._statsd_timerhandle: asyncio.TimerHandle = None self._guild_available = asyncio.Event() statsd_url = constants.Stats.statsd_host @@ -48,20 +49,24 @@ class Bot(commands.Bot): statsd_url = LOCALHOST self.stats = AsyncStatsClient(self.loop, LOCALHOST) - self.connect_statsd(statsd_url) + self._connect_statsd(statsd_url) - def connect_statsd(self, statsd_url: str, retry_after: int = 2, attempt: int = 1) -> None: + def _connect_statsd(self, statsd_url: str, retry_after: int = 2, attempt: int = 1) -> None: """Callback used to retry a connection to statsd if it should fail.""" - if attempt > 5: - log.error("Reached 10 attempts trying to reconnect AsyncStatsClient. Aborting") + if self._statsd_timerhandle and not self._statsd_timerhandle.cancelled: + self._statsd_timerhandle.cancel() + + if attempt >= 5: + log.error("Reached 5 attempts trying to reconnect AsyncStatsClient. Aborting") return try: self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") except socket.gaierror: - log.warning(f"Statsd client failed to connect (Attempts: {attempt})") + log.warning(f"Statsd client failed to connect (Attempt(s): {attempt})") # Use a fallback strategy for retrying, up to 5 times. - self.loop.call_later(retry_after, self.retry_statsd_connection, statsd_url, retry_after ** 2, attempt + 1) + self._statsd_timerhandle = self.loop.call_later( + retry_after, self._connect_statsd, statsd_url, retry_after * 5, attempt + 1) async def cache_filter_list_data(self) -> None: """Cache all the data in the FilterList on the site.""" @@ -167,6 +172,9 @@ class Bot(commands.Bot): if self.redis_session: await self.redis_session.close() + if self._statsd_timerhandle and not self._statsd_timerhandle.cancelled: + self._statsd_timerhandle.cancel() + def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None: """Add an item to the bots filter_list_cache.""" type_ = item["type"] -- cgit v1.2.3 From 097298e260f0d1d84a8442e5c267042424314f3e Mon Sep 17 00:00:00 2001 From: Xithrius Date: Wed, 11 Nov 2020 18:09:30 -0800 Subject: Changed logic of membership info creation. --- bot/exts/info/information.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c4c73efdf..a8adb817b 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -225,29 +225,34 @@ class Information(Cog): if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): badges.append(emoji) + verified_at, activity = await self.user_verification_and_messages(user) + if on_server: joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - membership = textwrap.dedent(f""" - Joined: {joined} - Roles: {roles or None} - """).strip() + if is_mod_channel(ctx.channel): + membership = textwrap.dedent(f""" + Joined: {joined} + Verified: {verified_at} + Roles: {roles or None} + """).strip() + else: + membership = textwrap.dedent(f""" + Joined: {joined} + Roles: {roles or None} + """).strip() else: roles = None membership = "The user is not a member of the server" - verified_at, activity = await self.user_verification_and_messages(user) - verified_at = f"Verified: {verified_at}" if is_mod_channel(ctx.channel) else "" - fields = [ ( "User information", textwrap.dedent(f""" Created: {created} - {verified_at} Profile: {user.mention} ID: {user.id} - """).strip().replace("\n\n", "\n") + """).strip() ), ( "Member information", @@ -364,17 +369,18 @@ class Information(Cog): async def user_verification_and_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: """Gets the time of verification and amount of messages for `member`.""" activity_output = [] + verified_at = False try: user_activity = await self.bot.api_client.get(f'bot/users/{user.id}/metricity_data') except ResponseCodeError as e: - verified_at = False - activity_output = f"{e.status}: No activity" + if e.status == 404: + activity_output = "No activity" + else: - if user_activity['verified_at'] is not None: + verified_at = user_activity['verified_at'] + if verified_at is not None: verified_at = time_since(parser.isoparse(user_activity["verified_at"]), max_units=3) - else: - verified_at = "Not verified" if user_activity["total_messages"]: activity_output.append(user_activity['total_messages']) -- cgit v1.2.3 From 51c4ecd2b5b0afedcdfcf2d3c85100a312720a09 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 12 Nov 2020 09:09:58 +0100 Subject: Remove selenium from the element list This could lead to some confusion with the users believing that this channel is reserved to help related to the selenium tool. --- bot/resources/elements.json | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/elements.json b/bot/resources/elements.json index 2dc9b6fd6..a3ac5b99f 100644 --- a/bot/resources/elements.json +++ b/bot/resources/elements.json @@ -32,7 +32,6 @@ "gallium", "germanium", "arsenic", - "selenium", "bromine", "krypton", "rubidium", -- cgit v1.2.3 From 5481e3cc304ac82d8e642220fa4f06c2f9e5795e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Nov 2020 20:19:19 +0200 Subject: Add months Enum to constants --- bot/constants.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 731f06fed..64f3bce98 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -13,7 +13,7 @@ their default values from `config-default.yml`. import logging import os from collections.abc import Mapping -from enum import Enum +from enum import Enum, IntEnum from pathlib import Path from typing import Dict, List, Optional @@ -631,6 +631,24 @@ class Event(Enum): voice_state_update = "voice_state_update" +class Month(IntEnum): + JANUARY = 1 + FEBRUARY = 2 + MARCH = 3 + APRIL = 4 + MAY = 5 + JUNE = 6 + JULY = 7 + AUGUST = 8 + SEPTEMBER = 9 + OCTOBER = 10 + NOVEMBER = 11 + DECEMBER = 12 + + def __str__(self) -> str: + return self.name.title() + + # Debug mode DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False -- cgit v1.2.3 From 91fb33becdae3b40a05737e5db4073572a482fe4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Nov 2020 20:19:32 +0200 Subject: Add BrandingError to errors --- bot/errors.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/errors.py b/bot/errors.py index 65d715203..a3484830b 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -18,3 +18,9 @@ class LockedResourceError(RuntimeError): f"Cannot operate on {self.type.lower()} `{self.id}`; " "it is currently locked and in use by another operation." ) + + +class BrandingError(Exception): + """Exception raised by the BrandingManager cog.""" + + pass -- cgit v1.2.3 From 84089edb69e96935a8ad45ca6baeabc73c009046 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Nov 2020 20:20:03 +0200 Subject: Port seasons from SeasonalBot to here --- bot/seasons.py | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 bot/seasons.py diff --git a/bot/seasons.py b/bot/seasons.py new file mode 100644 index 000000000..3b39f03cd --- /dev/null +++ b/bot/seasons.py @@ -0,0 +1,181 @@ +import logging +import typing as t +from datetime import datetime + +from bot.constants import Colours, Month +from bot.errors import BrandingError + +log = logging.getLogger(__name__) + + +class SeasonBase: + """ + Base for Seasonal classes. + + This serves as the off-season fallback for when no specific + seasons are active. + + Seasons are 'registered' simply by inheriting from `SeasonBase`. + We discover them by calling `__subclasses__`. + """ + + season_name: str = "Evergreen" + bot_name: str = "SeasonalBot" + + colour: str = Colours.soft_green + description: str = "The default season!" + + branding_path: str = "seasonal/evergreen" + + months: t.Set[Month] = set(Month) + + +class Christmas(SeasonBase): + """Branding for December.""" + + season_name = "Festive season" + bot_name = "MerryBot" + + colour = Colours.soft_red + description = ( + "The time is here to get into the festive spirit! No matter who you are, where you are, " + "or what beliefs you may follow, we hope every one of you enjoy this festive season!" + ) + + branding_path = "seasonal/christmas" + + months = {Month.DECEMBER} + + +class Easter(SeasonBase): + """Branding for April.""" + + season_name = "Easter" + bot_name = "BunnyBot" + + colour = Colours.bright_green + description = ( + "Bunny here, bunny there, bunny everywhere! Here at Python Discord, we celebrate " + "our version of Easter during the entire month of April." + ) + + branding_path = "seasonal/easter" + + months = {Month.APRIL} + + +class Halloween(SeasonBase): + """Branding for October.""" + + season_name = "Halloween" + bot_name = "NeonBot" + + colour = Colours.orange + description = "Trick or treat?!" + + branding_path = "seasonal/halloween" + + months = {Month.OCTOBER} + + +class Pride(SeasonBase): + """Branding for June.""" + + season_name = "Pride" + bot_name = "ProudBot" + + colour = Colours.pink + description = ( + "The month of June is a special month for us at Python Discord. It is very important to us " + "that everyone feels welcome here, no matter their origin, identity or sexuality. During the " + "month of June, while some of you are participating in Pride festivals across the world, " + "we will be celebrating individuality and commemorating the history and challenges " + "of the LGBTQ+ community with a Pride event of our own!" + ) + + branding_path = "seasonal/pride" + + months = {Month.JUNE} + + +class Valentines(SeasonBase): + """Branding for February.""" + + season_name = "Valentines" + bot_name = "TenderBot" + + colour = Colours.pink + description = "Love is in the air!" + + branding_path = "seasonal/valentines" + + months = {Month.FEBRUARY} + + +class Wildcard(SeasonBase): + """Branding for August.""" + + season_name = "Wildcard" + bot_name = "RetroBot" + + colour = Colours.purple + description = "A season full of surprises!" + + months = {Month.AUGUST} + + +def get_all_seasons() -> t.List[t.Type[SeasonBase]]: + """Give all available season classes.""" + return [SeasonBase] + SeasonBase.__subclasses__() + + +def get_current_season() -> t.Type[SeasonBase]: + """Give active season, based on current UTC month.""" + current_month = Month(datetime.utcnow().month) + + active_seasons = tuple( + season + for season in SeasonBase.__subclasses__() + if current_month in season.months + ) + + if not active_seasons: + return SeasonBase + + return active_seasons[0] + + +def get_season(name: str) -> t.Optional[t.Type[SeasonBase]]: + """ + Give season such that its class name or its `season_name` attr match `name` (caseless). + + If no such season exists, return None. + """ + name = name.casefold() + + for season in get_all_seasons(): + matches = (season.__name__.casefold(), season.season_name.casefold()) + + if name in matches: + return season + + +def _validate_season_overlap() -> None: + """ + Raise BrandingError if there are any colliding seasons. + + This serves as a local test to ensure that seasons haven't been misconfigured. + """ + month_to_season = {} + + for season in SeasonBase.__subclasses__(): + for month in season.months: + colliding_season = month_to_season.get(month) + + if colliding_season: + raise BrandingError(f"Season {season} collides with {colliding_season} in {month.name}") + else: + month_to_season[month] = season + + +_validate_season_overlap() -- cgit v1.2.3 From 7742078f970adfc66b0c2eb3b47578631321a0f5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Nov 2020 20:35:44 +0200 Subject: Add basic branding configuration --- bot/constants.py | 6 ++++++ config-default.yml | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 64f3bce98..ed298f41a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -605,6 +605,12 @@ class VoiceGate(metaclass=YAMLGetter): voice_ping_delete_delay: int +class Branding(metaclass=YAMLGetter): + section = "branding" + + cycle_frequency: int + + class Event(Enum): """ Event names. This does not include every event (for example, raw diff --git a/config-default.yml b/config-default.yml index 8912841ff..91ecf3107 100644 --- a/config-default.yml +++ b/config-default.yml @@ -525,5 +525,9 @@ voice_gate: voice_ping_delete_delay: 60 # Seconds before deleting the bot's ping to user in Voice Gate +branding: + cycle_frequency: 3 # How many days bot wait before refreshing server icon + + config: required_keys: ['bot.token'] -- cgit v1.2.3 From f8d070664783d7a4b86a6b3dc0646a2ae100ee5c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Nov 2020 20:42:44 +0200 Subject: Create config for tokens and add GitHub token --- bot/constants.py | 6 ++++++ config-default.yml | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index ed298f41a..1b7a09244 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -611,6 +611,12 @@ class Branding(metaclass=YAMLGetter): cycle_frequency: int +class Tokens(metaclass=YAMLGetter): + section = "tokens" + + github: str + + class Event(Enum): """ Event names. This does not include every event (for example, raw diff --git a/config-default.yml b/config-default.yml index 91ecf3107..fa2841f0c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -529,5 +529,9 @@ branding: cycle_frequency: 3 # How many days bot wait before refreshing server icon +tokens: + github: !ENV "GITHUB_TOKEN" + + config: required_keys: ['bot.token'] -- cgit v1.2.3 From 5be57981d058ee0bd480d68f58c3047341aa911e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Nov 2020 20:44:49 +0200 Subject: Add arrow dependency for branding --- Pipfile | 1 + Pipfile.lock | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Pipfile b/Pipfile index 0730b9150..38bd9013a 100644 --- a/Pipfile +++ b/Pipfile @@ -26,6 +26,7 @@ requests = "~=2.22" sentry-sdk = "~=0.14" sphinx = "~=2.2" statsd = "~=3.3" +arrow = "~=0.17" [dev-packages] coverage = "~=5.0" diff --git a/Pipfile.lock b/Pipfile.lock index 6a6a1aaf6..79c559e30 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ca6b100f7ee2e6e01eec413a754fc11be064e965a255b2c4927d4a2dd1c451ec" + "sha256": "d08ba836e630ae64a560ef879216d24801e8069dddf7d51b00e885efcf24c2ff" }, "pipfile-spec": 6, "requires": { @@ -82,6 +82,14 @@ ], "version": "==0.7.12" }, + "arrow": { + "hashes": [ + "sha256:e098abbd9af3665aea81bdd6c869e93af4feb078e98468dd351c383af187aac5", + "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4" + ], + "index": "pypi", + "version": "==0.17.0" + }, "async-rediscache": { "extras": [ "fakeredis" -- cgit v1.2.3 From 0d167d9a331d0a1e50358d7502b3e88f8855d5a6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Nov 2020 20:49:18 +0200 Subject: Create AssetType enum --- bot/constants.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 1b7a09244..eae9af006 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -661,6 +661,17 @@ class Month(IntEnum): return self.name.title() +class AssetType(Enum): + """ + Discord media assets. + + The values match exactly the kwarg keys that can be passed to `Guild.edit`. + """ + + BANNER = "banner" + SERVER_ICON = "icon" + + # Debug mode DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False -- cgit v1.2.3 From 73229e32a6a47cb78eaf15ffbd88d790b67effd6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 17 Nov 2020 16:14:22 +0200 Subject: Add required colors and emoji for branding management --- bot/constants.py | 6 ++++++ config-default.yml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index eae9af006..57d8928fa 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -248,6 +248,10 @@ class Colours(metaclass=YAMLGetter): soft_red: int soft_green: int soft_orange: int + bright_green: int + orange: int + pink: int + purple: int class DuckPond(metaclass=YAMLGetter): @@ -298,6 +302,8 @@ class Emojis(metaclass=YAMLGetter): comments: str user: str + ok_hand: str + class Icons(metaclass=YAMLGetter): section = "style" diff --git a/config-default.yml b/config-default.yml index fa2841f0c..e7163fbe0 100644 --- a/config-default.yml +++ b/config-default.yml @@ -27,6 +27,10 @@ style: soft_red: 0xcd6d6d soft_green: 0x68c290 soft_orange: 0xf9cb54 + bright_green: 0x01d277 + orange: 0xe67e22 + pink: 0xcf84e0 + purple: 0xb734eb emojis: defcon_disabled: "<:defcondisabled:470326273952972810>" @@ -67,6 +71,8 @@ style: comments: "<:reddit_comments:755845255001014384>" user: "<:reddit_users:755845303822974997>" + ok_hand: ":ok_hand:" + icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" -- cgit v1.2.3 From df55f3f74a2119543c22f4c07c50f128359d7df1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 17 Nov 2020 16:18:54 +0200 Subject: Create mock_in_debug decorator for branding --- bot/decorators.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/bot/decorators.py b/bot/decorators.py index 063c8f878..0b50cc365 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,4 +1,5 @@ import asyncio +import functools import logging import typing as t from contextlib import suppress @@ -8,7 +9,7 @@ from discord import Member, NotFound from discord.ext import commands from discord.ext.commands import Cog, Context -from bot.constants import Channels, RedirectOutput +from bot.constants import Channels, DEBUG_MODE, RedirectOutput from bot.utils import function from bot.utils.checks import in_whitelist_check @@ -153,3 +154,23 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: await func(*args, **kwargs) return wrapper return decorator + + +def mock_in_debug(return_value: t.Any) -> t.Callable: + """ + Short-circuit function execution if in debug mode and return `return_value`. + + The original function name, and the incoming args and kwargs are DEBUG level logged + upon each call. This is useful for expensive operations, i.e. media asset uploads + that are prone to rate-limits but need to be tested extensively. + """ + def decorator(func: t.Callable) -> t.Callable: + @functools.wraps(func) + async def wrapped(*args, **kwargs) -> t.Any: + """Short-circuit and log if in debug mode.""" + if DEBUG_MODE: + log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}") + return return_value + return await func(*args, **kwargs) + return wrapped + return decorator -- cgit v1.2.3 From 83b4a1b2feefc61678c12143a0eed30b2908f2e2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 17 Nov 2020 16:29:39 +0200 Subject: Handle branding error in error handler --- bot/exts/backend/error_handler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index c643d346e..6fb5bcf98 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,5 +1,6 @@ import contextlib import logging +import random import typing as t from discord import Embed @@ -8,9 +9,9 @@ from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours +from bot.constants import Channels, Colours, ERROR_REPLIES from bot.converters import TagNameConverter -from bot.errors import LockedResourceError +from bot.errors import LockedResourceError, BrandingError from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) @@ -78,6 +79,9 @@ class ErrorHandler(Cog): await self.handle_api_error(ctx, e.original) elif isinstance(e.original, LockedResourceError): await ctx.send(f"{e.original} Please wait for it to finish and try again later.") + elif isinstance(e.original, BrandingError): + await ctx.send(embed=self._get_error_embed(random.choice(ERROR_REPLIES), str(e.original))) + return else: await self.handle_unexpected_error(ctx, e.original) return # Exit early to avoid logging. -- cgit v1.2.3 From 6da1bedd7ba8fa9b0bc26a27fa60356dd3ca6820 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 17 Nov 2020 16:32:24 +0200 Subject: Port branding management to this bot from SeasonalBot --- bot/exts/backend/branding.py | 569 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 569 insertions(+) create mode 100644 bot/exts/backend/branding.py diff --git a/bot/exts/backend/branding.py b/bot/exts/backend/branding.py new file mode 100644 index 000000000..286b6e0b1 --- /dev/null +++ b/bot/exts/backend/branding.py @@ -0,0 +1,569 @@ +import asyncio +import itertools +import logging +import random +import typing as t +from datetime import datetime, time, timedelta + +import arrow +import async_timeout +import discord +from async_rediscache import RedisCache +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import AssetType, Branding, Colours, Emojis, Guild, MODERATION_ROLES, Tokens +from bot.seasons import SeasonBase, get_all_seasons, get_current_season, get_season +from bot.decorators import in_whitelist, mock_in_debug +from bot.errors import BrandingError + +log = logging.getLogger(__name__) + +STATUS_OK = 200 # HTTP status code + +FILE_BANNER = "banner.png" +FILE_AVATAR = "avatar.png" +SERVER_ICONS = "server_icons" + +BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" + +PARAMS = {"ref": "master"} # Target branch +HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3 + +# A GitHub token is not necessary for the cog to operate, +# unauthorized requests are however limited to 60 per hour +if Tokens.github: + HEADERS["Authorization"] = f"token {Tokens.github}" + + +class GitHubFile(t.NamedTuple): + """ + Represents a remote file on GitHub. + + The `sha` hash is kept so that we can determine that a file has changed, + despite its filename remaining unchanged. + """ + + download_url: str + path: str + sha: str + + +def pretty_files(files: t.Iterable[GitHubFile]) -> str: + """Provide a human-friendly representation of `files`.""" + return "\n".join(file.path for file in files) + + +def time_until_midnight() -> timedelta: + """ + Determine amount of time until the next-up UTC midnight. + + The exact `midnight` moment is actually delayed to 5 seconds after, in order + to avoid potential problems due to imprecise sleep. + """ + now = datetime.utcnow() + tomorrow = now + timedelta(days=1) + midnight = datetime.combine(tomorrow, time(second=5)) + + return midnight - now + + +class BrandingManager(commands.Cog): + """ + Manages the guild's branding. + + The purpose of this cog is to help automate the synchronization of the branding + repository with the guild. It is capable of discovering assets in the repository + via GitHub's API, resolving download urls for them, and delegating + to the `bot` instance to upload them to the guild. + + BrandingManager is designed to be entirely autonomous. Its `daemon` background task awakens + once a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single + season. The daemon can be turned on and off via the `daemon` cmd group. The value set via + its `start` and `stop` commands is persisted across sessions. If turned on, the daemon will + automatically start on the next bot start-up. Otherwise, it will wait to be started manually. + + All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can + also be invoked manually, via the following API: + + branding list + - Show all available seasons + + branding set + - Set the cog's internal state to represent `season_name`, if it exists + - If no `season_name` is given, set chronologically current season + - This will not automatically apply the season's branding to the guild, + the cog's state can be detached from the guild + - Seasons can therefore be 'previewed' using this command + + branding info + - View detailed information about resolved assets for current season + + branding refresh + - Refresh internal state, i.e. synchronize with branding repository + + branding apply + - Apply the current internal state to the guild, i.e. upload the assets + + branding cycle + - If there are multiple available icons for current season, randomly pick + and apply the next one + + The daemon calls these methods autonomously as appropriate. The use of this cog + is locked to moderation roles. As it performs media asset uploads, it is prone to + rate-limits - the `apply` command should be used with caution. The `set` command can, + however, be used freely to 'preview' seasonal branding and check whether paths have been + resolved as appropriate. + + While the bot is in debug mode, it will 'mock' asset uploads by logging the passed + download urls and pretending that the upload was successful. Make use of this + to test this cog's behaviour. + """ + + current_season: t.Type[SeasonBase] + + banner: t.Optional[GitHubFile] + + available_icons: t.List[GitHubFile] + remaining_icons: t.List[GitHubFile] + + days_since_cycle: t.Iterator + + daemon: t.Optional[asyncio.Task] + + # Branding configuration + branding_configuration = RedisCache() + + def __init__(self, bot: Bot) -> None: + """ + Assign safe default values on init. + + At this point, we don't have information about currently available branding. + Most of these attributes will be overwritten once the daemon connects, or once + the `refresh` command is used. + """ + self.bot = bot + self.current_season = get_current_season() + + self.banner = None + + self.available_icons = [] + self.remaining_icons = [] + + self.days_since_cycle = itertools.cycle([None]) + + should_run = self.bot.loop.run_until_complete(self.branding_configuration.get("daemon_active")) + + if should_run: + self.daemon = self.bot.loop.create_task(self._daemon_func()) + else: + self.daemon = None + + @property + def _daemon_running(self) -> bool: + """True if the daemon is currently active, False otherwise.""" + return self.daemon is not None and not self.daemon.done() + + async def _daemon_func(self) -> None: + """ + Manage all automated behaviour of the BrandingManager cog. + + Once a day, the daemon will perform the following tasks: + - Update `current_season` + - Poll GitHub API to see if the available branding for `current_season` has changed + - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname) + - Check whether it's time to cycle guild icons + + The internal loop runs once when activated, then periodically at the time + given by `time_until_midnight`. + + All method calls in the internal loop are considered safe, i.e. no errors propagate + to the daemon's loop. The daemon itself does not perform any error handling on its own. + """ + await self.bot.wait_until_guild_available() + + while True: + self.current_season = get_current_season() + branding_changed = await self.refresh() + + if branding_changed: + await self.apply() + + elif next(self.days_since_cycle) == Branding.cycle_frequency: + await self.cycle() + + until_midnight = time_until_midnight() + await asyncio.sleep(until_midnight.total_seconds()) + + async def _info_embed(self) -> discord.Embed: + """Make an informative embed representing current season.""" + info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour) + + # If we're in a non-evergreen season, also show active months + if self.current_season is not SeasonBase: + title = f"{self.current_season.season_name} ({', '.join(str(m) for m in self.current_season.months)})" + else: + title = self.current_season.season_name + + # Use the author field to show the season's name and avatar if available + info_embed.set_author(name=title) + + banner = self.banner.path if self.banner is not None else "Unavailable" + info_embed.add_field(name="Banner", value=banner, inline=False) + + icons = pretty_files(self.available_icons) or "Unavailable" + info_embed.add_field(name="Available icons", value=icons, inline=False) + + # Only display cycle frequency if we're actually cycling + if len(self.available_icons) > 1 and Branding.cycle_frequency: + info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}") + + return info_embed + + async def _reset_remaining_icons(self) -> None: + """Set `remaining_icons` to a shuffled copy of `available_icons`.""" + self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons)) + + async def _reset_days_since_cycle(self) -> None: + """ + Reset the `days_since_cycle` iterator based on configured frequency. + + If the current season only has 1 icon, or if `Branding.cycle_frequency` is falsey, + the iterator will always yield None. This signals that the icon shouldn't be cycled. + + Otherwise, it will yield ints in range [1, `Branding.cycle_frequency`] indefinitely. + When the iterator yields a value equal to `Branding.cycle_frequency`, it is time to cycle. + """ + if len(self.available_icons) > 1 and Branding.cycle_frequency: + sequence = range(1, Branding.cycle_frequency + 1) + else: + sequence = [None] + + self.days_since_cycle = itertools.cycle(sequence) + + async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GitHubFile]: + """ + Get files at `path` in the branding repository. + + If `include_dirs` is False (default), only returns files at `path`. + Otherwise, will return both files and directories. Never returns symlinks. + + Return dict mapping from filename to corresponding `GitHubFile` instance. + This may return an empty dict if the response status is non-200, + or if the target directory is empty. + """ + url = f"{BRANDING_URL}/{path}" + async with self.bot.http_session.get(url, headers=HEADERS, params=PARAMS) as resp: + # Short-circuit if we get non-200 response + if resp.status != STATUS_OK: + log.error(f"GitHub API returned non-200 response: {resp}") + return {} + directory = await resp.json() # Directory at `path` + + allowed_types = {"file", "dir"} if include_dirs else {"file"} + return { + file["name"]: GitHubFile(file["download_url"], file["path"], file["sha"]) + for file in directory + if file["type"] in allowed_types + } + + async def refresh(self) -> bool: + """ + Synchronize available assets with branding repository. + + If the current season is not the evergreen, and lacks at least one asset, + we use the evergreen seasonal dir as fallback for missing assets. + + Finally, if neither the seasonal nor fallback branding directories contain + an asset, it will simply be ignored. + + Return True if the branding has changed. This will be the case when we enter + a new season, or when something changes in the current seasons's directory + in the branding repository. + """ + old_branding = (self.banner, self.available_icons) + seasonal_dir = await self._get_files(self.current_season.branding_path, include_dirs=True) + + # Only make a call to the fallback directory if there is something to be gained + branding_incomplete = any( + asset not in seasonal_dir + for asset in (FILE_BANNER, FILE_AVATAR, SERVER_ICONS) + ) + if branding_incomplete and self.current_season is not SeasonBase: + fallback_dir = await self._get_files(SeasonBase.branding_path, include_dirs=True) + else: + fallback_dir = {} + + # Resolve assets in this directory, None is a safe value + self.banner = seasonal_dir.get(FILE_BANNER) or fallback_dir.get(FILE_BANNER) + + # Now resolve server icons by making a call to the proper sub-directory + if SERVER_ICONS in seasonal_dir: + icons_dir = await self._get_files(f"{self.current_season.branding_path}/{SERVER_ICONS}") + self.available_icons = list(icons_dir.values()) + + elif SERVER_ICONS in fallback_dir: + icons_dir = await self._get_files(f"{SeasonBase.branding_path}/{SERVER_ICONS}") + self.available_icons = list(icons_dir.values()) + + else: + self.available_icons = [] # This should never be the case, but an empty list is a safe value + + # GitHubFile instances carry a `sha` attr so this will pick up if a file changes + branding_changed = old_branding != (self.banner, self.available_icons) + + if branding_changed: + log.info(f"New branding detected (season: {self.current_season.season_name})") + await self._reset_remaining_icons() + await self._reset_days_since_cycle() + + return branding_changed + + async def cycle(self) -> bool: + """ + Apply the next-up server icon. + + Returns True if an icon is available and successfully gets applied, False otherwise. + """ + if not self.available_icons: + log.info("Cannot cycle: no icons for this season") + return False + + if not self.remaining_icons: + log.info("Reset & shuffle remaining icons") + await self._reset_remaining_icons() + + next_up = self.remaining_icons.pop(0) + success = await self.set_icon(next_up.download_url) + + return success + + async def apply(self) -> t.List[str]: + """ + Apply current branding to the guild and bot. + + This delegates to the bot instance to do all the work. We only provide download urls + for available assets. Assets unavailable in the branding repo will be ignored. + + Returns a list of names of all failed assets. An asset is considered failed + if it isn't found in the branding repo, or if something goes wrong while the + bot is trying to apply it. + + An empty list denotes that all assets have been applied successfully. + """ + report = {asset: False for asset in ("banner", "icon")} + + if self.banner is not None: + report["banner"] = await self.set_banner(self.banner.download_url) + + report["icon"] = await self.cycle() + + failed_assets = [asset for asset, succeeded in report.items() if not succeeded] + return failed_assets + + @in_whitelist(roles=MODERATION_ROLES) + @commands.group(name="branding") + async def branding_cmds(self, ctx: commands.Context) -> None: + """Manual branding control.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @branding_cmds.command(name="list", aliases=["ls"]) + async def branding_list(self, ctx: commands.Context) -> None: + """List all available seasons and branding sources.""" + embed = discord.Embed(title="Available seasons", colour=Colours.soft_green) + + for season in get_all_seasons(): + if season is SeasonBase: + active_when = "always" + else: + active_when = f"in {', '.join(str(m) for m in season.months)}" + + description = ( + f"Active {active_when}\n" + f"Branding: {season.branding_path}" + ) + embed.add_field(name=season.season_name, value=description, inline=False) + + await ctx.send(embed=embed) + + @branding_cmds.command(name="set") + async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None: + """ + Manually set season, or reset to current if none given. + + Season search is a case-less comparison against both seasonal class name, + and its `season_name` attr. + + This only pre-loads the cog's internal state to the chosen season, but does not + automatically apply the branding. As that is an expensive operation, the `apply` + command must be called explicitly after this command finishes. + + This means that this command can be used to 'preview' a season gathering info + about its available assets, without applying them to the guild. + + If the daemon is running, it will automatically reset the season to current when + it wakes up. The season set via this command can therefore remain 'detached' from + what it should be - the daemon will make sure that it's set back properly. + """ + if season_name is None: + new_season = get_current_season() + else: + new_season = get_season(season_name) + if new_season is None: + raise BrandingError("No such season exists") + + if self.current_season is new_season: + raise BrandingError(f"Season {self.current_season.season_name} already active") + + self.current_season = new_season + await self.branding_refresh(ctx) + + @branding_cmds.command(name="info", aliases=["status"]) + async def branding_info(self, ctx: commands.Context) -> None: + """ + Show available assets for current season. + + This can be used to confirm that assets have been resolved properly. + When `apply` is used, it attempts to upload exactly the assets listed here. + """ + await ctx.send(embed=await self._info_embed()) + + @branding_cmds.command(name="refresh") + async def branding_refresh(self, ctx: commands.Context) -> None: + """Sync currently available assets with branding repository.""" + async with ctx.typing(): + await self.refresh() + await self.branding_info(ctx) + + @branding_cmds.command(name="apply") + async def branding_apply(self, ctx: commands.Context) -> None: + """ + Apply current season's branding to the guild. + + Use `info` to check which assets will be applied. Shows which assets have + failed to be applied, if any. + """ + async with ctx.typing(): + failed_assets = await self.apply() + if failed_assets: + raise BrandingError(f"Failed to apply following assets: {', '.join(failed_assets)}") + + response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @branding_cmds.command(name="cycle") + async def branding_cycle(self, ctx: commands.Context) -> None: + """ + Apply the next-up guild icon, if multiple are available. + + The order is random. + """ + async with ctx.typing(): + success = await self.cycle() + if not success: + raise BrandingError("Failed to cycle icon") + + response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @branding_cmds.group(name="daemon", aliases=["d", "task"]) + async def daemon_group(self, ctx: commands.Context) -> None: + """Control the background daemon.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @daemon_group.command(name="status") + async def daemon_status(self, ctx: commands.Context) -> None: + """Check whether daemon is currently active.""" + if self._daemon_running: + remaining_time = (arrow.utcnow() + time_until_midnight()).humanize() + response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green) + response.set_footer(text=f"Next refresh {remaining_time}") + else: + response = discord.Embed(description="Daemon not running", colour=Colours.soft_red) + + await ctx.send(embed=response) + + @daemon_group.command(name="start") + async def daemon_start(self, ctx: commands.Context) -> None: + """If the daemon isn't running, start it.""" + if self._daemon_running: + raise BrandingError("Daemon already running!") + + self.daemon = self.bot.loop.create_task(self._daemon_func()) + await self.branding_configuration.set("daemon_active", True) + + response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @daemon_group.command(name="stop") + async def daemon_stop(self, ctx: commands.Context) -> None: + """If the daemon is running, stop it.""" + if not self._daemon_running: + raise BrandingError("Daemon not running!") + + self.daemon.cancel() + await self.branding_configuration.set("daemon_active", False) + + response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + async def _fetch_image(self, url: str) -> bytes: + """Retrieve and read image from `url`.""" + log.debug(f"Getting image from: {url}") + async with self.bot.http_session.get(url) as resp: + return await resp.read() + + async def _apply_asset(self, target: discord.Guild, asset: AssetType, url: str) -> bool: + """ + Internal method for applying media assets to the guild. + + This shouldn't be called directly. The purpose of this method is mainly generic + error handling to reduce needless code repetition. + + Return True if upload was successful, False otherwise. + """ + log.info(f"Attempting to set {asset.name}: {url}") + + kwargs = {asset.value: await self._fetch_image(url)} + try: + async with async_timeout.timeout(5): + await target.edit(**kwargs) + + except asyncio.TimeoutError: + log.info("Asset upload timed out") + return False + + except discord.HTTPException as discord_error: + log.exception("Asset upload failed", exc_info=discord_error) + return False + + else: + log.info("Asset successfully applied") + return True + + @mock_in_debug(return_value=True) + async def set_banner(self, url: str) -> bool: + """Set the guild's banner to image at `url`.""" + guild = self.bot.get_guild(Guild.id) + if guild is None: + log.info("Failed to get guild instance, aborting asset upload") + return False + + return await self._apply_asset(guild, AssetType.BANNER, url) + + @mock_in_debug(return_value=True) + async def set_icon(self, url: str) -> bool: + """Sets the guild's icon to image at `url`.""" + guild = self.bot.get_guild(Guild.id) + if guild is None: + log.info("Failed to get guild instance, aborting asset upload") + return False + + return await self._apply_asset(guild, AssetType.SERVER_ICON, url) + + +def setup(bot: Bot) -> None: + """Load BrandingManager cog.""" + bot.add_cog(BrandingManager(bot)) -- cgit v1.2.3 From f12ee976561b3cd474794c5e0230854b8d368a32 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 17 Nov 2020 16:34:16 +0200 Subject: Move GitHub API key to better location --- bot/constants.py | 7 +------ bot/exts/backend/branding.py | 6 +++--- config-default.yml | 5 +---- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 57d8928fa..20e8c4b83 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -491,6 +491,7 @@ class Keys(metaclass=YAMLGetter): section = "keys" site_api: Optional[str] + github: Optional[str] class URLs(metaclass=YAMLGetter): @@ -617,12 +618,6 @@ class Branding(metaclass=YAMLGetter): cycle_frequency: int -class Tokens(metaclass=YAMLGetter): - section = "tokens" - - github: str - - class Event(Enum): """ Event names. This does not include every event (for example, raw diff --git a/bot/exts/backend/branding.py b/bot/exts/backend/branding.py index 286b6e0b1..268f5dd48 100644 --- a/bot/exts/backend/branding.py +++ b/bot/exts/backend/branding.py @@ -12,7 +12,7 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import AssetType, Branding, Colours, Emojis, Guild, MODERATION_ROLES, Tokens +from bot.constants import AssetType, Branding, Colours, Emojis, Guild, Keys, MODERATION_ROLES from bot.seasons import SeasonBase, get_all_seasons, get_current_season, get_season from bot.decorators import in_whitelist, mock_in_debug from bot.errors import BrandingError @@ -32,8 +32,8 @@ HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3 # A GitHub token is not necessary for the cog to operate, # unauthorized requests are however limited to 60 per hour -if Tokens.github: - HEADERS["Authorization"] = f"token {Tokens.github}" +if Keys.github: + HEADERS["Authorization"] = f"token {Keys.github}" class GitHubFile(t.NamedTuple): diff --git a/config-default.yml b/config-default.yml index e7163fbe0..a9bbb144e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -321,6 +321,7 @@ filter: keys: site_api: !ENV "BOT_API_KEY" + github: !ENV "GITHUB_TOKEN" urls: @@ -535,9 +536,5 @@ branding: cycle_frequency: 3 # How many days bot wait before refreshing server icon -tokens: - github: !ENV "GITHUB_TOKEN" - - config: required_keys: ['bot.token'] -- cgit v1.2.3 From 0191ae3c54a4fc459ab392eaf5aa9743aa2801c3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 17 Nov 2020 16:44:05 +0200 Subject: Use GitHub token for fetching PEPs --- bot/exts/utils/utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 6d8d98695..59c472cf9 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -11,7 +11,7 @@ from discord import Colour, Embed, utils from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES +from bot.constants import Channels, Keys, MODERATION_ROLES, STAFF_ROLES from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils import messages @@ -45,6 +45,11 @@ ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" pep_cache = AsyncCache() +# Add GitHub token when it's set to raise limit of requests per hour +GITHUB_HEADERS = {} +if Keys.github: + GITHUB_HEADERS["Authorization"] = f"token {Keys.github}" + class Utils(Cog): """A selection of utilities which don't have a clear category.""" @@ -197,7 +202,7 @@ class Utils(Cog): await self.bot.wait_until_ready() log.trace("Started refreshing PEP URLs.") - async with self.bot.http_session.get(self.PEPS_LISTING_API_URL) as resp: + async with self.bot.http_session.get(self.PEPS_LISTING_API_URL, headers=GITHUB_HEADERS) as resp: listing = await resp.json() log.trace("Got PEP URLs listing from GitHub API") -- cgit v1.2.3 From 9a933fa098b1e5b1edb9e3f606bf2bb6b3ea15d6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 17 Nov 2020 16:44:33 +0200 Subject: Fix wrong import orders --- bot/exts/backend/branding.py | 2 +- bot/exts/backend/error_handler.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/backend/branding.py b/bot/exts/backend/branding.py index 268f5dd48..7ce85aab2 100644 --- a/bot/exts/backend/branding.py +++ b/bot/exts/backend/branding.py @@ -13,9 +13,9 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import AssetType, Branding, Colours, Emojis, Guild, Keys, MODERATION_ROLES -from bot.seasons import SeasonBase, get_all_seasons, get_current_season, get_season from bot.decorators import in_whitelist, mock_in_debug from bot.errors import BrandingError +from bot.seasons import SeasonBase, get_all_seasons, get_current_season, get_season log = logging.getLogger(__name__) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 6fb5bcf98..b6c19d504 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -11,7 +11,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Colours, ERROR_REPLIES from bot.converters import TagNameConverter -from bot.errors import LockedResourceError, BrandingError +from bot.errors import BrandingError, LockedResourceError from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) -- cgit v1.2.3 From ed68051a47e67d8e60498a866a8c3c54840aa6fb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 11:39:18 -0700 Subject: Help channels: move to a subpackage --- bot/exts/help_channels.py | 940 ------------------------------------- bot/exts/help_channels/__init__.py | 17 + bot/exts/help_channels/_cog.py | 930 ++++++++++++++++++++++++++++++++++++ 3 files changed, 947 insertions(+), 940 deletions(-) delete mode 100644 bot/exts/help_channels.py create mode 100644 bot/exts/help_channels/__init__.py create mode 100644 bot/exts/help_channels/_cog.py diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py deleted file mode 100644 index ced2f72ef..000000000 --- a/bot/exts/help_channels.py +++ /dev/null @@ -1,940 +0,0 @@ -import asyncio -import json -import logging -import random -import typing as t -from collections import deque -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import discord -import discord.abc -from async_rediscache import RedisCache -from discord.ext import commands - -from bot import constants -from bot.bot import Bot -from bot.utils import channel as channel_utils -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - -ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) - -HELP_CHANNEL_TOPIC = """ -This is a Python help channel. You can claim your own help channel in the Python Help: Available category. -""" - -AVAILABLE_MSG = f""" -**Send your question here to claim the channel** -This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. - -**Keep in mind:** -• It's always ok to just ask your question. You don't need permission. -• Explain what you expect to happen and what actually happens. -• Include a code sample and error message, if you got any. - -For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. -""" - -AVAILABLE_TITLE = "Available help channel" - -AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." - -DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ -category at the bottom of the channel list. It is no longer possible to send messages in this \ -channel until it becomes available again. - -If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ -question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. -""" - -CoroutineFunc = t.Callable[..., t.Coroutine] - - -class HelpChannels(commands.Cog): - """ - Manage the help channel system of the guild. - - The system is based on a 3-category system: - - Available Category - - * Contains channels which are ready to be occupied by someone who needs help - * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically - from the pool of dormant channels - * Prioritise using the channels which have been dormant for the longest amount of time - * If there are no more dormant channels, the bot will automatically create a new one - * If there are no dormant channels to move, helpers will be notified (see `notify()`) - * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` - * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` - * To keep track of cooldowns, user which claimed a channel will have a temporary role - - In Use Category - - * Contains all channels which are occupied by someone needing help - * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle - * Command can prematurely mark a channel as dormant - * Channel claimant is allowed to use the command - * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` - * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent - - Dormant Category - - * Contains channels which aren't in use - * Channels are used to refill the Available category - - Help channels are named after the chemical elements in `bot/resources/elements.json`. - """ - - # This cache tracks which channels are claimed by which members. - # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] - help_channel_claimants = RedisCache() - - # This cache maps a help channel to whether it has had any - # activity other than the original claimant. True being no other - # activity and False being other activity. - # RedisCache[discord.TextChannel.id, bool] - unanswered = RedisCache() - - # This dictionary maps a help channel to the time it was claimed - # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] - claim_times = RedisCache() - - # This cache maps a help channel to original question message in same channel. - # RedisCache[discord.TextChannel.id, discord.Message.id] - question_messages = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - # Categories - self.available_category: discord.CategoryChannel = None - self.in_use_category: discord.CategoryChannel = None - self.dormant_category: discord.CategoryChannel = None - - # Queues - self.channel_queue: asyncio.Queue[discord.TextChannel] = None - self.name_queue: t.Deque[str] = None - - self.name_positions = self.get_names() - self.last_notification: t.Optional[datetime] = None - - # Asyncio stuff - self.queue_tasks: t.List[asyncio.Task] = [] - self.ready = asyncio.Event() - self.on_message_lock = asyncio.Lock() - self.init_task = self.bot.loop.create_task(self.init_cog()) - - def cog_unload(self) -> None: - """Cancel the init task and scheduled tasks when the cog unloads.""" - log.trace("Cog unload: cancelling the init_cog task") - self.init_task.cancel() - - log.trace("Cog unload: cancelling the channel queue tasks") - for task in self.queue_tasks: - task.cancel() - - self.scheduler.cancel_all() - - def create_channel_queue(self) -> asyncio.Queue: - """ - Return a queue of dormant channels to use for getting the next available channel. - - The channels are added to the queue in a random order. - """ - log.trace("Creating the channel queue.") - - channels = list(self.get_category_channels(self.dormant_category)) - random.shuffle(channels) - - log.trace("Populating the channel queue with channels.") - queue = asyncio.Queue() - for channel in channels: - queue.put_nowait(channel) - - return queue - - async def create_dormant(self) -> t.Optional[discord.TextChannel]: - """ - Create and return a new channel in the Dormant category. - - The new channel will sync its permission overwrites with the category. - - Return None if no more channel names are available. - """ - log.trace("Getting a name for a new dormant channel.") - - try: - name = self.name_queue.popleft() - except IndexError: - log.debug("No more names available for new dormant channels.") - return None - - log.debug(f"Creating a new dormant channel named {name}.") - return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - - def create_name_queue(self) -> deque: - """Return a queue of element names to use for creating new channels.""" - log.trace("Creating the chemical element name queue.") - - used_names = self.get_used_names() - - log.trace("Determining the available names.") - available_names = (name for name in self.name_positions if name not in used_names) - - log.trace("Populating the name queue with names.") - return deque(available_names) - - async def dormant_check(self, ctx: commands.Context) -> bool: - """Return True if the user is the help channel claimant or passes the role check.""" - if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: - log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") - self.bot.stats.incr("help.dormant_invoke.claimant") - return True - - log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") - has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) - - if has_role: - self.bot.stats.incr("help.dormant_invoke.staff") - - return has_role - - @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) - async def close_command(self, ctx: commands.Context) -> None: - """ - Make the current in-use help channel dormant. - - Make the channel dormant if the user passes the `dormant_check`, - delete the message that invoked this, - and reset the send permissions cooldown for the user who started the session. - """ - log.trace("close command invoked; checking if the channel is in-use.") - if ctx.channel.category == self.in_use_category: - if await self.dormant_check(ctx): - await self.remove_cooldown_role(ctx.author) - - # Ignore missing task when cooldown has passed but the channel still isn't dormant. - if ctx.author.id in self.scheduler: - self.scheduler.cancel(ctx.author.id) - - await self.move_to_dormant(ctx.channel, "command") - self.scheduler.cancel(ctx.channel.id) - else: - log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") - - async def get_available_candidate(self) -> discord.TextChannel: - """ - Return a dormant channel to turn into an available channel. - - If no channel is available, wait indefinitely until one becomes available. - """ - log.trace("Getting an available channel candidate.") - - try: - channel = self.channel_queue.get_nowait() - except asyncio.QueueEmpty: - log.info("No candidate channels in the queue; creating a new channel.") - channel = await self.create_dormant() - - if not channel: - log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - await self.notify() - channel = await self.wait_for_dormant_channel() - - return channel - - @staticmethod - def get_clean_channel_name(channel: discord.TextChannel) -> str: - """Return a clean channel name without status emojis prefix.""" - prefix = constants.HelpChannels.name_prefix - try: - # Try to remove the status prefix using the index of the channel prefix - name = channel.name[channel.name.index(prefix):] - log.trace(f"The clean name for `{channel}` is `{name}`") - except ValueError: - # If, for some reason, the channel name does not contain "help-" fall back gracefully - log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") - name = channel.name - - return name - - @staticmethod - def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: - """Check if a channel should be excluded from the help channel system.""" - return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS - - def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - - # This is faster than using category.channels because the latter sorts them. - for channel in self.bot.get_guild(constants.Guild.id).channels: - if channel.category_id == category.id and not self.is_excluded_channel(channel): - yield channel - - async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: - """Return the duration `channel_id` has been in use. Return None if it's not in use.""" - log.trace(f"Calculating in use time for channel {channel_id}.") - - claimed_timestamp = await self.claim_times.get(channel_id) - if claimed_timestamp: - claimed = datetime.utcfromtimestamp(claimed_timestamp) - return datetime.utcnow() - claimed - - @staticmethod - def get_names() -> t.List[str]: - """ - Return a truncated list of prefixed element names. - - The amount of names is configured with `HelpChannels.max_total_channels`. - The prefix is configured with `HelpChannels.name_prefix`. - """ - count = constants.HelpChannels.max_total_channels - prefix = constants.HelpChannels.name_prefix - - log.trace(f"Getting the first {count} element names from JSON.") - - with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - all_names = json.load(elements_file) - - if prefix: - return [prefix + name for name in all_names[:count]] - else: - return all_names[:count] - - def get_used_names(self) -> t.Set[str]: - """Return channel names which are already being used.""" - log.trace("Getting channel names which are already being used.") - - names = set() - for cat in (self.available_category, self.in_use_category, self.dormant_category): - for channel in self.get_category_channels(cat): - names.add(self.get_clean_channel_name(channel)) - - if len(names) > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"Too many help channels ({len(names)}) already exist! " - f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." - ) - - log.trace(f"Got {len(names)} used names: {names}") - return names - - @classmethod - async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: - """ - Return the time elapsed, in seconds, since the last message sent in the `channel`. - - Return None if the channel has no messages. - """ - log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - - msg = await cls.get_last_message(channel) - if not msg: - log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") - return None - - idle_time = (datetime.utcnow() - msg.created_at).seconds - - log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") - return idle_time - - @staticmethod - async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: - """Return the last message sent in the channel or None if no messages exist.""" - log.trace(f"Getting the last message in #{channel} ({channel.id}).") - - try: - return await channel.history(limit=1).next() # noqa: B305 - except discord.NoMoreItems: - log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") - return None - - async def init_available(self) -> None: - """Initialise the Available category with channels.""" - log.trace("Initialising the Available category with channels.") - - channels = list(self.get_category_channels(self.available_category)) - missing = constants.HelpChannels.max_available - len(channels) - - # If we've got less than `max_available` channel available, we should add some. - if missing > 0: - log.trace(f"Moving {missing} missing channels to the Available category.") - for _ in range(missing): - await self.move_to_available() - - # If for some reason we have more than `max_available` channels available, - # we should move the superfluous ones over to dormant. - elif missing < 0: - log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") - for channel in channels[:abs(missing)]: - await self.move_to_dormant(channel, "auto") - - async def init_categories(self) -> None: - """Get the help category objects. Remove the cog if retrieval fails.""" - log.trace("Getting the CategoryChannel objects for the help categories.") - - try: - self.available_category = await channel_utils.try_get_channel( - constants.Categories.help_available - ) - self.in_use_category = await channel_utils.try_get_channel( - constants.Categories.help_in_use - ) - self.dormant_category = await channel_utils.try_get_channel( - constants.Categories.help_dormant - ) - except discord.HTTPException: - log.exception("Failed to get a category; cog will be removed") - self.bot.remove_cog(self.qualified_name) - - async def init_cog(self) -> None: - """Initialise the help channel system.""" - log.trace("Waiting for the guild to be available before initialisation.") - await self.bot.wait_until_guild_available() - - log.trace("Initialising the cog.") - await self.init_categories() - await self.check_cooldowns() - - self.channel_queue = self.create_channel_queue() - self.name_queue = self.create_name_queue() - - log.trace("Moving or rescheduling in-use channels.") - for channel in self.get_category_channels(self.in_use_category): - await self.move_idle_channel(channel, has_task=False) - - # Prevent the command from being used until ready. - # The ready event wasn't used because channels could change categories between the time - # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). - # This may confuse users. So would potentially long delays for the cog to become ready. - self.close_command.enabled = True - - await self.init_available() - - log.info("Cog is ready!") - self.ready.set() - - self.report_stats() - - def report_stats(self) -> None: - """Report the channel count stats.""" - total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in self.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) - - self.bot.stats.gauge("help.total.in_use", total_in_use) - self.bot.stats.gauge("help.total.available", total_available) - self.bot.stats.gauge("help.total.dormant", total_dormant) - - @staticmethod - def is_claimant(member: discord.Member) -> bool: - """Return True if `member` has the 'Help Cooldown' role.""" - return any(constants.Roles.help_cooldown == role.id for role in member.roles) - - def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: - """Return `True` if the bot's `message`'s embed description matches `description`.""" - if not message or not message.embeds: - return False - - bot_msg_desc = message.embeds[0].description - if bot_msg_desc is discord.Embed.Empty: - log.trace("Last message was a bot embed but it was empty.") - return False - return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() - - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: - """ - Make the `channel` dormant if idle or schedule the move if still active. - - If `has_task` is True and rescheduling is required, the extant task to make the channel - dormant will first be cancelled. - """ - log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - - if not await self.is_empty(channel): - idle_seconds = constants.HelpChannels.idle_minutes * 60 - else: - idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 - - time_elapsed = await self.get_idle_time(channel) - - if time_elapsed is None or time_elapsed >= idle_seconds: - log.info( - f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " - f"and will be made dormant." - ) - - await self.move_to_dormant(channel, "auto") - else: - # Cancel the existing task, if any. - if has_task: - self.scheduler.cancel(channel.id) - - delay = idle_seconds - time_elapsed - log.info( - f"#{channel} ({channel.id}) is still active; " - f"scheduling it to be moved after {delay} seconds." - ) - - self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) - - async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: - """ - Move the `channel` to the bottom position of `category` and edit channel attributes. - - To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current - positions of the other channels in the category as-is. This should make sure that the channel - really ends up at the bottom of the category. - - If `options` are provided, the channel will be edited after the move is completed. This is the - same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documentation on `discord.TextChannel.edit`. While possible, position-related - options should be avoided, as it may interfere with the category move we perform. - """ - # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await channel_utils.try_get_channel(category_id) - - payload = [{"id": c.id, "position": c.position} for c in category.channels] - - # Calculate the bottom position based on the current highest position in the category. If the - # category is currently empty, we simply use the current position of the channel to avoid making - # unnecessary changes to positions in the guild. - bottom_position = payload[-1]["position"] + 1 if payload else channel.position - - payload.append( - { - "id": channel.id, - "position": bottom_position, - "parent_id": category.id, - "lock_permissions": True, - } - ) - - # We use d.py's method to ensure our request is processed by d.py's rate limit manager - await self.bot.http.bulk_channel_update(category.guild.id, payload) - - # Now that the channel is moved, we can edit the other attributes - if options: - await channel.edit(**options) - - async def move_to_available(self) -> None: - """Make a channel available.""" - log.trace("Making a channel available.") - - channel = await self.get_available_candidate() - log.info(f"Making #{channel} ({channel.id}) available.") - - await self.send_available_message(channel) - - log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") - - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_available, - ) - - self.report_stats() - - async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: - """ - Make the `channel` dormant. - - A caller argument is provided for metrics. - """ - log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - - await self.help_channel_claimants.delete(channel.id) - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_dormant, - ) - - self.bot.stats.incr(f"help.dormant_calls.{caller}") - - in_use_time = await self.get_in_use_time(channel.id) - if in_use_time: - self.bot.stats.timing("help.in_use_time", in_use_time) - - unanswered = await self.unanswered.get(channel.id) - if unanswered: - self.bot.stats.incr("help.sessions.unanswered") - elif unanswered is not None: - self.bot.stats.incr("help.sessions.answered") - - log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") - log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed(description=DORMANT_MSG) - await channel.send(embed=embed) - - await self.unpin(channel) - - log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") - self.channel_queue.put_nowait(channel) - self.report_stats() - - async def move_to_in_use(self, channel: discord.TextChannel) -> None: - """Make a channel in-use and schedule it to be made dormant.""" - log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") - - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_in_use, - ) - - timeout = constants.HelpChannels.idle_minutes * 60 - - log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") - self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) - self.report_stats() - - async def notify(self) -> None: - """ - Send a message notifying about a lack of available help channels. - - Configuration: - - * `HelpChannels.notify` - toggle notifications - * `HelpChannels.notify_channel` - destination channel for notifications - * `HelpChannels.notify_minutes` - minimum interval between notifications - * `HelpChannels.notify_roles` - roles mentioned in notifications - """ - if not constants.HelpChannels.notify: - return - - log.trace("Notifying about lack of channels.") - - if self.last_notification: - elapsed = (datetime.utcnow() - self.last_notification).seconds - minimum_interval = constants.HelpChannels.notify_minutes * 60 - should_send = elapsed >= minimum_interval - else: - should_send = True - - if not should_send: - log.trace("Notification not sent because it's too recent since the previous one.") - return - - try: - log.trace("Sending notification message.") - - channel = self.bot.get_channel(constants.HelpChannels.notify_channel) - mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) - allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] - - message = await channel.send( - f"{mentions} A new available help channel is needed but there " - f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels.", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - - self.bot.stats.incr("help.out_of_channel_alerts") - - self.last_notification = message.created_at - except Exception: - # Handle it here cause this feature isn't critical for the functionality of the system. - log.exception("Failed to send notification about lack of dormant channels!") - - async def check_for_answer(self, message: discord.Message) -> None: - """Checks for whether new content in a help channel comes from non-claimants.""" - channel = message.channel - - # Confirm the channel is an in use help channel - if channel_utils.is_in_category(channel, constants.Categories.help_in_use): - log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") - - # Check if there is an entry in unanswered - if await self.unanswered.contains(channel.id): - claimant_id = await self.help_channel_claimants.get(channel.id) - if not claimant_id: - # The mapping for this channel doesn't exist, we can't do anything. - return - - # Check the message did not come from the claimant - if claimant_id != message.author.id: - # Mark the channel as answered - await self.unanswered.set(channel.id, False) - - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Move an available channel to the In Use category and replace it with a dormant one.""" - if message.author.bot: - return # Ignore messages sent by bots. - - channel = message.channel - - await self.check_for_answer(message) - - is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or self.is_excluded_channel(channel): - return # Ignore messages outside the Available category or in excluded channels. - - log.trace("Waiting for the cog to be ready before processing messages.") - await self.ready.wait() - - log.trace("Acquiring lock to prevent a channel from being processed twice...") - async with self.on_message_lock: - log.trace(f"on_message lock acquired for {message.id}.") - - if not channel_utils.is_in_category(channel, constants.Categories.help_available): - log.debug( - f"Message {message.id} will not make #{channel} ({channel.id}) in-use " - f"because another message in the channel already triggered that." - ) - return - - log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") - await self.move_to_in_use(channel) - await self.revoke_send_permissions(message.author) - - await self.pin(message) - - # Add user with channel for dormant check. - await self.help_channel_claimants.set(channel.id, message.author.id) - - self.bot.stats.incr("help.claimed") - - # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. - timestamp = datetime.now(timezone.utc).timestamp() - await self.claim_times.set(channel.id, timestamp) - - await self.unanswered.set(channel.id, True) - - log.trace(f"Releasing on_message lock for {message.id}.") - - # Move a dormant channel to the Available category to fill in the gap. - # This is done last and outside the lock because it may wait indefinitely for a channel to - # be put in the queue. - await self.move_to_available() - - @commands.Cog.listener() - async def on_message_delete(self, msg: discord.Message) -> None: - """ - Reschedule an in-use channel to become dormant sooner if the channel is empty. - - The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. - """ - if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): - return - - if not await self.is_empty(msg.channel): - return - - log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") - - # Cancel existing dormant task before scheduling new. - self.scheduler.cancel(msg.channel.id) - - delay = constants.HelpChannels.deleted_idle_minutes * 60 - self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - - async def is_empty(self, channel: discord.TextChannel) -> bool: - """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" - log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - - # A limit of 100 results in a single API call. - # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. - # Not gonna do an extensive search for it cause it's too expensive. - async for msg in channel.history(limit=100): - if not msg.author.bot: - log.trace(f"#{channel} ({channel.id}) has a non-bot message.") - return False - - if self.match_bot_embed(msg, AVAILABLE_MSG): - log.trace(f"#{channel} ({channel.id}) has the available message embed.") - return True - - return False - - async def check_cooldowns(self) -> None: - """Remove expired cooldowns and re-schedule active ones.""" - log.trace("Checking all cooldowns to remove or re-schedule them.") - guild = self.bot.get_guild(constants.Guild.id) - cooldown = constants.HelpChannels.claim_minutes * 60 - - for channel_id, member_id in await self.help_channel_claimants.items(): - member = guild.get_member(member_id) - if not member: - continue # Member probably left the guild. - - in_use_time = await self.get_in_use_time(channel_id) - - if not in_use_time or in_use_time.seconds > cooldown: - # Remove the role if no claim time could be retrieved or if the cooldown expired. - # Since the channel is in the claimants cache, it is definitely strange for a time - # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. - await self.remove_cooldown_role(member) - else: - # The member is still on a cooldown; re-schedule it for the remaining time. - delay = cooldown - in_use_time.seconds - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - - async def add_cooldown_role(self, member: discord.Member) -> None: - """Add the help cooldown role to `member`.""" - log.trace(f"Adding cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.add_roles) - - async def remove_cooldown_role(self, member: discord.Member) -> None: - """Remove the help cooldown role from `member`.""" - log.trace(f"Removing cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.remove_roles) - - async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: - """ - Change `member`'s cooldown role via awaiting `coro_func` and handle errors. - - `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - guild = self.bot.get_guild(constants.Guild.id) - role = guild.get_role(constants.Roles.help_cooldown) - if role is None: - log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") - return - - try: - await coro_func(role) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - - async def revoke_send_permissions(self, member: discord.Member) -> None: - """ - Disallow `member` to send messages in the Available category for a certain time. - - The time until permissions are reinstated can be configured with - `HelpChannels.claim_minutes`. - """ - log.trace( - f"Revoking {member}'s ({member.id}) send message permissions in the Available category." - ) - - await self.add_cooldown_role(member) - - # Cancel the existing task, if any. - # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - if member.id in self.scheduler: - self.scheduler.cancel(member.id) - - delay = constants.HelpChannels.claim_minutes * 60 - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - - async def send_available_message(self, channel: discord.TextChannel) -> None: - """Send the available message by editing a dormant message or sending a new message.""" - channel_info = f"#{channel} ({channel.id})" - log.trace(f"Sending available message in {channel_info}.") - - embed = discord.Embed( - color=constants.Colours.bright_green, - description=AVAILABLE_MSG, - ) - embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) - embed.set_footer(text=AVAILABLE_FOOTER) - - msg = await self.get_last_message(channel) - if self.match_bot_embed(msg, DORMANT_MSG): - log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") - await msg.edit(embed=embed) - else: - log.trace(f"Dormant message not found in {channel_info}; sending a new message.") - await channel.send(embed=embed) - - async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: - """ - Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - - Return True if successful and False otherwise. - """ - channel_str = f"#{channel} ({channel.id})" - if pin: - func = self.bot.http.pin_message - verb = "pin" - else: - func = self.bot.http.unpin_message - verb = "unpin" - - try: - await func(channel.id, msg_id) - except discord.HTTPException as e: - if e.code == 10008: - log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") - else: - log.exception( - f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" - ) - return False - else: - log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") - return True - - async def pin(self, message: discord.Message) -> None: - """Pin an initial question `message` and store it in a cache.""" - if await self.pin_wrapper(message.id, message.channel, pin=True): - await self.question_messages.set(message.channel.id, message.id) - - async def unpin(self, channel: discord.TextChannel) -> None: - """Unpin the initial question message sent in `channel`.""" - msg_id = await self.question_messages.pop(channel.id) - if msg_id is None: - log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") - else: - await self.pin_wrapper(msg_id, channel, pin=False) - - async def wait_for_dormant_channel(self) -> discord.TextChannel: - """Wait for a dormant channel to become available in the queue and return it.""" - log.trace("Waiting for a dormant channel.") - - task = asyncio.create_task(self.channel_queue.get()) - self.queue_tasks.append(task) - channel = await task - - log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") - self.queue_tasks.remove(task) - - return channel - - -def validate_config() -> None: - """Raise a ValueError if the cog's config is invalid.""" - log.trace("Validating config.") - total = constants.HelpChannels.max_total_channels - available = constants.HelpChannels.max_available - - if total == 0 or available == 0: - raise ValueError("max_total_channels and max_available and must be greater than 0.") - - if total < available: - raise ValueError( - f"max_total_channels ({total}) must be greater than or equal to max_available " - f"({available})." - ) - - if total > MAX_CHANNELS_PER_CATEGORY: - raise ValueError( - f"max_total_channels ({total}) must be less than or equal to " - f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." - ) - - -def setup(bot: Bot) -> None: - """Load the HelpChannels cog.""" - try: - validate_config() - except ValueError as e: - log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") - else: - bot.add_cog(HelpChannels(bot)) diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py new file mode 100644 index 000000000..38444b707 --- /dev/null +++ b/bot/exts/help_channels/__init__.py @@ -0,0 +1,17 @@ +import logging + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: + """Load the HelpChannels cog.""" + # Defer import to reduce side effects from importing the sync package. + from bot.exts.help_channels import _cog + try: + _cog.validate_config() + except ValueError as e: + log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") + else: + bot.add_cog(_cog.HelpChannels(bot)) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py new file mode 100644 index 000000000..5e2a7dd71 --- /dev/null +++ b/bot/exts/help_channels/_cog.py @@ -0,0 +1,930 @@ +import asyncio +import json +import logging +import random +import typing as t +from collections import deque +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import discord +import discord.abc +from async_rediscache import RedisCache +from discord.ext import commands + +from bot import constants +from bot.bot import Bot +from bot.utils import channel as channel_utils +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) + +HELP_CHANNEL_TOPIC = """ +This is a Python help channel. You can claim your own help channel in the Python Help: Available category. +""" + +AVAILABLE_MSG = f""" +**Send your question here to claim the channel** +This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. + +**Keep in mind:** +• It's always ok to just ask your question. You don't need permission. +• Explain what you expect to happen and what actually happens. +• Include a code sample and error message, if you got any. + +For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. +""" + +AVAILABLE_TITLE = "Available help channel" + +AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." + +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +category at the bottom of the channel list. It is no longer possible to send messages in this \ +channel until it becomes available again. + +If your question wasn't answered yet, you can claim a new help channel from the \ +**Help: Available** category by simply asking your question again. Consider rephrasing the \ +question to maximize your chance of getting a good answer. If you're not sure how, have a look \ +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. +""" + +CoroutineFunc = t.Callable[..., t.Coroutine] + + +class HelpChannels(commands.Cog): + """ + Manage the help channel system of the guild. + + The system is based on a 3-category system: + + Available Category + + * Contains channels which are ready to be occupied by someone who needs help + * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically + from the pool of dormant channels + * Prioritise using the channels which have been dormant for the longest amount of time + * If there are no more dormant channels, the bot will automatically create a new one + * If there are no dormant channels to move, helpers will be notified (see `notify()`) + * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` + * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` + * To keep track of cooldowns, user which claimed a channel will have a temporary role + + In Use Category + + * Contains all channels which are occupied by someone needing help + * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle + * Command can prematurely mark a channel as dormant + * Channel claimant is allowed to use the command + * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` + * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent + + Dormant Category + + * Contains channels which aren't in use + * Channels are used to refill the Available category + + Help channels are named after the chemical elements in `bot/resources/elements.json`. + """ + + # This cache tracks which channels are claimed by which members. + # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] + help_channel_claimants = RedisCache() + + # This cache maps a help channel to whether it has had any + # activity other than the original claimant. True being no other + # activity and False being other activity. + # RedisCache[discord.TextChannel.id, bool] + unanswered = RedisCache() + + # This dictionary maps a help channel to the time it was claimed + # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] + claim_times = RedisCache() + + # This cache maps a help channel to original question message in same channel. + # RedisCache[discord.TextChannel.id, discord.Message.id] + question_messages = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + # Categories + self.available_category: discord.CategoryChannel = None + self.in_use_category: discord.CategoryChannel = None + self.dormant_category: discord.CategoryChannel = None + + # Queues + self.channel_queue: asyncio.Queue[discord.TextChannel] = None + self.name_queue: t.Deque[str] = None + + self.name_positions = self.get_names() + self.last_notification: t.Optional[datetime] = None + + # Asyncio stuff + self.queue_tasks: t.List[asyncio.Task] = [] + self.ready = asyncio.Event() + self.on_message_lock = asyncio.Lock() + self.init_task = self.bot.loop.create_task(self.init_cog()) + + def cog_unload(self) -> None: + """Cancel the init task and scheduled tasks when the cog unloads.""" + log.trace("Cog unload: cancelling the init_cog task") + self.init_task.cancel() + + log.trace("Cog unload: cancelling the channel queue tasks") + for task in self.queue_tasks: + task.cancel() + + self.scheduler.cancel_all() + + def create_channel_queue(self) -> asyncio.Queue: + """ + Return a queue of dormant channels to use for getting the next available channel. + + The channels are added to the queue in a random order. + """ + log.trace("Creating the channel queue.") + + channels = list(self.get_category_channels(self.dormant_category)) + random.shuffle(channels) + + log.trace("Populating the channel queue with channels.") + queue = asyncio.Queue() + for channel in channels: + queue.put_nowait(channel) + + return queue + + async def create_dormant(self) -> t.Optional[discord.TextChannel]: + """ + Create and return a new channel in the Dormant category. + + The new channel will sync its permission overwrites with the category. + + Return None if no more channel names are available. + """ + log.trace("Getting a name for a new dormant channel.") + + try: + name = self.name_queue.popleft() + except IndexError: + log.debug("No more names available for new dormant channels.") + return None + + log.debug(f"Creating a new dormant channel named {name}.") + return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) + + def create_name_queue(self) -> deque: + """Return a queue of element names to use for creating new channels.""" + log.trace("Creating the chemical element name queue.") + + used_names = self.get_used_names() + + log.trace("Determining the available names.") + available_names = (name for name in self.name_positions if name not in used_names) + + log.trace("Populating the name queue with names.") + return deque(available_names) + + async def dormant_check(self, ctx: commands.Context) -> bool: + """Return True if the user is the help channel claimant or passes the role check.""" + if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: + log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") + self.bot.stats.incr("help.dormant_invoke.claimant") + return True + + log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") + has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) + + if has_role: + self.bot.stats.incr("help.dormant_invoke.staff") + + return has_role + + @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) + async def close_command(self, ctx: commands.Context) -> None: + """ + Make the current in-use help channel dormant. + + Make the channel dormant if the user passes the `dormant_check`, + delete the message that invoked this, + and reset the send permissions cooldown for the user who started the session. + """ + log.trace("close command invoked; checking if the channel is in-use.") + if ctx.channel.category == self.in_use_category: + if await self.dormant_check(ctx): + await self.remove_cooldown_role(ctx.author) + + # Ignore missing task when cooldown has passed but the channel still isn't dormant. + if ctx.author.id in self.scheduler: + self.scheduler.cancel(ctx.author.id) + + await self.move_to_dormant(ctx.channel, "command") + self.scheduler.cancel(ctx.channel.id) + else: + log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") + + async def get_available_candidate(self) -> discord.TextChannel: + """ + Return a dormant channel to turn into an available channel. + + If no channel is available, wait indefinitely until one becomes available. + """ + log.trace("Getting an available channel candidate.") + + try: + channel = self.channel_queue.get_nowait() + except asyncio.QueueEmpty: + log.info("No candidate channels in the queue; creating a new channel.") + channel = await self.create_dormant() + + if not channel: + log.info("Couldn't create a candidate channel; waiting to get one from the queue.") + await self.notify() + channel = await self.wait_for_dormant_channel() + + return channel + + @staticmethod + def get_clean_channel_name(channel: discord.TextChannel) -> str: + """Return a clean channel name without status emojis prefix.""" + prefix = constants.HelpChannels.name_prefix + try: + # Try to remove the status prefix using the index of the channel prefix + name = channel.name[channel.name.index(prefix):] + log.trace(f"The clean name for `{channel}` is `{name}`") + except ValueError: + # If, for some reason, the channel name does not contain "help-" fall back gracefully + log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") + name = channel.name + + return name + + @staticmethod + def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: + """Check if a channel should be excluded from the help channel system.""" + return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + + def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + """Yield the text channels of the `category` in an unsorted manner.""" + log.trace(f"Getting text channels in the category '{category}' ({category.id}).") + + # This is faster than using category.channels because the latter sorts them. + for channel in self.bot.get_guild(constants.Guild.id).channels: + if channel.category_id == category.id and not self.is_excluded_channel(channel): + yield channel + + async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: + """Return the duration `channel_id` has been in use. Return None if it's not in use.""" + log.trace(f"Calculating in use time for channel {channel_id}.") + + claimed_timestamp = await self.claim_times.get(channel_id) + if claimed_timestamp: + claimed = datetime.utcfromtimestamp(claimed_timestamp) + return datetime.utcnow() - claimed + + @staticmethod + def get_names() -> t.List[str]: + """ + Return a truncated list of prefixed element names. + + The amount of names is configured with `HelpChannels.max_total_channels`. + The prefix is configured with `HelpChannels.name_prefix`. + """ + count = constants.HelpChannels.max_total_channels + prefix = constants.HelpChannels.name_prefix + + log.trace(f"Getting the first {count} element names from JSON.") + + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + all_names = json.load(elements_file) + + if prefix: + return [prefix + name for name in all_names[:count]] + else: + return all_names[:count] + + def get_used_names(self) -> t.Set[str]: + """Return channel names which are already being used.""" + log.trace("Getting channel names which are already being used.") + + names = set() + for cat in (self.available_category, self.in_use_category, self.dormant_category): + for channel in self.get_category_channels(cat): + names.add(self.get_clean_channel_name(channel)) + + if len(names) > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"Too many help channels ({len(names)}) already exist! " + f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." + ) + + log.trace(f"Got {len(names)} used names: {names}") + return names + + @classmethod + async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: + """ + Return the time elapsed, in seconds, since the last message sent in the `channel`. + + Return None if the channel has no messages. + """ + log.trace(f"Getting the idle time for #{channel} ({channel.id}).") + + msg = await cls.get_last_message(channel) + if not msg: + log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") + return None + + idle_time = (datetime.utcnow() - msg.created_at).seconds + + log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") + return idle_time + + @staticmethod + async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: + """Return the last message sent in the channel or None if no messages exist.""" + log.trace(f"Getting the last message in #{channel} ({channel.id}).") + + try: + return await channel.history(limit=1).next() # noqa: B305 + except discord.NoMoreItems: + log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") + return None + + async def init_available(self) -> None: + """Initialise the Available category with channels.""" + log.trace("Initialising the Available category with channels.") + + channels = list(self.get_category_channels(self.available_category)) + missing = constants.HelpChannels.max_available - len(channels) + + # If we've got less than `max_available` channel available, we should add some. + if missing > 0: + log.trace(f"Moving {missing} missing channels to the Available category.") + for _ in range(missing): + await self.move_to_available() + + # If for some reason we have more than `max_available` channels available, + # we should move the superfluous ones over to dormant. + elif missing < 0: + log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") + for channel in channels[:abs(missing)]: + await self.move_to_dormant(channel, "auto") + + async def init_categories(self) -> None: + """Get the help category objects. Remove the cog if retrieval fails.""" + log.trace("Getting the CategoryChannel objects for the help categories.") + + try: + self.available_category = await channel_utils.try_get_channel( + constants.Categories.help_available + ) + self.in_use_category = await channel_utils.try_get_channel( + constants.Categories.help_in_use + ) + self.dormant_category = await channel_utils.try_get_channel( + constants.Categories.help_dormant + ) + except discord.HTTPException: + log.exception("Failed to get a category; cog will be removed") + self.bot.remove_cog(self.qualified_name) + + async def init_cog(self) -> None: + """Initialise the help channel system.""" + log.trace("Waiting for the guild to be available before initialisation.") + await self.bot.wait_until_guild_available() + + log.trace("Initialising the cog.") + await self.init_categories() + await self.check_cooldowns() + + self.channel_queue = self.create_channel_queue() + self.name_queue = self.create_name_queue() + + log.trace("Moving or rescheduling in-use channels.") + for channel in self.get_category_channels(self.in_use_category): + await self.move_idle_channel(channel, has_task=False) + + # Prevent the command from being used until ready. + # The ready event wasn't used because channels could change categories between the time + # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). + # This may confuse users. So would potentially long delays for the cog to become ready. + self.close_command.enabled = True + + await self.init_available() + + log.info("Cog is ready!") + self.ready.set() + + self.report_stats() + + def report_stats(self) -> None: + """Report the channel count stats.""" + total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in self.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) + + self.bot.stats.gauge("help.total.in_use", total_in_use) + self.bot.stats.gauge("help.total.available", total_available) + self.bot.stats.gauge("help.total.dormant", total_dormant) + + @staticmethod + def is_claimant(member: discord.Member) -> bool: + """Return True if `member` has the 'Help Cooldown' role.""" + return any(constants.Roles.help_cooldown == role.id for role in member.roles) + + def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: + """Return `True` if the bot's `message`'s embed description matches `description`.""" + if not message or not message.embeds: + return False + + bot_msg_desc = message.embeds[0].description + if bot_msg_desc is discord.Embed.Empty: + log.trace("Last message was a bot embed but it was empty.") + return False + return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() + + async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: + """ + Make the `channel` dormant if idle or schedule the move if still active. + + If `has_task` is True and rescheduling is required, the extant task to make the channel + dormant will first be cancelled. + """ + log.trace(f"Handling in-use channel #{channel} ({channel.id}).") + + if not await self.is_empty(channel): + idle_seconds = constants.HelpChannels.idle_minutes * 60 + else: + idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 + + time_elapsed = await self.get_idle_time(channel) + + if time_elapsed is None or time_elapsed >= idle_seconds: + log.info( + f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " + f"and will be made dormant." + ) + + await self.move_to_dormant(channel, "auto") + else: + # Cancel the existing task, if any. + if has_task: + self.scheduler.cancel(channel.id) + + delay = idle_seconds - time_elapsed + log.info( + f"#{channel} ({channel.id}) is still active; " + f"scheduling it to be moved after {delay} seconds." + ) + + self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) + + async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: + """ + Move the `channel` to the bottom position of `category` and edit channel attributes. + + To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current + positions of the other channels in the category as-is. This should make sure that the channel + really ends up at the bottom of the category. + + If `options` are provided, the channel will be edited after the move is completed. This is the + same order of operations that `discord.TextChannel.edit` uses. For information on available + options, see the documentation on `discord.TextChannel.edit`. While possible, position-related + options should be avoided, as it may interfere with the category move we perform. + """ + # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. + category = await channel_utils.try_get_channel(category_id) + + payload = [{"id": c.id, "position": c.position} for c in category.channels] + + # Calculate the bottom position based on the current highest position in the category. If the + # category is currently empty, we simply use the current position of the channel to avoid making + # unnecessary changes to positions in the guild. + bottom_position = payload[-1]["position"] + 1 if payload else channel.position + + payload.append( + { + "id": channel.id, + "position": bottom_position, + "parent_id": category.id, + "lock_permissions": True, + } + ) + + # We use d.py's method to ensure our request is processed by d.py's rate limit manager + await self.bot.http.bulk_channel_update(category.guild.id, payload) + + # Now that the channel is moved, we can edit the other attributes + if options: + await channel.edit(**options) + + async def move_to_available(self) -> None: + """Make a channel available.""" + log.trace("Making a channel available.") + + channel = await self.get_available_candidate() + log.info(f"Making #{channel} ({channel.id}) available.") + + await self.send_available_message(channel) + + log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") + + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_available, + ) + + self.report_stats() + + async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: + """ + Make the `channel` dormant. + + A caller argument is provided for metrics. + """ + log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") + + await self.help_channel_claimants.delete(channel.id) + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_dormant, + ) + + self.bot.stats.incr(f"help.dormant_calls.{caller}") + + in_use_time = await self.get_in_use_time(channel.id) + if in_use_time: + self.bot.stats.timing("help.in_use_time", in_use_time) + + unanswered = await self.unanswered.get(channel.id) + if unanswered: + self.bot.stats.incr("help.sessions.unanswered") + elif unanswered is not None: + self.bot.stats.incr("help.sessions.answered") + + log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") + log.trace(f"Sending dormant message for #{channel} ({channel.id}).") + embed = discord.Embed(description=DORMANT_MSG) + await channel.send(embed=embed) + + await self.unpin(channel) + + log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") + self.channel_queue.put_nowait(channel) + self.report_stats() + + async def move_to_in_use(self, channel: discord.TextChannel) -> None: + """Make a channel in-use and schedule it to be made dormant.""" + log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") + + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_in_use, + ) + + timeout = constants.HelpChannels.idle_minutes * 60 + + log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") + self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) + self.report_stats() + + async def notify(self) -> None: + """ + Send a message notifying about a lack of available help channels. + + Configuration: + + * `HelpChannels.notify` - toggle notifications + * `HelpChannels.notify_channel` - destination channel for notifications + * `HelpChannels.notify_minutes` - minimum interval between notifications + * `HelpChannels.notify_roles` - roles mentioned in notifications + """ + if not constants.HelpChannels.notify: + return + + log.trace("Notifying about lack of channels.") + + if self.last_notification: + elapsed = (datetime.utcnow() - self.last_notification).seconds + minimum_interval = constants.HelpChannels.notify_minutes * 60 + should_send = elapsed >= minimum_interval + else: + should_send = True + + if not should_send: + log.trace("Notification not sent because it's too recent since the previous one.") + return + + try: + log.trace("Sending notification message.") + + channel = self.bot.get_channel(constants.HelpChannels.notify_channel) + mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] + + message = await channel.send( + f"{mentions} A new available help channel is needed but there " + f"are no more dormant ones. Consider freeing up some in-use channels manually by " + f"using the `{constants.Bot.prefix}dormant` command within the channels.", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + + self.bot.stats.incr("help.out_of_channel_alerts") + + self.last_notification = message.created_at + except Exception: + # Handle it here cause this feature isn't critical for the functionality of the system. + log.exception("Failed to send notification about lack of dormant channels!") + + async def check_for_answer(self, message: discord.Message) -> None: + """Checks for whether new content in a help channel comes from non-claimants.""" + channel = message.channel + + # Confirm the channel is an in use help channel + if channel_utils.is_in_category(channel, constants.Categories.help_in_use): + log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") + + # Check if there is an entry in unanswered + if await self.unanswered.contains(channel.id): + claimant_id = await self.help_channel_claimants.get(channel.id) + if not claimant_id: + # The mapping for this channel doesn't exist, we can't do anything. + return + + # Check the message did not come from the claimant + if claimant_id != message.author.id: + # Mark the channel as answered + await self.unanswered.set(channel.id, False) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Move an available channel to the In Use category and replace it with a dormant one.""" + if message.author.bot: + return # Ignore messages sent by bots. + + channel = message.channel + + await self.check_for_answer(message) + + is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) + if not is_available or self.is_excluded_channel(channel): + return # Ignore messages outside the Available category or in excluded channels. + + log.trace("Waiting for the cog to be ready before processing messages.") + await self.ready.wait() + + log.trace("Acquiring lock to prevent a channel from being processed twice...") + async with self.on_message_lock: + log.trace(f"on_message lock acquired for {message.id}.") + + if not channel_utils.is_in_category(channel, constants.Categories.help_available): + log.debug( + f"Message {message.id} will not make #{channel} ({channel.id}) in-use " + f"because another message in the channel already triggered that." + ) + return + + log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") + await self.move_to_in_use(channel) + await self.revoke_send_permissions(message.author) + + await self.pin(message) + + # Add user with channel for dormant check. + await self.help_channel_claimants.set(channel.id, message.author.id) + + self.bot.stats.incr("help.claimed") + + # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. + timestamp = datetime.now(timezone.utc).timestamp() + await self.claim_times.set(channel.id, timestamp) + + await self.unanswered.set(channel.id, True) + + log.trace(f"Releasing on_message lock for {message.id}.") + + # Move a dormant channel to the Available category to fill in the gap. + # This is done last and outside the lock because it may wait indefinitely for a channel to + # be put in the queue. + await self.move_to_available() + + @commands.Cog.listener() + async def on_message_delete(self, msg: discord.Message) -> None: + """ + Reschedule an in-use channel to become dormant sooner if the channel is empty. + + The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. + """ + if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): + return + + if not await self.is_empty(msg.channel): + return + + log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") + + # Cancel existing dormant task before scheduling new. + self.scheduler.cancel(msg.channel.id) + + delay = constants.HelpChannels.deleted_idle_minutes * 60 + self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) + + async def is_empty(self, channel: discord.TextChannel) -> bool: + """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + log.trace(f"Checking if #{channel} ({channel.id}) is empty.") + + # A limit of 100 results in a single API call. + # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. + # Not gonna do an extensive search for it cause it's too expensive. + async for msg in channel.history(limit=100): + if not msg.author.bot: + log.trace(f"#{channel} ({channel.id}) has a non-bot message.") + return False + + if self.match_bot_embed(msg, AVAILABLE_MSG): + log.trace(f"#{channel} ({channel.id}) has the available message embed.") + return True + + return False + + async def check_cooldowns(self) -> None: + """Remove expired cooldowns and re-schedule active ones.""" + log.trace("Checking all cooldowns to remove or re-schedule them.") + guild = self.bot.get_guild(constants.Guild.id) + cooldown = constants.HelpChannels.claim_minutes * 60 + + for channel_id, member_id in await self.help_channel_claimants.items(): + member = guild.get_member(member_id) + if not member: + continue # Member probably left the guild. + + in_use_time = await self.get_in_use_time(channel_id) + + if not in_use_time or in_use_time.seconds > cooldown: + # Remove the role if no claim time could be retrieved or if the cooldown expired. + # Since the channel is in the claimants cache, it is definitely strange for a time + # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. + await self.remove_cooldown_role(member) + else: + # The member is still on a cooldown; re-schedule it for the remaining time. + delay = cooldown - in_use_time.seconds + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) + + async def add_cooldown_role(self, member: discord.Member) -> None: + """Add the help cooldown role to `member`.""" + log.trace(f"Adding cooldown role for {member} ({member.id}).") + await self._change_cooldown_role(member, member.add_roles) + + async def remove_cooldown_role(self, member: discord.Member) -> None: + """Remove the help cooldown role from `member`.""" + log.trace(f"Removing cooldown role for {member} ({member.id}).") + await self._change_cooldown_role(member, member.remove_roles) + + async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: + """ + Change `member`'s cooldown role via awaiting `coro_func` and handle errors. + + `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + guild = self.bot.get_guild(constants.Guild.id) + role = guild.get_role(constants.Roles.help_cooldown) + if role is None: + log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") + return + + try: + await coro_func(role) + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") + + async def revoke_send_permissions(self, member: discord.Member) -> None: + """ + Disallow `member` to send messages in the Available category for a certain time. + + The time until permissions are reinstated can be configured with + `HelpChannels.claim_minutes`. + """ + log.trace( + f"Revoking {member}'s ({member.id}) send message permissions in the Available category." + ) + + await self.add_cooldown_role(member) + + # Cancel the existing task, if any. + # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). + if member.id in self.scheduler: + self.scheduler.cancel(member.id) + + delay = constants.HelpChannels.claim_minutes * 60 + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) + + async def send_available_message(self, channel: discord.TextChannel) -> None: + """Send the available message by editing a dormant message or sending a new message.""" + channel_info = f"#{channel} ({channel.id})" + log.trace(f"Sending available message in {channel_info}.") + + embed = discord.Embed( + color=constants.Colours.bright_green, + description=AVAILABLE_MSG, + ) + embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) + embed.set_footer(text=AVAILABLE_FOOTER) + + msg = await self.get_last_message(channel) + if self.match_bot_embed(msg, DORMANT_MSG): + log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") + await msg.edit(embed=embed) + else: + log.trace(f"Dormant message not found in {channel_info}; sending a new message.") + await channel.send(embed=embed) + + async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: + """ + Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. + + Return True if successful and False otherwise. + """ + channel_str = f"#{channel} ({channel.id})" + if pin: + func = self.bot.http.pin_message + verb = "pin" + else: + func = self.bot.http.unpin_message + verb = "unpin" + + try: + await func(channel.id, msg_id) + except discord.HTTPException as e: + if e.code == 10008: + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") + else: + log.exception( + f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" + ) + return False + else: + log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") + return True + + async def pin(self, message: discord.Message) -> None: + """Pin an initial question `message` and store it in a cache.""" + if await self.pin_wrapper(message.id, message.channel, pin=True): + await self.question_messages.set(message.channel.id, message.id) + + async def unpin(self, channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await self.question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") + else: + await self.pin_wrapper(msg_id, channel, pin=False) + + async def wait_for_dormant_channel(self) -> discord.TextChannel: + """Wait for a dormant channel to become available in the queue and return it.""" + log.trace("Waiting for a dormant channel.") + + task = asyncio.create_task(self.channel_queue.get()) + self.queue_tasks.append(task) + channel = await task + + log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") + self.queue_tasks.remove(task) + + return channel + + +def validate_config() -> None: + """Raise a ValueError if the cog's config is invalid.""" + log.trace("Validating config.") + total = constants.HelpChannels.max_total_channels + available = constants.HelpChannels.max_available + + if total == 0 or available == 0: + raise ValueError("max_total_channels and max_available and must be greater than 0.") + + if total < available: + raise ValueError( + f"max_total_channels ({total}) must be greater than or equal to max_available " + f"({available})." + ) + + if total > MAX_CHANNELS_PER_CATEGORY: + raise ValueError( + f"max_total_channels ({total}) must be less than or equal to " + f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." + ) -- cgit v1.2.3 From a1b2e8e9bff23ad5c9c8db8fa35116e7ce6a4965 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 11:44:23 -0700 Subject: Help channels: remove get_clean_channel_name Emoji are no longer used in channel names due to harsher rate limits for renaming channels. Therefore, the function is obsolete. --- bot/exts/help_channels/_cog.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5e2a7dd71..00dd36304 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -251,21 +251,6 @@ class HelpChannels(commands.Cog): return channel - @staticmethod - def get_clean_channel_name(channel: discord.TextChannel) -> str: - """Return a clean channel name without status emojis prefix.""" - prefix = constants.HelpChannels.name_prefix - try: - # Try to remove the status prefix using the index of the channel prefix - name = channel.name[channel.name.index(prefix):] - log.trace(f"The clean name for `{channel}` is `{name}`") - except ValueError: - # If, for some reason, the channel name does not contain "help-" fall back gracefully - log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") - name = channel.name - - return name - @staticmethod def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: """Check if a channel should be excluded from the help channel system.""" @@ -317,7 +302,7 @@ class HelpChannels(commands.Cog): names = set() for cat in (self.available_category, self.in_use_category, self.dormant_category): for channel in self.get_category_channels(cat): - names.add(self.get_clean_channel_name(channel)) + names.add(channel.name) if len(names) > MAX_CHANNELS_PER_CATEGORY: log.warning( -- cgit v1.2.3 From abd6953a0f1567b6ff25d971a97eed966f469743 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:10:46 -0700 Subject: Help channels: move name and channel funcs to separate modules --- bot/exts/help_channels/_channels.py | 26 +++++++++++ bot/exts/help_channels/_cog.py | 93 ++++++------------------------------- bot/exts/help_channels/_names.py | 69 +++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 79 deletions(-) create mode 100644 bot/exts/help_channels/_channels.py create mode 100644 bot/exts/help_channels/_names.py diff --git a/bot/exts/help_channels/_channels.py b/bot/exts/help_channels/_channels.py new file mode 100644 index 000000000..047f41e89 --- /dev/null +++ b/bot/exts/help_channels/_channels.py @@ -0,0 +1,26 @@ +import logging +import typing as t + +import discord + +from bot import constants + +log = logging.getLogger(__name__) + +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) + + +def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + """Yield the text channels of the `category` in an unsorted manner.""" + log.trace(f"Getting text channels in the category '{category}' ({category.id}).") + + # This is faster than using category.channels because the latter sorts them. + for channel in category.guild.channels: + if channel.category_id == category.id and not is_excluded_channel(channel): + yield channel + + +def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: + """Check if a channel should be excluded from the help channel system.""" + return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 00dd36304..1db597e6c 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -1,11 +1,8 @@ import asyncio -import json import logging import random import typing as t -from collections import deque from datetime import datetime, timedelta, timezone -from pathlib import Path import discord import discord.abc @@ -14,14 +11,14 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.exts.help_channels import _channels +from bot.exts.help_channels._names import MAX_CHANNELS_PER_CATEGORY, create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. @@ -123,7 +120,6 @@ class HelpChannels(commands.Cog): self.channel_queue: asyncio.Queue[discord.TextChannel] = None self.name_queue: t.Deque[str] = None - self.name_positions = self.get_names() self.last_notification: t.Optional[datetime] = None # Asyncio stuff @@ -151,7 +147,7 @@ class HelpChannels(commands.Cog): """ log.trace("Creating the channel queue.") - channels = list(self.get_category_channels(self.dormant_category)) + channels = list(_channels.get_category_channels(self.dormant_category)) random.shuffle(channels) log.trace("Populating the channel queue with channels.") @@ -180,18 +176,6 @@ class HelpChannels(commands.Cog): log.debug(f"Creating a new dormant channel named {name}.") return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - def create_name_queue(self) -> deque: - """Return a queue of element names to use for creating new channels.""" - log.trace("Creating the chemical element name queue.") - - used_names = self.get_used_names() - - log.trace("Determining the available names.") - available_names = (name for name in self.name_positions if name not in used_names) - - log.trace("Populating the name queue with names.") - return deque(available_names) - async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user is the help channel claimant or passes the role check.""" if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: @@ -251,20 +235,6 @@ class HelpChannels(commands.Cog): return channel - @staticmethod - def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: - """Check if a channel should be excluded from the help channel system.""" - return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS - - def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - - # This is faster than using category.channels because the latter sorts them. - for channel in self.bot.get_guild(constants.Guild.id).channels: - if channel.category_id == category.id and not self.is_excluded_channel(channel): - yield channel - async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: """Return the duration `channel_id` has been in use. Return None if it's not in use.""" log.trace(f"Calculating in use time for channel {channel_id}.") @@ -274,45 +244,6 @@ class HelpChannels(commands.Cog): claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed - @staticmethod - def get_names() -> t.List[str]: - """ - Return a truncated list of prefixed element names. - - The amount of names is configured with `HelpChannels.max_total_channels`. - The prefix is configured with `HelpChannels.name_prefix`. - """ - count = constants.HelpChannels.max_total_channels - prefix = constants.HelpChannels.name_prefix - - log.trace(f"Getting the first {count} element names from JSON.") - - with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - all_names = json.load(elements_file) - - if prefix: - return [prefix + name for name in all_names[:count]] - else: - return all_names[:count] - - def get_used_names(self) -> t.Set[str]: - """Return channel names which are already being used.""" - log.trace("Getting channel names which are already being used.") - - names = set() - for cat in (self.available_category, self.in_use_category, self.dormant_category): - for channel in self.get_category_channels(cat): - names.add(channel.name) - - if len(names) > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"Too many help channels ({len(names)}) already exist! " - f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." - ) - - log.trace(f"Got {len(names)} used names: {names}") - return names - @classmethod async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: """ @@ -347,7 +278,7 @@ class HelpChannels(commands.Cog): """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") - channels = list(self.get_category_channels(self.available_category)) + channels = list(_channels.get_category_channels(self.available_category)) missing = constants.HelpChannels.max_available - len(channels) # If we've got less than `max_available` channel available, we should add some. @@ -391,10 +322,14 @@ class HelpChannels(commands.Cog): await self.check_cooldowns() self.channel_queue = self.create_channel_queue() - self.name_queue = self.create_name_queue() + self.name_queue = create_name_queue( + self.available_category, + self.in_use_category, + self.dormant_category, + ) log.trace("Moving or rescheduling in-use channels.") - for channel in self.get_category_channels(self.in_use_category): + for channel in _channels.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) # Prevent the command from being used until ready. @@ -412,9 +347,9 @@ class HelpChannels(commands.Cog): def report_stats(self) -> None: """Report the channel count stats.""" - total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in self.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) + total_in_use = sum(1 for _ in _channels.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in _channels.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in _channels.get_category_channels(self.dormant_category)) self.bot.stats.gauge("help.total.in_use", total_in_use) self.bot.stats.gauge("help.total.available", total_available) @@ -660,7 +595,7 @@ class HelpChannels(commands.Cog): await self.check_for_answer(message) is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or self.is_excluded_channel(channel): + if not is_available or _channels.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") diff --git a/bot/exts/help_channels/_names.py b/bot/exts/help_channels/_names.py new file mode 100644 index 000000000..9959c105e --- /dev/null +++ b/bot/exts/help_channels/_names.py @@ -0,0 +1,69 @@ +import json +import logging +import typing as t +from collections import deque +from pathlib import Path + +import discord + +from bot import constants +from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY, get_category_channels + +log = logging.getLogger(__name__) + + +def create_name_queue(*categories: discord.CategoryChannel) -> deque: + """ + Return a queue of element names to use for creating new channels. + + Skip names that are already in use by channels in `categories`. + """ + log.trace("Creating the chemical element name queue.") + + used_names = _get_used_names(*categories) + + log.trace("Determining the available names.") + available_names = (name for name in _get_names() if name not in used_names) + + log.trace("Populating the name queue with names.") + return deque(available_names) + + +def _get_names() -> t.List[str]: + """ + Return a truncated list of prefixed element names. + + The amount of names is configured with `HelpChannels.max_total_channels`. + The prefix is configured with `HelpChannels.name_prefix`. + """ + count = constants.HelpChannels.max_total_channels + prefix = constants.HelpChannels.name_prefix + + log.trace(f"Getting the first {count} element names from JSON.") + + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + all_names = json.load(elements_file) + + if prefix: + return [prefix + name for name in all_names[:count]] + else: + return all_names[:count] + + +def _get_used_names(*categories: discord.CategoryChannel) -> t.Set[str]: + """Return names which are already being used by channels in `categories`.""" + log.trace("Getting channel names which are already being used.") + + names = set() + for cat in categories: + for channel in get_category_channels(cat): + names.add(channel.name) + + if len(names) > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"Too many help channels ({len(names)}) already exist! " + f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." + ) + + log.trace(f"Got {len(names)} used names: {names}") + return names -- cgit v1.2.3 From 0cc221a6169bd12ddcc605eb02f2785716d2446e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:14:19 -0700 Subject: Help channels: move validation code to __init__.py --- bot/exts/help_channels/__init__.py | 32 ++++++++++++++++++++++++++++---- bot/exts/help_channels/_cog.py | 24 +----------------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 38444b707..6ed94ebda 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -1,17 +1,41 @@ import logging +from bot import constants from bot.bot import Bot +from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY log = logging.getLogger(__name__) +def validate_config() -> None: + """Raise a ValueError if the cog's config is invalid.""" + log.trace("Validating config.") + total = constants.HelpChannels.max_total_channels + available = constants.HelpChannels.max_available + + if total == 0 or available == 0: + raise ValueError("max_total_channels and max_available and must be greater than 0.") + + if total < available: + raise ValueError( + f"max_total_channels ({total}) must be greater than or equal to max_available " + f"({available})." + ) + + if total > MAX_CHANNELS_PER_CATEGORY: + raise ValueError( + f"max_total_channels ({total}) must be less than or equal to " + f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." + ) + + def setup(bot: Bot) -> None: """Load the HelpChannels cog.""" - # Defer import to reduce side effects from importing the sync package. - from bot.exts.help_channels import _cog + # Defer import to reduce side effects from importing the help_channels package. + from bot.exts.help_channels._cog import HelpChannels try: - _cog.validate_config() + validate_config() except ValueError as e: log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") else: - bot.add_cog(_cog.HelpChannels(bot)) + bot.add_cog(HelpChannels(bot)) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 1db597e6c..d8fb3b830 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,7 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _channels -from bot.exts.help_channels._names import MAX_CHANNELS_PER_CATEGORY, create_name_queue +from bot.exts.help_channels._names import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -826,25 +826,3 @@ class HelpChannels(commands.Cog): self.queue_tasks.remove(task) return channel - - -def validate_config() -> None: - """Raise a ValueError if the cog's config is invalid.""" - log.trace("Validating config.") - total = constants.HelpChannels.max_total_channels - available = constants.HelpChannels.max_available - - if total == 0 or available == 0: - raise ValueError("max_total_channels and max_available and must be greater than 0.") - - if total < available: - raise ValueError( - f"max_total_channels ({total}) must be greater than or equal to max_available " - f"({available})." - ) - - if total > MAX_CHANNELS_PER_CATEGORY: - raise ValueError( - f"max_total_channels ({total}) must be less than or equal to " - f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." - ) -- cgit v1.2.3 From aa7eff22759e18bcbe0373e5397eb225f563e001 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:18:19 -0700 Subject: Help channels: rename modules to use singular tense Plural names imply the modules contain homogenous content. For example, "channels" implies the module contains multiple kinds of channels. --- bot/exts/help_channels/__init__.py | 2 +- bot/exts/help_channels/_channel.py | 26 ++++++++++++++ bot/exts/help_channels/_channels.py | 26 -------------- bot/exts/help_channels/_cog.py | 18 +++++----- bot/exts/help_channels/_name.py | 69 +++++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_names.py | 69 ------------------------------------- 6 files changed, 105 insertions(+), 105 deletions(-) create mode 100644 bot/exts/help_channels/_channel.py delete mode 100644 bot/exts/help_channels/_channels.py create mode 100644 bot/exts/help_channels/_name.py delete mode 100644 bot/exts/help_channels/_names.py diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 6ed94ebda..781f40449 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -2,7 +2,7 @@ import logging from bot import constants from bot.bot import Bot -from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY +from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY log = logging.getLogger(__name__) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py new file mode 100644 index 000000000..047f41e89 --- /dev/null +++ b/bot/exts/help_channels/_channel.py @@ -0,0 +1,26 @@ +import logging +import typing as t + +import discord + +from bot import constants + +log = logging.getLogger(__name__) + +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) + + +def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + """Yield the text channels of the `category` in an unsorted manner.""" + log.trace(f"Getting text channels in the category '{category}' ({category.id}).") + + # This is faster than using category.channels because the latter sorts them. + for channel in category.guild.channels: + if channel.category_id == category.id and not is_excluded_channel(channel): + yield channel + + +def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: + """Check if a channel should be excluded from the help channel system.""" + return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS diff --git a/bot/exts/help_channels/_channels.py b/bot/exts/help_channels/_channels.py deleted file mode 100644 index 047f41e89..000000000 --- a/bot/exts/help_channels/_channels.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging -import typing as t - -import discord - -from bot import constants - -log = logging.getLogger(__name__) - -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) - - -def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - - # This is faster than using category.channels because the latter sorts them. - for channel in category.guild.channels: - if channel.category_id == category.id and not is_excluded_channel(channel): - yield channel - - -def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: - """Check if a channel should be excluded from the help channel system.""" - return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index d8fb3b830..e58660af8 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,8 +11,8 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channels -from bot.exts.help_channels._names import create_name_queue +from bot.exts.help_channels import _channel +from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -147,7 +147,7 @@ class HelpChannels(commands.Cog): """ log.trace("Creating the channel queue.") - channels = list(_channels.get_category_channels(self.dormant_category)) + channels = list(_channel.get_category_channels(self.dormant_category)) random.shuffle(channels) log.trace("Populating the channel queue with channels.") @@ -278,7 +278,7 @@ class HelpChannels(commands.Cog): """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") - channels = list(_channels.get_category_channels(self.available_category)) + channels = list(_channel.get_category_channels(self.available_category)) missing = constants.HelpChannels.max_available - len(channels) # If we've got less than `max_available` channel available, we should add some. @@ -329,7 +329,7 @@ class HelpChannels(commands.Cog): ) log.trace("Moving or rescheduling in-use channels.") - for channel in _channels.get_category_channels(self.in_use_category): + for channel in _channel.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) # Prevent the command from being used until ready. @@ -347,9 +347,9 @@ class HelpChannels(commands.Cog): def report_stats(self) -> None: """Report the channel count stats.""" - total_in_use = sum(1 for _ in _channels.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in _channels.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in _channels.get_category_channels(self.dormant_category)) + total_in_use = sum(1 for _ in _channel.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in _channel.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in _channel.get_category_channels(self.dormant_category)) self.bot.stats.gauge("help.total.in_use", total_in_use) self.bot.stats.gauge("help.total.available", total_available) @@ -595,7 +595,7 @@ class HelpChannels(commands.Cog): await self.check_for_answer(message) is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or _channels.is_excluded_channel(channel): + if not is_available or _channel.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") diff --git a/bot/exts/help_channels/_name.py b/bot/exts/help_channels/_name.py new file mode 100644 index 000000000..728234b1e --- /dev/null +++ b/bot/exts/help_channels/_name.py @@ -0,0 +1,69 @@ +import json +import logging +import typing as t +from collections import deque +from pathlib import Path + +import discord + +from bot import constants +from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY, get_category_channels + +log = logging.getLogger(__name__) + + +def create_name_queue(*categories: discord.CategoryChannel) -> deque: + """ + Return a queue of element names to use for creating new channels. + + Skip names that are already in use by channels in `categories`. + """ + log.trace("Creating the chemical element name queue.") + + used_names = _get_used_names(*categories) + + log.trace("Determining the available names.") + available_names = (name for name in _get_names() if name not in used_names) + + log.trace("Populating the name queue with names.") + return deque(available_names) + + +def _get_names() -> t.List[str]: + """ + Return a truncated list of prefixed element names. + + The amount of names is configured with `HelpChannels.max_total_channels`. + The prefix is configured with `HelpChannels.name_prefix`. + """ + count = constants.HelpChannels.max_total_channels + prefix = constants.HelpChannels.name_prefix + + log.trace(f"Getting the first {count} element names from JSON.") + + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + all_names = json.load(elements_file) + + if prefix: + return [prefix + name for name in all_names[:count]] + else: + return all_names[:count] + + +def _get_used_names(*categories: discord.CategoryChannel) -> t.Set[str]: + """Return names which are already being used by channels in `categories`.""" + log.trace("Getting channel names which are already being used.") + + names = set() + for cat in categories: + for channel in get_category_channels(cat): + names.add(channel.name) + + if len(names) > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"Too many help channels ({len(names)}) already exist! " + f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." + ) + + log.trace(f"Got {len(names)} used names: {names}") + return names diff --git a/bot/exts/help_channels/_names.py b/bot/exts/help_channels/_names.py deleted file mode 100644 index 9959c105e..000000000 --- a/bot/exts/help_channels/_names.py +++ /dev/null @@ -1,69 +0,0 @@ -import json -import logging -import typing as t -from collections import deque -from pathlib import Path - -import discord - -from bot import constants -from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY, get_category_channels - -log = logging.getLogger(__name__) - - -def create_name_queue(*categories: discord.CategoryChannel) -> deque: - """ - Return a queue of element names to use for creating new channels. - - Skip names that are already in use by channels in `categories`. - """ - log.trace("Creating the chemical element name queue.") - - used_names = _get_used_names(*categories) - - log.trace("Determining the available names.") - available_names = (name for name in _get_names() if name not in used_names) - - log.trace("Populating the name queue with names.") - return deque(available_names) - - -def _get_names() -> t.List[str]: - """ - Return a truncated list of prefixed element names. - - The amount of names is configured with `HelpChannels.max_total_channels`. - The prefix is configured with `HelpChannels.name_prefix`. - """ - count = constants.HelpChannels.max_total_channels - prefix = constants.HelpChannels.name_prefix - - log.trace(f"Getting the first {count} element names from JSON.") - - with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - all_names = json.load(elements_file) - - if prefix: - return [prefix + name for name in all_names[:count]] - else: - return all_names[:count] - - -def _get_used_names(*categories: discord.CategoryChannel) -> t.Set[str]: - """Return names which are already being used by channels in `categories`.""" - log.trace("Getting channel names which are already being used.") - - names = set() - for cat in categories: - for channel in get_category_channels(cat): - names.add(channel.name) - - if len(names) > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"Too many help channels ({len(names)}) already exist! " - f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." - ) - - log.trace(f"Got {len(names)} used names: {names}") - return names -- cgit v1.2.3 From e7d6b2ba81e3609bd52e2d0c4c9d999e7deb14e8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 13:31:13 -0700 Subject: Help channels: move pin functions to a separate module --- bot/exts/help_channels/_cog.py | 50 ++-------------------------------- bot/exts/help_channels/_message.py | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 47 deletions(-) create mode 100644 bot/exts/help_channels/_message.py diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e58660af8..b3d720b24 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,6 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _channel +from bot.exts.help_channels._message import pin, unpin from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -103,10 +104,6 @@ class HelpChannels(commands.Cog): # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] claim_times = RedisCache() - # This cache maps a help channel to original question message in same channel. - # RedisCache[discord.TextChannel.id, discord.Message.id] - question_messages = RedisCache() - def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) @@ -495,7 +492,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) - await self.unpin(channel) + await unpin(channel) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) @@ -616,7 +613,7 @@ class HelpChannels(commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - await self.pin(message) + await pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) @@ -773,47 +770,6 @@ class HelpChannels(commands.Cog): log.trace(f"Dormant message not found in {channel_info}; sending a new message.") await channel.send(embed=embed) - async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: - """ - Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - - Return True if successful and False otherwise. - """ - channel_str = f"#{channel} ({channel.id})" - if pin: - func = self.bot.http.pin_message - verb = "pin" - else: - func = self.bot.http.unpin_message - verb = "unpin" - - try: - await func(channel.id, msg_id) - except discord.HTTPException as e: - if e.code == 10008: - log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") - else: - log.exception( - f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" - ) - return False - else: - log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") - return True - - async def pin(self, message: discord.Message) -> None: - """Pin an initial question `message` and store it in a cache.""" - if await self.pin_wrapper(message.id, message.channel, pin=True): - await self.question_messages.set(message.channel.id, message.id) - - async def unpin(self, channel: discord.TextChannel) -> None: - """Unpin the initial question message sent in `channel`.""" - msg_id = await self.question_messages.pop(channel.id) - if msg_id is None: - log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") - else: - await self.pin_wrapper(msg_id, channel, pin=False) - async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py new file mode 100644 index 000000000..e593aacc9 --- /dev/null +++ b/bot/exts/help_channels/_message.py @@ -0,0 +1,56 @@ +import logging + +import discord +from async_rediscache import RedisCache + +import bot + +log = logging.getLogger(__name__) + +# This cache maps a help channel to original question message in same channel. +# RedisCache[discord.TextChannel.id, discord.Message.id] +_question_messages = RedisCache(namespace="HelpChannels.question_messages") + + +async def pin(message: discord.Message) -> None: + """Pin an initial question `message` and store it in a cache.""" + if await _pin_wrapper(message.id, message.channel, pin=True): + await _question_messages.set(message.channel.id, message.id) + + +async def unpin(channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await _question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") + else: + await _pin_wrapper(msg_id, channel, pin=False) + + +async def _pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: + """ + Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. + + Return True if successful and False otherwise. + """ + channel_str = f"#{channel} ({channel.id})" + if pin: + func = bot.instance.http.pin_message + verb = "pin" + else: + func = bot.instance.http.unpin_message + verb = "unpin" + + try: + await func(channel.id, msg_id) + except discord.HTTPException as e: + if e.code == 10008: + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") + else: + log.exception( + f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" + ) + return False + else: + log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") + return True -- cgit v1.2.3 From e30d69f18dbfefefbc4034d744d0fad5198567c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 14:01:20 -0700 Subject: Help channels: move message functions to message module --- bot/exts/help_channels/_cog.py | 197 ++++--------------------------------- bot/exts/help_channels/_message.py | 172 ++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 178 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index b3d720b24..174c40096 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,47 +11,17 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channel -from bot.exts.help_channels._message import pin, unpin +from bot.exts.help_channels import _channel, _message from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" - HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ -AVAILABLE_MSG = f""" -**Send your question here to claim the channel** -This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. - -**Keep in mind:** -• It's always ok to just ask your question. You don't need permission. -• Explain what you expect to happen and what actually happens. -• Include a code sample and error message, if you got any. - -For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. -""" - -AVAILABLE_TITLE = "Available help channel" - -AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." - -DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ -category at the bottom of the channel list. It is no longer possible to send messages in this \ -channel until it becomes available again. - -If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ -question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. -""" - CoroutineFunc = t.Callable[..., t.Coroutine] @@ -94,12 +64,6 @@ class HelpChannels(commands.Cog): # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] help_channel_claimants = RedisCache() - # This cache maps a help channel to whether it has had any - # activity other than the original claimant. True being no other - # activity and False being other activity. - # RedisCache[discord.TextChannel.id, bool] - unanswered = RedisCache() - # This dictionary maps a help channel to the time it was claimed # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] claim_times = RedisCache() @@ -227,7 +191,12 @@ class HelpChannels(commands.Cog): if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - await self.notify() + notify_channel = self.bot.get_channel(constants.HelpChannels.notify_channel) + last_notification = await _message.notify(notify_channel, self.last_notification) + if last_notification: + self.last_notification = last_notification + self.bot.stats.incr("help.out_of_channel_alerts") + channel = await self.wait_for_dormant_channel() return channel @@ -241,8 +210,8 @@ class HelpChannels(commands.Cog): claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed - @classmethod - async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: + @staticmethod + async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: """ Return the time elapsed, in seconds, since the last message sent in the `channel`. @@ -250,7 +219,7 @@ class HelpChannels(commands.Cog): """ log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - msg = await cls.get_last_message(channel) + msg = await _message.get_last_message(channel) if not msg: log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") return None @@ -260,17 +229,6 @@ class HelpChannels(commands.Cog): log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") return idle_time - @staticmethod - async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: - """Return the last message sent in the channel or None if no messages exist.""" - log.trace(f"Getting the last message in #{channel} ({channel.id}).") - - try: - return await channel.history(limit=1).next() # noqa: B305 - except discord.NoMoreItems: - log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") - return None - async def init_available(self) -> None: """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") @@ -357,17 +315,6 @@ class HelpChannels(commands.Cog): """Return True if `member` has the 'Help Cooldown' role.""" return any(constants.Roles.help_cooldown == role.id for role in member.roles) - def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: - """Return `True` if the bot's `message`'s embed description matches `description`.""" - if not message or not message.embeds: - return False - - bot_msg_desc = message.embeds[0].description - if bot_msg_desc is discord.Embed.Empty: - log.trace("Last message was a bot embed but it was empty.") - return False - return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -377,7 +324,7 @@ class HelpChannels(commands.Cog): """ log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - if not await self.is_empty(channel): + if not await _message.is_empty(channel): idle_seconds = constants.HelpChannels.idle_minutes * 60 else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 @@ -450,7 +397,7 @@ class HelpChannels(commands.Cog): channel = await self.get_available_candidate() log.info(f"Making #{channel} ({channel.id}) available.") - await self.send_available_message(channel) + await _message.send_available_message(channel) log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") @@ -481,7 +428,7 @@ class HelpChannels(commands.Cog): if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) - unanswered = await self.unanswered.get(channel.id) + unanswered = await _message.unanswered.get(channel.id) if unanswered: self.bot.stats.incr("help.sessions.unanswered") elif unanswered is not None: @@ -489,10 +436,10 @@ class HelpChannels(commands.Cog): log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed(description=DORMANT_MSG) + embed = discord.Embed(description=_message.DORMANT_MSG) await channel.send(embed=embed) - await unpin(channel) + await _message.unpin(channel) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) @@ -513,74 +460,6 @@ class HelpChannels(commands.Cog): self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) self.report_stats() - async def notify(self) -> None: - """ - Send a message notifying about a lack of available help channels. - - Configuration: - - * `HelpChannels.notify` - toggle notifications - * `HelpChannels.notify_channel` - destination channel for notifications - * `HelpChannels.notify_minutes` - minimum interval between notifications - * `HelpChannels.notify_roles` - roles mentioned in notifications - """ - if not constants.HelpChannels.notify: - return - - log.trace("Notifying about lack of channels.") - - if self.last_notification: - elapsed = (datetime.utcnow() - self.last_notification).seconds - minimum_interval = constants.HelpChannels.notify_minutes * 60 - should_send = elapsed >= minimum_interval - else: - should_send = True - - if not should_send: - log.trace("Notification not sent because it's too recent since the previous one.") - return - - try: - log.trace("Sending notification message.") - - channel = self.bot.get_channel(constants.HelpChannels.notify_channel) - mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) - allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] - - message = await channel.send( - f"{mentions} A new available help channel is needed but there " - f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels.", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - - self.bot.stats.incr("help.out_of_channel_alerts") - - self.last_notification = message.created_at - except Exception: - # Handle it here cause this feature isn't critical for the functionality of the system. - log.exception("Failed to send notification about lack of dormant channels!") - - async def check_for_answer(self, message: discord.Message) -> None: - """Checks for whether new content in a help channel comes from non-claimants.""" - channel = message.channel - - # Confirm the channel is an in use help channel - if channel_utils.is_in_category(channel, constants.Categories.help_in_use): - log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") - - # Check if there is an entry in unanswered - if await self.unanswered.contains(channel.id): - claimant_id = await self.help_channel_claimants.get(channel.id) - if not claimant_id: - # The mapping for this channel doesn't exist, we can't do anything. - return - - # Check the message did not come from the claimant - if claimant_id != message.author.id: - # Mark the channel as answered - await self.unanswered.set(channel.id, False) - @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" @@ -589,7 +468,7 @@ class HelpChannels(commands.Cog): channel = message.channel - await self.check_for_answer(message) + await _message.check_for_answer(message) is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) if not is_available or _channel.is_excluded_channel(channel): @@ -613,7 +492,7 @@ class HelpChannels(commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - await pin(message) + await _message.pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) @@ -624,7 +503,7 @@ class HelpChannels(commands.Cog): timestamp = datetime.now(timezone.utc).timestamp() await self.claim_times.set(channel.id, timestamp) - await self.unanswered.set(channel.id, True) + await _message.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") @@ -643,7 +522,7 @@ class HelpChannels(commands.Cog): if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return - if not await self.is_empty(msg.channel): + if not await _message.is_empty(msg.channel): return log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") @@ -654,24 +533,6 @@ class HelpChannels(commands.Cog): delay = constants.HelpChannels.deleted_idle_minutes * 60 self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - async def is_empty(self, channel: discord.TextChannel) -> bool: - """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" - log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - - # A limit of 100 results in a single API call. - # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. - # Not gonna do an extensive search for it cause it's too expensive. - async for msg in channel.history(limit=100): - if not msg.author.bot: - log.trace(f"#{channel} ({channel.id}) has a non-bot message.") - return False - - if self.match_bot_embed(msg, AVAILABLE_MSG): - log.trace(f"#{channel} ({channel.id}) has the available message embed.") - return True - - return False - async def check_cooldowns(self) -> None: """Remove expired cooldowns and re-schedule active ones.""" log.trace("Checking all cooldowns to remove or re-schedule them.") @@ -750,26 +611,6 @@ class HelpChannels(commands.Cog): delay = constants.HelpChannels.claim_minutes * 60 self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - async def send_available_message(self, channel: discord.TextChannel) -> None: - """Send the available message by editing a dormant message or sending a new message.""" - channel_info = f"#{channel} ({channel.id})" - log.trace(f"Sending available message in {channel_info}.") - - embed = discord.Embed( - color=constants.Colours.bright_green, - description=AVAILABLE_MSG, - ) - embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) - embed.set_footer(text=AVAILABLE_FOOTER) - - msg = await self.get_last_message(channel) - if self.match_bot_embed(msg, DORMANT_MSG): - log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") - await msg.edit(embed=embed) - else: - log.trace(f"Dormant message not found in {channel_info}; sending a new message.") - await channel.send(embed=embed) - async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index e593aacc9..eaf8b0ab5 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -1,23 +1,183 @@ import logging +import typing as t +from datetime import datetime import discord from async_rediscache import RedisCache import bot +from bot import constants +from bot.utils.channel import is_in_category log = logging.getLogger(__name__) +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" + +AVAILABLE_MSG = f""" +**Send your question here to claim the channel** +This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. + +**Keep in mind:** +• It's always ok to just ask your question. You don't need permission. +• Explain what you expect to happen and what actually happens. +• Include a code sample and error message, if you got any. + +For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. +""" + +AVAILABLE_TITLE = "Available help channel" + +AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." + +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +category at the bottom of the channel list. It is no longer possible to send messages in this \ +channel until it becomes available again. + +If your question wasn't answered yet, you can claim a new help channel from the \ +**Help: Available** category by simply asking your question again. Consider rephrasing the \ +question to maximize your chance of getting a good answer. If you're not sure how, have a look \ +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. +""" + +# This cache maps a help channel to whether it has had any +# activity other than the original claimant. True being no other +# activity and False being other activity. +# RedisCache[discord.TextChannel.id, bool] +unanswered = RedisCache(namespace="HelpChannels.unanswered") + +# This cache tracks which channels are claimed by which members. +# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] +_help_channel_claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") + # This cache maps a help channel to original question message in same channel. # RedisCache[discord.TextChannel.id, discord.Message.id] _question_messages = RedisCache(namespace="HelpChannels.question_messages") +async def check_for_answer(message: discord.Message) -> None: + """Checks for whether new content in a help channel comes from non-claimants.""" + channel = message.channel + + # Confirm the channel is an in use help channel + if is_in_category(channel, constants.Categories.help_in_use): + log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") + + # Check if there is an entry in unanswered + if await unanswered.contains(channel.id): + claimant_id = await _help_channel_claimants.get(channel.id) + if not claimant_id: + # The mapping for this channel doesn't exist, we can't do anything. + return + + # Check the message did not come from the claimant + if claimant_id != message.author.id: + # Mark the channel as answered + await unanswered.set(channel.id, False) + + +async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: + """Return the last message sent in the channel or None if no messages exist.""" + log.trace(f"Getting the last message in #{channel} ({channel.id}).") + + try: + return await channel.history(limit=1).next() # noqa: B305 + except discord.NoMoreItems: + log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") + return None + + +async def is_empty(channel: discord.TextChannel) -> bool: + """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + log.trace(f"Checking if #{channel} ({channel.id}) is empty.") + + # A limit of 100 results in a single API call. + # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. + # Not gonna do an extensive search for it cause it's too expensive. + async for msg in channel.history(limit=100): + if not msg.author.bot: + log.trace(f"#{channel} ({channel.id}) has a non-bot message.") + return False + + if _match_bot_embed(msg, AVAILABLE_MSG): + log.trace(f"#{channel} ({channel.id}) has the available message embed.") + return True + + return False + + +async def notify(channel: discord.TextChannel, last_notification: t.Optional[datetime]) -> t.Optional[datetime]: + """ + Send a message in `channel` notifying about a lack of available help channels. + + Configuration: + + * `HelpChannels.notify` - toggle notifications + * `HelpChannels.notify_minutes` - minimum interval between notifications + * `HelpChannels.notify_roles` - roles mentioned in notifications + """ + if not constants.HelpChannels.notify: + return + + log.trace("Notifying about lack of channels.") + + if last_notification: + elapsed = (datetime.utcnow() - last_notification).seconds + minimum_interval = constants.HelpChannels.notify_minutes * 60 + should_send = elapsed >= minimum_interval + else: + should_send = True + + if not should_send: + log.trace("Notification not sent because it's too recent since the previous one.") + return + + try: + log.trace("Sending notification message.") + + mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] + + message = await channel.send( + f"{mentions} A new available help channel is needed but there " + f"are no more dormant ones. Consider freeing up some in-use channels manually by " + f"using the `{constants.Bot.prefix}dormant` command within the channels.", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + + return message.created_at + except Exception: + # Handle it here cause this feature isn't critical for the functionality of the system. + log.exception("Failed to send notification about lack of dormant channels!") + + async def pin(message: discord.Message) -> None: """Pin an initial question `message` and store it in a cache.""" if await _pin_wrapper(message.id, message.channel, pin=True): await _question_messages.set(message.channel.id, message.id) +async def send_available_message(channel: discord.TextChannel) -> None: + """Send the available message by editing a dormant message or sending a new message.""" + channel_info = f"#{channel} ({channel.id})" + log.trace(f"Sending available message in {channel_info}.") + + embed = discord.Embed( + color=constants.Colours.bright_green, + description=AVAILABLE_MSG, + ) + embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) + embed.set_footer(text=AVAILABLE_FOOTER) + + msg = await get_last_message(channel) + if _match_bot_embed(msg, DORMANT_MSG): + log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") + await msg.edit(embed=embed) + else: + log.trace(f"Dormant message not found in {channel_info}; sending a new message.") + await channel.send(embed=embed) + + async def unpin(channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" msg_id = await _question_messages.pop(channel.id) @@ -27,6 +187,18 @@ async def unpin(channel: discord.TextChannel) -> None: await _pin_wrapper(msg_id, channel, pin=False) +def _match_bot_embed(message: t.Optional[discord.Message], description: str) -> bool: + """Return `True` if the bot's `message`'s embed description matches `description`.""" + if not message or not message.embeds: + return False + + bot_msg_desc = message.embeds[0].description + if bot_msg_desc is discord.Embed.Empty: + log.trace("Last message was a bot embed but it was empty.") + return False + return message.author == bot.instance.user and bot_msg_desc.strip() == description.strip() + + async def _pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: """ Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. -- cgit v1.2.3 From 44fe885de67135dd16a44539fc97d8e7fc543400 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:56:08 -0700 Subject: Help channels: move time functions to channel module --- bot/exts/help_channels/_channel.py | 36 ++++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_cog.py | 34 +++------------------------------- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 047f41e89..93c0c7fc9 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -1,15 +1,22 @@ import logging import typing as t +from datetime import datetime, timedelta import discord +from async_rediscache import RedisCache from bot import constants +from bot.exts.help_channels import _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) +# This dictionary maps a help channel to the time it was claimed +# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] +_claim_times = RedisCache(namespace="HelpChannels.claim_times") + def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" @@ -21,6 +28,35 @@ def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[disco yield channel +async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: + """ + Return the time elapsed, in seconds, since the last message sent in the `channel`. + + Return None if the channel has no messages. + """ + log.trace(f"Getting the idle time for #{channel} ({channel.id}).") + + msg = await _message.get_last_message(channel) + if not msg: + log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") + return None + + idle_time = (datetime.utcnow() - msg.created_at).seconds + + log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") + return idle_time + + +async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: + """Return the duration `channel_id` has been in use. Return None if it's not in use.""" + log.trace(f"Calculating in use time for channel {channel_id}.") + + claimed_timestamp = await _claim_times.get(channel_id) + if claimed_timestamp: + claimed = datetime.utcfromtimestamp(claimed_timestamp) + return datetime.utcnow() - claimed + + def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: """Check if a channel should be excluded from the help channel system.""" return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 174c40096..390528fde 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -2,7 +2,7 @@ import asyncio import logging import random import typing as t -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import discord import discord.abc @@ -201,34 +201,6 @@ class HelpChannels(commands.Cog): return channel - async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: - """Return the duration `channel_id` has been in use. Return None if it's not in use.""" - log.trace(f"Calculating in use time for channel {channel_id}.") - - claimed_timestamp = await self.claim_times.get(channel_id) - if claimed_timestamp: - claimed = datetime.utcfromtimestamp(claimed_timestamp) - return datetime.utcnow() - claimed - - @staticmethod - async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: - """ - Return the time elapsed, in seconds, since the last message sent in the `channel`. - - Return None if the channel has no messages. - """ - log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - - msg = await _message.get_last_message(channel) - if not msg: - log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") - return None - - idle_time = (datetime.utcnow() - msg.created_at).seconds - - log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") - return idle_time - async def init_available(self) -> None: """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") @@ -329,7 +301,7 @@ class HelpChannels(commands.Cog): else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 - time_elapsed = await self.get_idle_time(channel) + time_elapsed = await _channel.get_idle_time(channel) if time_elapsed is None or time_elapsed >= idle_seconds: log.info( @@ -424,7 +396,7 @@ class HelpChannels(commands.Cog): self.bot.stats.incr(f"help.dormant_calls.{caller}") - in_use_time = await self.get_in_use_time(channel.id) + in_use_time = await _channel.get_in_use_time(channel.id) if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) -- cgit v1.2.3 From 3843ae1546a1a1cbf7ca05756eee223bf7c2f317 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 10:40:17 -0700 Subject: Help channels: move cooldown/role functions to cooldown module --- bot/exts/help_channels/_cog.py | 88 ++----------------------------- bot/exts/help_channels/_cooldown.py | 100 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 84 deletions(-) create mode 100644 bot/exts/help_channels/_cooldown.py diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 390528fde..169238937 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,7 +11,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channel, _message +from bot.exts.help_channels import _channel, _cooldown, _message from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -22,8 +22,6 @@ HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ -CoroutineFunc = t.Callable[..., t.Coroutine] - class HelpChannels(commands.Cog): """ @@ -164,7 +162,7 @@ class HelpChannels(commands.Cog): log.trace("close command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): - await self.remove_cooldown_role(ctx.author) + await _cooldown.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. if ctx.author.id in self.scheduler: @@ -246,7 +244,7 @@ class HelpChannels(commands.Cog): log.trace("Initialising the cog.") await self.init_categories() - await self.check_cooldowns() + await _cooldown.check_cooldowns(self.scheduler) self.channel_queue = self.create_channel_queue() self.name_queue = create_name_queue( @@ -462,7 +460,7 @@ class HelpChannels(commands.Cog): log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) - await self.revoke_send_permissions(message.author) + await _cooldown.revoke_send_permissions(message.author, self.scheduler) await _message.pin(message) @@ -505,84 +503,6 @@ class HelpChannels(commands.Cog): delay = constants.HelpChannels.deleted_idle_minutes * 60 self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - async def check_cooldowns(self) -> None: - """Remove expired cooldowns and re-schedule active ones.""" - log.trace("Checking all cooldowns to remove or re-schedule them.") - guild = self.bot.get_guild(constants.Guild.id) - cooldown = constants.HelpChannels.claim_minutes * 60 - - for channel_id, member_id in await self.help_channel_claimants.items(): - member = guild.get_member(member_id) - if not member: - continue # Member probably left the guild. - - in_use_time = await self.get_in_use_time(channel_id) - - if not in_use_time or in_use_time.seconds > cooldown: - # Remove the role if no claim time could be retrieved or if the cooldown expired. - # Since the channel is in the claimants cache, it is definitely strange for a time - # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. - await self.remove_cooldown_role(member) - else: - # The member is still on a cooldown; re-schedule it for the remaining time. - delay = cooldown - in_use_time.seconds - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - - async def add_cooldown_role(self, member: discord.Member) -> None: - """Add the help cooldown role to `member`.""" - log.trace(f"Adding cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.add_roles) - - async def remove_cooldown_role(self, member: discord.Member) -> None: - """Remove the help cooldown role from `member`.""" - log.trace(f"Removing cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.remove_roles) - - async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: - """ - Change `member`'s cooldown role via awaiting `coro_func` and handle errors. - - `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - guild = self.bot.get_guild(constants.Guild.id) - role = guild.get_role(constants.Roles.help_cooldown) - if role is None: - log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") - return - - try: - await coro_func(role) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - - async def revoke_send_permissions(self, member: discord.Member) -> None: - """ - Disallow `member` to send messages in the Available category for a certain time. - - The time until permissions are reinstated can be configured with - `HelpChannels.claim_minutes`. - """ - log.trace( - f"Revoking {member}'s ({member.id}) send message permissions in the Available category." - ) - - await self.add_cooldown_role(member) - - # Cancel the existing task, if any. - # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - if member.id in self.scheduler: - self.scheduler.cancel(member.id) - - delay = constants.HelpChannels.claim_minutes * 60 - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py new file mode 100644 index 000000000..c4fd4b662 --- /dev/null +++ b/bot/exts/help_channels/_cooldown.py @@ -0,0 +1,100 @@ +import logging +from typing import Callable, Coroutine + +import discord +from async_rediscache import RedisCache + +import bot +from bot import constants +from bot.exts.help_channels import _channel +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) +CoroutineFunc = Callable[..., Coroutine] + +# This cache tracks which channels are claimed by which members. +# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] +_help_channel_claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") + + +async def add_cooldown_role(member: discord.Member) -> None: + """Add the help cooldown role to `member`.""" + log.trace(f"Adding cooldown role for {member} ({member.id}).") + await _change_cooldown_role(member, member.add_roles) + + +async def check_cooldowns(scheduler: Scheduler) -> None: + """Remove expired cooldowns and re-schedule active ones.""" + log.trace("Checking all cooldowns to remove or re-schedule them.") + guild = bot.instance.get_guild(constants.Guild.id) + cooldown = constants.HelpChannels.claim_minutes * 60 + + for channel_id, member_id in await _help_channel_claimants.items(): + member = guild.get_member(member_id) + if not member: + continue # Member probably left the guild. + + in_use_time = await _channel.get_in_use_time(channel_id) + + if not in_use_time or in_use_time.seconds > cooldown: + # Remove the role if no claim time could be retrieved or if the cooldown expired. + # Since the channel is in the claimants cache, it is definitely strange for a time + # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. + await remove_cooldown_role(member) + else: + # The member is still on a cooldown; re-schedule it for the remaining time. + delay = cooldown - in_use_time.seconds + scheduler.schedule_later(delay, member.id, remove_cooldown_role(member)) + + +async def remove_cooldown_role(member: discord.Member) -> None: + """Remove the help cooldown role from `member`.""" + log.trace(f"Removing cooldown role for {member} ({member.id}).") + await _change_cooldown_role(member, member.remove_roles) + + +async def revoke_send_permissions(member: discord.Member, scheduler: Scheduler) -> None: + """ + Disallow `member` to send messages in the Available category for a certain time. + + The time until permissions are reinstated can be configured with + `HelpChannels.claim_minutes`. + """ + log.trace( + f"Revoking {member}'s ({member.id}) send message permissions in the Available category." + ) + + await add_cooldown_role(member) + + # Cancel the existing task, if any. + # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). + if member.id in scheduler: + scheduler.cancel(member.id) + + delay = constants.HelpChannels.claim_minutes * 60 + scheduler.schedule_later(delay, member.id, remove_cooldown_role(member)) + + +async def _change_cooldown_role(member: discord.Member, coro_func: CoroutineFunc) -> None: + """ + Change `member`'s cooldown role via awaiting `coro_func` and handle errors. + + `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + guild = bot.instance.get_guild(constants.Guild.id) + role = guild.get_role(constants.Roles.help_cooldown) + if role is None: + log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") + return + + try: + await coro_func(role) + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") -- cgit v1.2.3 From b9593039a1663c983b84970dc8832900ce9e4cbb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:58:43 -0700 Subject: Help channels: remove obsolete function --- bot/exts/help_channels/_cog.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 169238937..5c4a0d972 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -280,11 +280,6 @@ class HelpChannels(commands.Cog): self.bot.stats.gauge("help.total.available", total_available) self.bot.stats.gauge("help.total.dormant", total_dormant) - @staticmethod - def is_claimant(member: discord.Member) -> bool: - """Return True if `member` has the 'Help Cooldown' role.""" - return any(constants.Roles.help_cooldown == role.id for role in member.roles) - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. -- cgit v1.2.3 From 2eb56e4f863475307eafa070d22e71d061641f6a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 19 Oct 2020 13:08:05 -0700 Subject: Help channels: replace ready event with awaiting the init task The event is redundant since awaiting the task accomplishes the same thing. If the task is already done, the await will finish immediately. If the task gets cancelled, the error is raised but discord.py suppress it in both commands and event listeners. --- bot/exts/help_channels/_cog.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5c4a0d972..bea1f65e6 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -83,7 +83,6 @@ class HelpChannels(commands.Cog): # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] - self.ready = asyncio.Event() self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) @@ -264,11 +263,9 @@ class HelpChannels(commands.Cog): self.close_command.enabled = True await self.init_available() + self.report_stats() log.info("Cog is ready!") - self.ready.set() - - self.report_stats() def report_stats(self) -> None: """Report the channel count stats.""" @@ -439,8 +436,9 @@ class HelpChannels(commands.Cog): if not is_available or _channel.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. - log.trace("Waiting for the cog to be ready before processing messages.") - await self.ready.wait() + if not self.init_task.done(): + log.trace("Waiting for the cog to be ready before processing messages.") + await self.init_task log.trace("Acquiring lock to prevent a channel from being processed twice...") async with self.on_message_lock: -- cgit v1.2.3 From 9debf8d649e0f63753f0f486cd8b7490d90c324c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 19 Oct 2020 13:08:29 -0700 Subject: Help channels: wait for cog to be ready in deleted msg listener --- bot/exts/help_channels/_cog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index bea1f65e6..29570bab3 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -488,6 +488,10 @@ class HelpChannels(commands.Cog): if not await _message.is_empty(msg.channel): return + if not self.init_task.done(): + log.trace("Waiting for the cog to be ready before processing deleted messages.") + await self.init_task + log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") # Cancel existing dormant task before scheduling new. -- cgit v1.2.3 From 21f6a9a5f799c88f746b2bbda250ec1a6bb8f845 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 20 Oct 2020 12:31:40 -0700 Subject: Help channels: move all caches to a separate module Some need to be shared among modules, so it became redundant to redefine them in each module. --- bot/exts/help_channels/_caches.py | 19 +++++++++++++++++++ bot/exts/help_channels/_channel.py | 9 ++------- bot/exts/help_channels/_cog.py | 23 +++++++---------------- bot/exts/help_channels/_cooldown.py | 9 ++------- bot/exts/help_channels/_message.py | 26 ++++++-------------------- 5 files changed, 36 insertions(+), 50 deletions(-) create mode 100644 bot/exts/help_channels/_caches.py diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py new file mode 100644 index 000000000..4cea385b7 --- /dev/null +++ b/bot/exts/help_channels/_caches.py @@ -0,0 +1,19 @@ +from async_rediscache import RedisCache + +# This dictionary maps a help channel to the time it was claimed +# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] +claim_times = RedisCache(namespace="HelpChannels.claim_times") + +# This cache tracks which channels are claimed by which members. +# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] +claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") + +# This cache maps a help channel to original question message in same channel. +# RedisCache[discord.TextChannel.id, discord.Message.id] +question_messages = RedisCache(namespace="HelpChannels.question_messages") + +# This cache maps a help channel to whether it has had any +# activity other than the original claimant. True being no other +# activity and False being other activity. +# RedisCache[discord.TextChannel.id, bool] +unanswered = RedisCache(namespace="HelpChannels.unanswered") diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 93c0c7fc9..d6d6f1245 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -3,20 +3,15 @@ import typing as t from datetime import datetime, timedelta import discord -from async_rediscache import RedisCache from bot import constants -from bot.exts.help_channels import _message +from bot.exts.help_channels import _caches, _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) -# This dictionary maps a help channel to the time it was claimed -# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -_claim_times = RedisCache(namespace="HelpChannels.claim_times") - def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" @@ -51,7 +46,7 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: """Return the duration `channel_id` has been in use. Return None if it's not in use.""" log.trace(f"Calculating in use time for channel {channel_id}.") - claimed_timestamp = await _claim_times.get(channel_id) + claimed_timestamp = await _caches.claim_times.get(channel_id) if claimed_timestamp: claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 29570bab3..a17213323 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -6,12 +6,11 @@ from datetime import datetime, timezone import discord import discord.abc -from async_rediscache import RedisCache from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channel, _cooldown, _message +from bot.exts.help_channels import _caches, _channel, _cooldown, _message from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -58,14 +57,6 @@ class HelpChannels(commands.Cog): Help channels are named after the chemical elements in `bot/resources/elements.json`. """ - # This cache tracks which channels are claimed by which members. - # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] - help_channel_claimants = RedisCache() - - # This dictionary maps a help channel to the time it was claimed - # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] - claim_times = RedisCache() - def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) @@ -136,7 +127,7 @@ class HelpChannels(commands.Cog): async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user is the help channel claimant or passes the role check.""" - if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: + if await _caches.claimants.get(ctx.channel.id) == ctx.author.id: log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") self.bot.stats.incr("help.dormant_invoke.claimant") return True @@ -378,7 +369,7 @@ class HelpChannels(commands.Cog): """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await self.help_channel_claimants.delete(channel.id) + await _caches.claimants.delete(channel.id) await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_dormant, @@ -390,7 +381,7 @@ class HelpChannels(commands.Cog): if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) - unanswered = await _message.unanswered.get(channel.id) + unanswered = await _caches.unanswered.get(channel.id) if unanswered: self.bot.stats.incr("help.sessions.unanswered") elif unanswered is not None: @@ -458,15 +449,15 @@ class HelpChannels(commands.Cog): await _message.pin(message) # Add user with channel for dormant check. - await self.help_channel_claimants.set(channel.id, message.author.id) + await _caches.claimants.set(channel.id, message.author.id) self.bot.stats.incr("help.claimed") # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. timestamp = datetime.now(timezone.utc).timestamp() - await self.claim_times.set(channel.id, timestamp) + await _caches.claim_times.set(channel.id, timestamp) - await _message.unanswered.set(channel.id, True) + await _caches.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py index c4fd4b662..c5c39297f 100644 --- a/bot/exts/help_channels/_cooldown.py +++ b/bot/exts/help_channels/_cooldown.py @@ -2,20 +2,15 @@ import logging from typing import Callable, Coroutine import discord -from async_rediscache import RedisCache import bot from bot import constants -from bot.exts.help_channels import _channel +from bot.exts.help_channels import _caches, _channel from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) CoroutineFunc = Callable[..., Coroutine] -# This cache tracks which channels are claimed by which members. -# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] -_help_channel_claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") - async def add_cooldown_role(member: discord.Member) -> None: """Add the help cooldown role to `member`.""" @@ -29,7 +24,7 @@ async def check_cooldowns(scheduler: Scheduler) -> None: guild = bot.instance.get_guild(constants.Guild.id) cooldown = constants.HelpChannels.claim_minutes * 60 - for channel_id, member_id in await _help_channel_claimants.items(): + for channel_id, member_id in await _caches.claimants.items(): member = guild.get_member(member_id) if not member: continue # Member probably left the guild. diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index eaf8b0ab5..8b058d5aa 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -3,10 +3,10 @@ import typing as t from datetime import datetime import discord -from async_rediscache import RedisCache import bot from bot import constants +from bot.exts.help_channels import _caches from bot.utils.channel import is_in_category log = logging.getLogger(__name__) @@ -40,20 +40,6 @@ question to maximize your chance of getting a good answer. If you're not sure ho through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. """ -# This cache maps a help channel to whether it has had any -# activity other than the original claimant. True being no other -# activity and False being other activity. -# RedisCache[discord.TextChannel.id, bool] -unanswered = RedisCache(namespace="HelpChannels.unanswered") - -# This cache tracks which channels are claimed by which members. -# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] -_help_channel_claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") - -# This cache maps a help channel to original question message in same channel. -# RedisCache[discord.TextChannel.id, discord.Message.id] -_question_messages = RedisCache(namespace="HelpChannels.question_messages") - async def check_for_answer(message: discord.Message) -> None: """Checks for whether new content in a help channel comes from non-claimants.""" @@ -64,8 +50,8 @@ async def check_for_answer(message: discord.Message) -> None: log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Check if there is an entry in unanswered - if await unanswered.contains(channel.id): - claimant_id = await _help_channel_claimants.get(channel.id) + if await _caches.unanswered.contains(channel.id): + claimant_id = await _caches.claimants.get(channel.id) if not claimant_id: # The mapping for this channel doesn't exist, we can't do anything. return @@ -73,7 +59,7 @@ async def check_for_answer(message: discord.Message) -> None: # Check the message did not come from the claimant if claimant_id != message.author.id: # Mark the channel as answered - await unanswered.set(channel.id, False) + await _caches.unanswered.set(channel.id, False) async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: @@ -154,7 +140,7 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[dat async def pin(message: discord.Message) -> None: """Pin an initial question `message` and store it in a cache.""" if await _pin_wrapper(message.id, message.channel, pin=True): - await _question_messages.set(message.channel.id, message.id) + await _caches.question_messages.set(message.channel.id, message.id) async def send_available_message(channel: discord.TextChannel) -> None: @@ -180,7 +166,7 @@ async def send_available_message(channel: discord.TextChannel) -> None: async def unpin(channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" - msg_id = await _question_messages.pop(channel.id) + msg_id = await _caches.question_messages.pop(channel.id) if msg_id is None: log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") else: -- cgit v1.2.3 From 47d65fca599d138098d5021d403db78e4abc0e7a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Nov 2020 16:42:24 -0800 Subject: Help channels: merge 2 imports into 1 The import was an outlier compared to how the other modules were imported. It's nicer to keep the imports consistent. --- bot/exts/help_channels/_cog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a17213323..638d00e4a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -10,8 +10,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _cooldown, _message -from bot.exts.help_channels._name import create_name_queue +from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -237,7 +236,7 @@ class HelpChannels(commands.Cog): await _cooldown.check_cooldowns(self.scheduler) self.channel_queue = self.create_channel_queue() - self.name_queue = create_name_queue( + self.name_queue = _name.create_name_queue( self.available_category, self.in_use_category, self.dormant_category, -- cgit v1.2.3 From 8ea13768378deadef6e666ed40ed88ff8a08e16d Mon Sep 17 00:00:00 2001 From: Steele Date: Fri, 20 Nov 2020 22:22:10 -0500 Subject: `!close` removes the cooldown role from the claimant even when invoked by someone else; flattened `close_command` --- bot/exts/help_channels.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index f5a8b251b..e50fab7fc 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -213,18 +213,23 @@ class HelpChannels(commands.Cog): and reset the send permissions cooldown for the user who started the session. """ log.trace("close command invoked; checking if the channel is in-use.") - if ctx.channel.category == self.in_use_category: - if await self.dormant_check(ctx): - await self.remove_cooldown_role(ctx.author) + if ctx.channel.category != self.in_use_category: + log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") + return - # Ignore missing task when cooldown has passed but the channel still isn't dormant. - if ctx.author.id in self.scheduler: - self.scheduler.cancel(ctx.author.id) + if not await self.dormant_check(ctx): + return - await self.move_to_dormant(ctx.channel, "command") - self.scheduler.cancel(ctx.channel.id) - else: - log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") + guild = self.bot.get_guild(constants.Guild.id) + claimant = guild.get_member(await self.help_channel_claimants.get(ctx.channel.id)) + await self.move_to_dormant(ctx.channel, "command") + await self.remove_cooldown_role(claimant) + + # Ignore missing task when cooldown has passed but the channel still isn't dormant. + if ctx.author.id in self.scheduler: + self.scheduler.cancel(ctx.author.id) + + self.scheduler.cancel(ctx.channel.id) async def get_available_candidate(self) -> discord.TextChannel: """ -- cgit v1.2.3 From 6ff9a5e1500805f98ee136757192c868fa17c764 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 21 Nov 2020 09:37:08 +0200 Subject: Don't log CommandError, handle it as check fail --- bot/exts/info/help.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 9d7f0702e..518330370 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -181,8 +181,8 @@ class CustomHelpCommand(HelpCommand): command_details += "***You cannot run this command.***\n\n" except DisabledCommand: command_details += "***This command is disabled.***\n\n" - except CommandError as e: - log.warning(f"An exception raised when trying to check {command.name} command running permission: {e}") + except CommandError: + command_details += "***You cannot run this command.***\n\n" command_details += f"*{command.help or 'No details provided.'}*\n" embed.description = command_details -- cgit v1.2.3 From 436cdcd1d4e1fc6dbf32a65d8cd76f644f653770 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 23 Nov 2020 23:48:21 +0100 Subject: Narrow down repository events that trigger a build I've narrowed down repository events that trigger a Build to the "push" event specifically. This means that we never build for a "pull request" trigger, even if the source branch is called "master". Signed-off-by: Sebastiaan Zeeff --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 706ab462f..6152f1543 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ on: jobs: build: - if: github.event.workflow_run.conclusion == 'success' + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' name: Build & Push runs-on: ubuntu-latest -- cgit v1.2.3 From 7b67df5d427a43b57df9a7f6e5ca13530958dfb4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 23 Nov 2020 23:55:43 +0100 Subject: Open deployment.yaml from kubernetes repository We will now use the deployment information located in the private python-discord/kubernetes repository. The workflow will use a GitHub Personal Access Token to access this private repository. Signed-off-by: Sebastiaan Zeeff --- .github/workflows/deploy.yml | 5 ++++- deployment.yaml | 21 --------------------- 2 files changed, 4 insertions(+), 22 deletions(-) delete mode 100644 deployment.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 90555a8ee..5a4aede30 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,6 +23,9 @@ jobs: - name: Checkout code uses: actions/checkout@v2 + with: + repository: python-discord/kubernetes + token: ${{ secrets.REPO_TOKEN }} - name: Authenticate with Kubernetes uses: azure/k8s-set-context@v1 @@ -34,6 +37,6 @@ jobs: uses: Azure/k8s-deploy@v1 with: manifests: | - deployment.yaml + bot/deployment.yaml images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}' kubectl-version: 'latest' diff --git a/deployment.yaml b/deployment.yaml deleted file mode 100644 index ca5ff5941..000000000 --- a/deployment.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: bot -spec: - replicas: 1 - selector: - matchLabels: - app: bot - template: - metadata: - labels: - app: bot - spec: - containers: - - name: bot - image: ghcr.io/python-discord/bot:latest - imagePullPolicy: Always - envFrom: - - secretRef: - name: bot-env -- cgit v1.2.3 From 17b9c1a895855414bc28060bc615280f22449062 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 24 Nov 2020 09:06:38 +0000 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cf5f1590d..8b1378917 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @python-discord/core-developers + -- cgit v1.2.3 From ed8160f3e73e2060630b10e1fb0b762e0a51294a Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 24 Nov 2020 09:11:03 +0000 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8b1378917..cf343e5f0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,2 @@ - +# Request Joe for any PR +* @jb3 -- cgit v1.2.3 From 6204906c734e0f0d44f7c1c544fbf4eb2443acc8 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 21:56:34 +0300 Subject: Adds VoiceChannels and Related Chats to Config Updates config-default.yml to include voice channels, and the text chat channel they map to. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/constants.py | 7 +++++++ config-default.yml | 13 ++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6bb6aacd2..ecbf5f98e 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -398,6 +398,9 @@ class Channels(metaclass=YAMLGetter): change_log: int code_help_voice: int code_help_voice_2: int + general_voice: int + admins_voice: int + staff_voice: int cooldown: int defcon: int dev_contrib: int @@ -430,7 +433,11 @@ class Channels(metaclass=YAMLGetter): user_event_announcements: int user_log: int verification: int + code_help_chat: int + code_help_chat_2: int voice_chat: int + admins_voice_chat: int + staff_voice_chat: int voice_gate: int voice_log: int diff --git a/config-default.yml b/config-default.yml index 60eb437af..8ba3b7175 100644 --- a/config-default.yml +++ b/config-default.yml @@ -196,10 +196,17 @@ guild: mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225 admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 - # Voice - code_help_voice: 755154969761677312 - code_help_voice_2: 766330079135268884 + # Voice Chat + code_help_chat: 755154969761677312 + code_help_chat_2: 766330079135268884 voice_chat: 412357430186344448 + admins_voice_chat: 000000000000000000 # FIXME + staff_voice_chat: 541638762007101470 + + # Voice Channels + code_help_voice: 751592231726481530 + code_help_voice_2: 764232549840846858 + general_voice: 751591688538947646 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From 94bd6133be0eb284f20af5ae1da4f477cab59975 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Tue, 24 Nov 2020 15:18:38 -0500 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cf343e5f0..1707c0244 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ -# Request Joe for any PR -* @jb3 +# Request Joe and Dennis for any PR +* @jb3 @Den4200 -- cgit v1.2.3 From 851f5a5ccc65a3f4d51cbfbc59472e8a3b6cdd8e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 24 Nov 2020 13:35:33 -0800 Subject: Specify code ownership for Mark --- .github/CODEOWNERS | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1707c0244..5cdbc76bd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,21 @@ # Request Joe and Dennis for any PR * @jb3 @Den4200 + +# Extensions +**/bot/exts/backend/sync/** @MarkKoz +**/bot/exts/filters/*token_remover.py @MarkKoz +**/bot/exts/moderation/*silence.py @MarkKoz +bot/exts/info/codeblock/** @MarkKoz +bot/exts/utils/extensions.py @MarkKoz +bot/exts/utils/snekbox.py @MarkKoz +bot/exts/help_channels.py @MarkKoz + +# Utils +bot/utils/extensions.py @MarkKoz +bot/utils/function.py @MarkKoz +bot/utils/lock.py @MarkKoz +bot/utils/scheduling.py @MarkKoz + +# Tests +tests/_autospec.py @MarkKoz +tests/bot/exts/test_cogs.py @MarkKoz -- cgit v1.2.3 From 0a49cb61085c2ceb8974decc69b200905bcf14e7 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 24 Nov 2020 13:46:09 -0800 Subject: Add Mark as a code owner of CI and Docker files --- .github/CODEOWNERS | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5cdbc76bd..5f5386222 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,3 +19,8 @@ bot/utils/scheduling.py @MarkKoz # Tests tests/_autospec.py @MarkKoz tests/bot/exts/test_cogs.py @MarkKoz + +# CI & Docker +.github/workflows/** @MarkKoz +Dockerfile @MarkKoz +docker-compose.yml @MarkKoz -- cgit v1.2.3 From 089efa35345af32c8f5475bb49bd09b9cd3f06c3 Mon Sep 17 00:00:00 2001 From: Steele Date: Wed, 25 Nov 2020 16:09:40 -0500 Subject: `!close` removes role when they have no help channels left; needs to be fixed so role is removed when the channel times out --- bot/exts/help_channels.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index e50fab7fc..e1d28ece3 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -223,7 +223,10 @@ class HelpChannels(commands.Cog): guild = self.bot.get_guild(constants.Guild.id) claimant = guild.get_member(await self.help_channel_claimants.get(ctx.channel.id)) await self.move_to_dormant(ctx.channel, "command") - await self.remove_cooldown_role(claimant) + + # Remove the cooldown role if they have no other channels left + if claimant.id not in {user_id for _, user_id in await self.help_channel_claimants.items()}: + await self.remove_cooldown_role(claimant) # Ignore missing task when cooldown has passed but the channel still isn't dormant. if ctx.author.id in self.scheduler: @@ -413,6 +416,8 @@ class HelpChannels(commands.Cog): for channel in self.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) + log.trace(f'Initial state of help_channel_claimants: {await self.help_channel_claimants.items()}') + # Prevent the command from being used until ready. # The ready event wasn't used because channels could change categories between the time # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). -- cgit v1.2.3 From 731fea162705583e1ee6edeb5da270b628a018d5 Mon Sep 17 00:00:00 2001 From: Steele Date: Wed, 25 Nov 2020 16:44:37 -0500 Subject: Moved the removal of the cooldown role from `close_command` to `move_to_dormant` --- bot/exts/help_channels.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index e1d28ece3..4fd4896df 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -220,14 +220,8 @@ class HelpChannels(commands.Cog): if not await self.dormant_check(ctx): return - guild = self.bot.get_guild(constants.Guild.id) - claimant = guild.get_member(await self.help_channel_claimants.get(ctx.channel.id)) await self.move_to_dormant(ctx.channel, "command") - # Remove the cooldown role if they have no other channels left - if claimant.id not in {user_id for _, user_id in await self.help_channel_claimants.items()}: - await self.remove_cooldown_role(claimant) - # Ignore missing task when cooldown has passed but the channel still isn't dormant. if ctx.author.id in self.scheduler: self.scheduler.cancel(ctx.author.id) @@ -552,18 +546,25 @@ class HelpChannels(commands.Cog): async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: """ - Make the `channel` dormant. + Make the `channel` dormant and remove the help cooldown role if it was the claimant's only channel. A caller argument is provided for metrics. """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") + guild = self.bot.get_guild(constants.Guild.id) + claimant = guild.get_member(await self.help_channel_claimants.get(channel.id)) + await self.help_channel_claimants.delete(channel.id) await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_dormant, ) + # Remove the cooldown role if the claimant has no other channels left + if claimant.id not in {user_id for _, user_id in await self.help_channel_claimants.items()}: + await self.remove_cooldown_role(claimant) + self.bot.stats.incr(f"help.dormant_calls.{caller}") in_use_time = await self.get_in_use_time(channel.id) -- cgit v1.2.3 From 8c2e7ed9026219ecc6fdd528c1bfe55b5dc7700f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 26 Nov 2020 17:16:55 +0100 Subject: Add voice_ban to supported types of the scheduler The `voice_ban` infraction was not listed as a supported type for the infraction scheduler. This meant that the scheduler did not schedule the expiry of `voice_ban` infractions after a restart. Those unlucky users were voice-banned perpetually. --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 746d4e154..6056df1d2 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -27,7 +27,7 @@ class Infractions(InfractionScheduler, commands.Cog): category_description = "Server moderation tools." def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) + super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_ban"}) self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) -- cgit v1.2.3 From 3f490ab413b64474bc7e40a2d66d3c3178d615ba Mon Sep 17 00:00:00 2001 From: Steele Date: Thu, 26 Nov 2020 13:34:03 -0500 Subject: Changes requested by @MarkKoz, new `unclaim_channel` method Deleted expensive logging operation; moved cooldown role removal functionality to new `unclaim_channel` method; handle possibility that claimant has left the guild; optimized redis cache iteration with `any` --- bot/exts/help_channels.py | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index 5676728e9..25ca67d47 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -221,16 +221,9 @@ class HelpChannels(commands.Cog): log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") return - if not await self.dormant_check(ctx): - return - - await self.move_to_dormant(ctx.channel, "command") - - # Ignore missing task when cooldown has passed but the channel still isn't dormant. - if ctx.author.id in self.scheduler: - self.scheduler.cancel(ctx.author.id) - - self.scheduler.cancel(ctx.channel.id) + if await self.dormant_check(ctx): + await self.move_to_dormant(ctx.channel, "command") + self.scheduler.cancel(ctx.channel.id) async def get_available_candidate(self) -> discord.TextChannel: """ @@ -414,8 +407,6 @@ class HelpChannels(commands.Cog): for channel in self.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) - log.trace(f'Initial state of help_channel_claimants: {await self.help_channel_claimants.items()}') - # Prevent the command from being used until ready. # The ready event wasn't used because channels could change categories between the time # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). @@ -550,24 +541,18 @@ class HelpChannels(commands.Cog): async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: """ - Make the `channel` dormant and remove the help cooldown role if it was the claimant's only channel. + Make the `channel` dormant. A caller argument is provided for metrics. """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - guild = self.bot.get_guild(constants.Guild.id) - claimant = guild.get_member(await self.help_channel_claimants.get(channel.id)) - - await self.help_channel_claimants.delete(channel.id) await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_dormant, ) - # Remove the cooldown role if the claimant has no other channels left - if claimant.id not in {user_id for _, user_id in await self.help_channel_claimants.items()}: - await self.remove_cooldown_role(claimant) + await self.unclaim_channel(channel) self.bot.stats.incr(f"help.dormant_calls.{caller}") @@ -592,6 +577,27 @@ class HelpChannels(commands.Cog): self.channel_queue.put_nowait(channel) self.report_stats() + async def unclaim_channel(self, channel: discord.TextChannel) -> None: + """ + Deletes `channel` from the mapping of channels to claimants and removes the help cooldown + role from the claimant if it was their only channel + """ + claimant_id = await self.help_channel_claimants.pop(channel.id) + + # Ignore missing task when cooldown has passed but the channel still isn't dormant. + if claimant_id in self.scheduler: + self.scheduler.cancel(claimant_id) + + claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) + + if claimant is None: + # `claimant` has left the guild, so the cooldown role need not be removed + return + + # Remove the cooldown role if the claimant has no other channels left + if not any(claimant.id == user_id for _, user_id in await self.help_channel_claimants.items()): + await self.remove_cooldown_role(claimant) + async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -- cgit v1.2.3 From 0242b4ed4145edf3e3e6ea6ebcef62aaff77d7ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 13:57:55 -0800 Subject: Help channels: remove how_to_get_help from excluded channels The channel as moved out of this category. Delete the constant too since it isn't used anywhere else. Keep the excluded channels a tuple to conveniently support excluding multiple channels in the future. --- bot/constants.py | 1 - bot/exts/help_channels/_channel.py | 2 +- config-default.yml | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6bb6aacd2..fb280b042 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -406,7 +406,6 @@ class Channels(metaclass=YAMLGetter): dm_log: int esoteric: int helpers: int - how_to_get_help: int incidents: int incidents_archive: int mailing_lists: int diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index d6d6f1245..e717d7af8 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -10,7 +10,7 @@ from bot.exts.help_channels import _caches, _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) +EXCLUDED_CHANNELS = (constants.Channels.cooldown,) def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: diff --git a/config-default.yml b/config-default.yml index 60eb437af..82023aae1 100644 --- a/config-default.yml +++ b/config-default.yml @@ -155,7 +155,6 @@ guild: python_discussion: &PY_DISCUSSION 267624335836053506 # Python Help: Available - how_to_get_help: 704250143020417084 cooldown: 720603994149486673 # Logs -- cgit v1.2.3 From d9f87cc867a93d5f2d14d16eaac86ccb5adef447 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:07:29 -0800 Subject: Help channels: don't check if task is done before awaiting Awaiting a done task is effectively a no-op, so it's redundant to check if the task is done before awaiting it. Furthermore, a task is also considered done if it was cancelled or an exception was raised. Therefore, avoiding awaiting in such cases doesn't allow the errors to be propagated and incorrectly allows the awaiter to keep executing. --- bot/exts/help_channels/_cog.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 638d00e4a..e22d4663e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -426,9 +426,8 @@ class HelpChannels(commands.Cog): if not is_available or _channel.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. - if not self.init_task.done(): - log.trace("Waiting for the cog to be ready before processing messages.") - await self.init_task + log.trace("Waiting for the cog to be ready before processing messages.") + await self.init_task log.trace("Acquiring lock to prevent a channel from being processed twice...") async with self.on_message_lock: @@ -478,9 +477,8 @@ class HelpChannels(commands.Cog): if not await _message.is_empty(msg.channel): return - if not self.init_task.done(): - log.trace("Waiting for the cog to be ready before processing deleted messages.") - await self.init_task + log.trace("Waiting for the cog to be ready before processing deleted messages.") + await self.init_task log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") -- cgit v1.2.3 From e5b073c5ee9c7c887a193ec05d813d259eca58ee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:11:40 -0800 Subject: Help channels: document the return value of notify() --- bot/exts/help_channels/_message.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 8b058d5aa..2bbd4bdd6 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -96,6 +96,9 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[dat """ Send a message in `channel` notifying about a lack of available help channels. + If a notification was sent, return the `datetime` at which the message was sent. Otherwise, + return None. + Configuration: * `HelpChannels.notify` - toggle notifications -- cgit v1.2.3 From bff4cd34bbacc7229a3fa2cf5421276c433c8b65 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:12:57 -0800 Subject: Update help channels directory for code owners --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5f5386222..843f86b71 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,7 @@ bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz bot/exts/utils/snekbox.py @MarkKoz -bot/exts/help_channels.py @MarkKoz +bot/exts/help_channels/** @MarkKoz # Utils bot/utils/extensions.py @MarkKoz -- cgit v1.2.3 From a8891d43484d602d49800ad5a8a39505758a86ba Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 26 Nov 2020 22:16:31 +0000 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5f5386222..0d7572e38 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,5 @@ -# Request Joe and Dennis for any PR -* @jb3 @Den4200 +# Request Dennis for any PR +* @Den4200 # Extensions **/bot/exts/backend/sync/** @MarkKoz @@ -24,3 +24,7 @@ tests/bot/exts/test_cogs.py @MarkKoz .github/workflows/** @MarkKoz Dockerfile @MarkKoz docker-compose.yml @MarkKoz + +# Statistics +bot/async_stats.py @jb3 +bot/exts/info/stats.py @jb3 -- cgit v1.2.3 From 63abf5cd0dfe831caaf0a854387fca11d0e13ae2 Mon Sep 17 00:00:00 2001 From: Mushinako <8977737+Mushinako@users.noreply.github.com> Date: Thu, 26 Nov 2020 18:50:20 -0800 Subject: Add `build-tools` tag Infoblock tag helping people install Microsoft Visual C++ Build Tools on Windows --- bot/resources/tags/build-tools.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/resources/tags/build-tools.md diff --git a/bot/resources/tags/build-tools.md b/bot/resources/tags/build-tools.md new file mode 100644 index 000000000..23ce15e8a --- /dev/null +++ b/bot/resources/tags/build-tools.md @@ -0,0 +1,15 @@ +**Microsoft Visual C++ Build Tools** + +When you install a library through `pip` on Windows, sometimes you may encounter this error: + +``` +error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/ +``` + +This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) + +1. Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +2. Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. +3. Run the downloaded file. If a **`User Account Control`** dialog pops up, click **`Yes`**. Click **`Continue`** to proceed. +4. Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. +5. Try installing the library via `pip` again. -- cgit v1.2.3 From c5b7f7b500aeefbb65ff943f8e3a918c9ed75193 Mon Sep 17 00:00:00 2001 From: Mushinako <8977737+Mushinako@users.noreply.github.com> Date: Thu, 26 Nov 2020 23:06:51 -0800 Subject: Bold numbering --- bot/resources/tags/build-tools.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/resources/tags/build-tools.md b/bot/resources/tags/build-tools.md index 23ce15e8a..db88098e0 100644 --- a/bot/resources/tags/build-tools.md +++ b/bot/resources/tags/build-tools.md @@ -8,8 +8,8 @@ error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) -1. Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). -2. Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. -3. Run the downloaded file. If a **`User Account Control`** dialog pops up, click **`Yes`**. Click **`Continue`** to proceed. -4. Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. -5. Try installing the library via `pip` again. +**1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +**2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. +**3.** Run the downloaded file. If a **`User Account Control`** dialog pops up, click **`Yes`**. Click **`Continue`** to proceed. +**4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. +**5.** Try installing the library via `pip` again. -- cgit v1.2.3 From c5d885da1e5094c9ee55f2d37dcc01db4ce08e9c Mon Sep 17 00:00:00 2001 From: Mushinako <8977737+Mushinako@users.noreply.github.com> Date: Thu, 26 Nov 2020 23:08:48 -0800 Subject: Remove UAC prompt info Assuming Windows users are familiar with UAC --- bot/resources/tags/build-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/build-tools.md b/bot/resources/tags/build-tools.md index db88098e0..7c702e296 100644 --- a/bot/resources/tags/build-tools.md +++ b/bot/resources/tags/build-tools.md @@ -10,6 +10,6 @@ This means the library you're installing has code written in other languages and **1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). **2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. -**3.** Run the downloaded file. If a **`User Account Control`** dialog pops up, click **`Yes`**. Click **`Continue`** to proceed. +**3.** Run the downloaded file. Click **`Continue`** to proceed. **4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. **5.** Try installing the library via `pip` again. -- cgit v1.2.3 From 1e1d57f6294eb7c3a8d9a0c76c77eb10c43b3ebe Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Fri, 27 Nov 2020 16:00:19 +0100 Subject: fix(bot): PR reivew of bot.py --- bot/bot.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index b097513f1..bcce4a118 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -53,20 +53,22 @@ class Bot(commands.Bot): def _connect_statsd(self, statsd_url: str, retry_after: int = 2, attempt: int = 1) -> None: """Callback used to retry a connection to statsd if it should fail.""" - if self._statsd_timerhandle and not self._statsd_timerhandle.cancelled: - self._statsd_timerhandle.cancel() - - if attempt >= 5: - log.error("Reached 5 attempts trying to reconnect AsyncStatsClient. Aborting") + if attempt >= 8: + log.error("Reached 8 attempts trying to reconnect AsyncStatsClient. Aborting") return try: self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") except socket.gaierror: log.warning(f"Statsd client failed to connect (Attempt(s): {attempt})") - # Use a fallback strategy for retrying, up to 5 times. + # Use a fallback strategy for retrying, up to 8 times. self._statsd_timerhandle = self.loop.call_later( - retry_after, self._connect_statsd, statsd_url, retry_after * 5, attempt + 1) + retry_after, + self._connect_statsd, + statsd_url, + retry_after * 2, + attempt + 1 + ) async def cache_filter_list_data(self) -> None: """Cache all the data in the FilterList on the site.""" @@ -172,7 +174,7 @@ class Bot(commands.Bot): if self.redis_session: await self.redis_session.close() - if self._statsd_timerhandle and not self._statsd_timerhandle.cancelled: + if self._statsd_timerhandle: self._statsd_timerhandle.cancel() def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None: -- cgit v1.2.3 From a7dd1c56dad3e2aa3c1304b6f9cc5bd63150ad91 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 27 Nov 2020 18:18:22 +0100 Subject: Add @Akarys42 to the codeowners --- .github/CODEOWNERS | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 86df4db8d..272fb2ffe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,24 +7,31 @@ **/bot/exts/moderation/*silence.py @MarkKoz bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz -bot/exts/utils/snekbox.py @MarkKoz -bot/exts/help_channels/** @MarkKoz +bot/exts/utils/snekbox.py @MarkKoz @Akarys42 +bot/exts/help_channels/** @MarkKoz @Akarys42 +bot/exts/moderation/** @Akarys42 +bot/exts/info/** @Akarys42 # Utils bot/utils/extensions.py @MarkKoz bot/utils/function.py @MarkKoz bot/utils/lock.py @MarkKoz +bot/utils/regex.py @Akarys42 bot/utils/scheduling.py @MarkKoz # Tests tests/_autospec.py @MarkKoz tests/bot/exts/test_cogs.py @MarkKoz +tests/** @Akarys42 # CI & Docker -.github/workflows/** @MarkKoz -Dockerfile @MarkKoz -docker-compose.yml @MarkKoz +.github/workflows/** @MarkKoz @Akarys42 +Dockerfile @MarkKoz @Akarys42 +docker-compose.yml @MarkKoz @Akarys42 + +# Tools +Pipfile* @Akarys42 # Statistics -bot/async_stats.py @jb3 -bot/exts/info/stats.py @jb3 +bot/async_stats.py @jb3 +bot/exts/info/stats.py @jb3 -- cgit v1.2.3 From 1cde79faa2675638ede0435fbf4cd2911b8a76ba Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 27 Nov 2020 23:39:13 +0100 Subject: Add myself to CODEOWNERS for CI files --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 272fb2ffe..495a6e9e2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,7 +25,7 @@ tests/bot/exts/test_cogs.py @MarkKoz tests/** @Akarys42 # CI & Docker -.github/workflows/** @MarkKoz @Akarys42 +.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ Dockerfile @MarkKoz @Akarys42 docker-compose.yml @MarkKoz @Akarys42 -- cgit v1.2.3 From cb59440d7c7bf5780545a1a31beadaaadd5700f9 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Nov 2020 01:37:27 +0200 Subject: Remove unnecessary shadow infractions --- bot/exts/moderation/infraction/infractions.py | 30 --------------------------- 1 file changed, 30 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 6056df1d2..8c3451c7a 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -180,11 +180,6 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) - @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True) - @command(hidden=True, aliases=['shadowban', 'sban']) async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" @@ -193,31 +188,6 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Temporary shadow infractions - @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute( - self, ctx: Context, - user: Member, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily mute a user for the given reason and duration without notifying the user. - - A unit of time should be appended to the duration. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) - @command(hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( self, -- cgit v1.2.3 From f1bb099b90acc870995b5e1a1f02aa20c44e9b6d Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Nov 2020 15:38:08 +0200 Subject: Add default mute duration --- bot/exts/moderation/infraction/infractions.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 8c3451c7a..18e937e87 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Expiry, FetchedMember +from bot.converters import Duration, Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -98,7 +98,13 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary infractions @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: + async def tempmute( + self, ctx: Context, + user: Member, + duration: t.Optional[Expiry] = None, + *, + reason: t.Optional[str] = None + ) -> None: """ Temporarily mute a user for the given reason and duration. @@ -113,7 +119,11 @@ class Infractions(InfractionScheduler, commands.Cog): \u2003`s` - seconds Alternatively, an ISO 8601 timestamp can be provided for the duration. + + If no duration is given, a one hour duration is used by default. """ + if duration is None: + duration = await Duration().convert(ctx, "1h") await self.apply_mute(ctx, user, reason, expires_at=duration) @command() -- cgit v1.2.3 From 7b3d8ed6e655b787c2538541ca35301f2fadb508 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Nov 2020 15:47:50 +0200 Subject: Added infraction edit aliases --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 394f63da3..4cd7d15bf 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -40,12 +40,12 @@ class ModManagement(commands.Cog): # region: Edit infraction commands - @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) + @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True) async def infraction_group(self, ctx: Context) -> None: """Infraction manipulation commands.""" await ctx.send_help(ctx.command) - @infraction_group.command(name='edit') + @infraction_group.command(name='edit', aliases=('e',)) async def infraction_edit( self, ctx: Context, -- cgit v1.2.3 From 67fbb71f7954f4edc2f43b8f1069e3c366dcca4d Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sat, 28 Nov 2020 16:58:58 +0200 Subject: Add myself to CODEOWNERS --- .github/CODEOWNERS | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 495a6e9e2..642676078 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,8 +9,9 @@ bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz bot/exts/utils/snekbox.py @MarkKoz @Akarys42 bot/exts/help_channels/** @MarkKoz @Akarys42 -bot/exts/moderation/** @Akarys42 -bot/exts/info/** @Akarys42 +bot/exts/moderation/** @Akarys42 @mbaruh +bot/exts/info/** @Akarys42 @mbaruh +bot/exts/filters/** @mbaruh # Utils bot/utils/extensions.py @MarkKoz -- cgit v1.2.3 From fde6dd9a37cf5f5a98eed7ffcb05f43dca6886a3 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 28 Nov 2020 21:18:13 +0300 Subject: Removes Non-Existent Channel Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/constants.py | 1 - config-default.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index ecbf5f98e..5b3779eb6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -436,7 +436,6 @@ class Channels(metaclass=YAMLGetter): code_help_chat: int code_help_chat_2: int voice_chat: int - admins_voice_chat: int staff_voice_chat: int voice_gate: int voice_log: int diff --git a/config-default.yml b/config-default.yml index 8ba3b7175..563244819 100644 --- a/config-default.yml +++ b/config-default.yml @@ -200,7 +200,6 @@ guild: code_help_chat: 755154969761677312 code_help_chat_2: 766330079135268884 voice_chat: 412357430186344448 - admins_voice_chat: 000000000000000000 # FIXME staff_voice_chat: 541638762007101470 # Voice Channels -- cgit v1.2.3 From cd8b4b91fbfe69b9370fec1a89dc82688f7d317b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 28 Nov 2020 21:19:34 +0300 Subject: Renames Code Help Channel Renames code_help_channel to be more inline with channel 2. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/constants.py | 4 ++-- config-default.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 5b3779eb6..42b3d7008 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -396,7 +396,7 @@ class Channels(metaclass=YAMLGetter): big_brother_logs: int bot_commands: int change_log: int - code_help_voice: int + code_help_voice_1: int code_help_voice_2: int general_voice: int admins_voice: int @@ -433,7 +433,7 @@ class Channels(metaclass=YAMLGetter): user_event_announcements: int user_log: int verification: int - code_help_chat: int + code_help_chat_1: int code_help_chat_2: int voice_chat: int staff_voice_chat: int diff --git a/config-default.yml b/config-default.yml index 563244819..f18d08126 100644 --- a/config-default.yml +++ b/config-default.yml @@ -197,13 +197,13 @@ guild: admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 # Voice Chat - code_help_chat: 755154969761677312 + code_help_chat_1: 755154969761677312 code_help_chat_2: 766330079135268884 voice_chat: 412357430186344448 staff_voice_chat: 541638762007101470 # Voice Channels - code_help_voice: 751592231726481530 + code_help_voice_1: 751592231726481530 code_help_voice_2: 764232549840846858 general_voice: 751591688538947646 admins_voice: &ADMINS_VOICE 500734494840717332 -- cgit v1.2.3 From d190056ac6fa39ac54b4eafc566c2f05fc0b6f8e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 28 Nov 2020 21:48:51 +0300 Subject: Fixes Alignment Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index f18d08126..df3d971ff 100644 --- a/config-default.yml +++ b/config-default.yml @@ -197,13 +197,13 @@ guild: admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 # Voice Chat - code_help_chat_1: 755154969761677312 + code_help_chat_1: 755154969761677312 code_help_chat_2: 766330079135268884 voice_chat: 412357430186344448 staff_voice_chat: 541638762007101470 # Voice Channels - code_help_voice_1: 751592231726481530 + code_help_voice_1: 751592231726481530 code_help_voice_2: 764232549840846858 general_voice: 751591688538947646 admins_voice: &ADMINS_VOICE 500734494840717332 -- cgit v1.2.3 From 54540bf9dcf88f7d3e2e0f389a2127456d6c877f Mon Sep 17 00:00:00 2001 From: Mushinako <8977737+Mushinako@users.noreply.github.com> Date: Sat, 28 Nov 2020 11:38:32 -0800 Subject: Rename `build-tools` to `microsoft-build-tools` --- bot/resources/tags/build-tools.md | 15 --------------- bot/resources/tags/microsoft-build-tools.md | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 15 deletions(-) delete mode 100644 bot/resources/tags/build-tools.md create mode 100644 bot/resources/tags/microsoft-build-tools.md diff --git a/bot/resources/tags/build-tools.md b/bot/resources/tags/build-tools.md deleted file mode 100644 index 7c702e296..000000000 --- a/bot/resources/tags/build-tools.md +++ /dev/null @@ -1,15 +0,0 @@ -**Microsoft Visual C++ Build Tools** - -When you install a library through `pip` on Windows, sometimes you may encounter this error: - -``` -error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/ -``` - -This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) - -**1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). -**2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. -**3.** Run the downloaded file. Click **`Continue`** to proceed. -**4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. -**5.** Try installing the library via `pip` again. diff --git a/bot/resources/tags/microsoft-build-tools.md b/bot/resources/tags/microsoft-build-tools.md new file mode 100644 index 000000000..7c702e296 --- /dev/null +++ b/bot/resources/tags/microsoft-build-tools.md @@ -0,0 +1,15 @@ +**Microsoft Visual C++ Build Tools** + +When you install a library through `pip` on Windows, sometimes you may encounter this error: + +``` +error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/ +``` + +This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) + +**1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +**2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. +**3.** Run the downloaded file. Click **`Continue`** to proceed. +**4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. +**5.** Try installing the library via `pip` again. -- cgit v1.2.3 From 114e3058ca18cf45a55d7dcdf7d509de44d7ad1a Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 28 Nov 2020 23:52:30 +0300 Subject: Alphabetizes Channel Ordering Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/constants.py | 12 ++++++------ config-default.yml | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 42b3d7008..a33939537 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -391,16 +391,16 @@ class Channels(metaclass=YAMLGetter): admin_announcements: int admin_spam: int admins: int + admins_voice: int announcements: int attachment_log: int big_brother_logs: int bot_commands: int change_log: int + code_help_chat_1: int + code_help_chat_2: int code_help_voice_1: int code_help_voice_2: int - general_voice: int - admins_voice: int - staff_voice: int cooldown: int defcon: int dev_contrib: int @@ -408,6 +408,7 @@ class Channels(metaclass=YAMLGetter): dev_log: int dm_log: int esoteric: int + general_voice: int helpers: int how_to_get_help: int incidents: int @@ -429,14 +430,13 @@ class Channels(metaclass=YAMLGetter): python_news: int reddit: int staff_announcements: int + staff_voice: int + staff_voice_chat: int talent_pool: int user_event_announcements: int user_log: int verification: int - code_help_chat_1: int - code_help_chat_2: int voice_chat: int - staff_voice_chat: int voice_gate: int voice_log: int diff --git a/config-default.yml b/config-default.yml index df3d971ff..9dc1ac18e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -196,19 +196,19 @@ guild: mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225 admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 - # Voice Chat - code_help_chat_1: 755154969761677312 - code_help_chat_2: 766330079135268884 - voice_chat: 412357430186344448 - staff_voice_chat: 541638762007101470 - # Voice Channels + admins_voice: &ADMINS_VOICE 500734494840717332 code_help_voice_1: 751592231726481530 code_help_voice_2: 764232549840846858 general_voice: 751591688538947646 - admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 + # Voice Chat + code_help_chat_1: 755154969761677312 + code_help_chat_2: 766330079135268884 + staff_voice_chat: 541638762007101470 + voice_chat: 412357430186344448 + # Watch big_brother_logs: &BB_LOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 -- cgit v1.2.3 From 65366ead766fa6533fe6c59310a39ca3ee3a06b2 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Mon, 30 Nov 2020 00:47:34 +0800 Subject: Fix unawaited coroutine in Infraction --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index f350e863e..d0a9731d6 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -575,7 +575,7 @@ class Infraction(Converter): return infractions[0] else: - return ctx.bot.api_client.get(f"bot/infractions/{arg}") + return await ctx.bot.api_client.get(f"bot/infractions/{arg}") Expiry = t.Union[Duration, ISODateTime] -- cgit v1.2.3 From db2136cd5bd1b86b05f249177e7a68a731404d16 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Mon, 30 Nov 2020 00:49:47 +0800 Subject: Refactor flow for automatically adding punctuation --- bot/exts/moderation/infraction/management.py | 11 +++++------ bot/utils/regex.py | 2 -- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index bb7a6737a..10bca5fc5 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -16,7 +16,6 @@ from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel -from bot.utils.regex import END_PUNCTUATION_RE log = logging.getLogger(__name__) @@ -77,13 +76,13 @@ class ModManagement(commands.Cog): If a previous infraction reason does not end with an ending punctuation mark, this automatically adds a period before the amended reason. """ - add_period = not END_PUNCTUATION_RE.match(infraction["reason"]) + old_reason = infraction["reason"] - new_reason = "".join(( - infraction["reason"], ". " if add_period else " ", reason, - )) + if old_reason is not None: + add_period = not old_reason.endswith((".", "!", "?")) + reason = old_reason + (". " if add_period else " ") + reason - await self.infraction_edit(ctx, infraction, duration, reason=new_reason) + await self.infraction_edit(ctx, infraction, duration, reason=reason) @infraction_group.command(name='edit') async def infraction_edit( diff --git a/bot/utils/regex.py b/bot/utils/regex.py index cfce52bb3..0d2068f90 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -10,5 +10,3 @@ INVITE_RE = re.compile( r"([a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE ) - -END_PUNCTUATION_RE = re.compile("^.+?[.?!]$") -- cgit v1.2.3 From 6ac6786c480ec9919009acca4906a52234f42285 Mon Sep 17 00:00:00 2001 From: Steele Date: Sun, 29 Nov 2020 13:04:38 -0500 Subject: `!close` removes role from claimant only, new method `unclaim_channel`. Previously `!close` would remove the cooldown role from the person who issued the command, whereas now `unclaim_channel` handles removing the role from the claimant if it was their only channel open. --- bot/exts/help_channels/_cog.py | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e22d4663e..86eb91b02 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -145,22 +145,17 @@ class HelpChannels(commands.Cog): Make the current in-use help channel dormant. Make the channel dormant if the user passes the `dormant_check`, - delete the message that invoked this, - and reset the send permissions cooldown for the user who started the session. + delete the message that invoked this. """ log.trace("close command invoked; checking if the channel is in-use.") - if ctx.channel.category == self.in_use_category: - if await self.dormant_check(ctx): - await _cooldown.remove_cooldown_role(ctx.author) - # Ignore missing task when cooldown has passed but the channel still isn't dormant. - if ctx.author.id in self.scheduler: - self.scheduler.cancel(ctx.author.id) - - await self.move_to_dormant(ctx.channel, "command") - self.scheduler.cancel(ctx.channel.id) - else: + if ctx.channel.category != self.in_use_category: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") + return + + if await self.dormant_check(ctx): + await self.move_to_dormant(ctx.channel, "command") + self.scheduler.cancel(ctx.channel.id) async def get_available_candidate(self) -> discord.TextChannel: """ @@ -368,12 +363,13 @@ class HelpChannels(commands.Cog): """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await _caches.claimants.delete(channel.id) await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_dormant, ) + await self.unclaim_channel(channel) + self.bot.stats.incr(f"help.dormant_calls.{caller}") in_use_time = await _channel.get_in_use_time(channel.id) @@ -397,6 +393,26 @@ class HelpChannels(commands.Cog): self.channel_queue.put_nowait(channel) self.report_stats() + async def unclaim_channel(self, channel: discord.TextChannel) -> None: + """ + Remove the claimant from the claimant cache and remove the cooldown role + if it was their last open help channel. + """ + claimant_id = await _caches.claimants.pop(channel.id) + + # Ignore missing task when cooldown has passed but the channel still isn't dormant. + if claimant_id in self.scheduler: + self.scheduler.cancel(claimant_id) + + claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) + if claimant is None: + log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") + return + + # Remove the cooldown role if the claimant has no other channels left + if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): + await _cooldown.remove_cooldown_role(claimant) + async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -- cgit v1.2.3 From 15593de0a1a503a39aa29031061bc17ac26e4230 Mon Sep 17 00:00:00 2001 From: Steele Date: Sun, 29 Nov 2020 14:15:19 -0500 Subject: Corrected `unclaim_channel` docstring to comply with style guide --- bot/exts/help_channels/_cog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 86eb91b02..983c5d183 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -395,8 +395,10 @@ class HelpChannels(commands.Cog): async def unclaim_channel(self, channel: discord.TextChannel) -> None: """ - Remove the claimant from the claimant cache and remove the cooldown role - if it was their last open help channel. + Mark the channel as unclaimed and remove the cooldown role from the claimant if needed. + + The role is only removed if they have no claimed channels left once the current one is unclaimed. + This method also handles canceling the automatic removal of the cooldown role. """ claimant_id = await _caches.claimants.pop(channel.id) -- cgit v1.2.3 From ae976d56bd3ce89c1da69f42f59680f7b4763771 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Mon, 30 Nov 2020 17:52:22 +0800 Subject: Remove unused get_latest_infraction helper method --- bot/exts/moderation/infraction/management.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 10bca5fc5..3b0719ed2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -310,20 +310,6 @@ class ModManagement(commands.Cog): return lines.strip() - async def get_latest_infraction(self, actor: int) -> t.Optional[dict]: - """Obtains the latest infraction from an actor.""" - params = { - "actor__id": actor, - "ordering": "-inserted_at" - } - - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - return infractions[0] - - return None - # endregion # This cannot be static (must have a __func__ attribute). -- cgit v1.2.3 From df897e57ea0dbbaeb3e2a07a1e443c5b2df34c36 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 30 Nov 2020 22:26:23 +0300 Subject: Adds Member Checks Before Changing Voice Adds a check that checks if the user object is an instance of guild member, before performing guild operations. Adds tests. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/infraction/infractions.py | 12 ++++++++++-- tests/bot/exts/moderation/infraction/test_infractions.py | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 18e937e87..78c326c47 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -257,6 +257,10 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) async def action() -> None: + # Skip members that left the server + if not isinstance(user, Member): + return + await user.add_roles(self._muted_role, reason=reason) log.trace(f"Attempting to kick {user} from voice because they've been muted.") @@ -351,9 +355,13 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + action = None + + # Skip members that left the server + if isinstance(user, Member): + await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + action = user.remove_roles(self._voice_verified_role, reason=reason) - action = user.remove_roles(self._voice_verified_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) # endregion diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index bf557a484..4ba5a4feb 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch from bot.constants import Event from bot.exts.moderation.infraction.infractions import Infractions -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser class TruncationTests(unittest.IsolatedAsyncioTestCase): @@ -164,6 +164,19 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): ) self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + @patch("bot.exts.moderation.infraction._utils.get_active_infraction", return_value=None) + @patch("bot.exts.moderation.infraction._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions.Infractions.apply_infraction") + async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): + """Should voice ban user that left the guild without throwing an error.""" + infraction = {"foo": "bar"} + post_infraction_mock.return_value = {"foo": "bar"} + + user = MockUser() + await self.cog.voiceban(self.cog, self.ctx, user, reason=None) + post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True) + apply_infraction_mock.assert_called_once_with(self.ctx, infraction, user, None) + async def test_voice_unban_user_not_found(self): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None -- cgit v1.2.3 From 85a115ae4c029397c8cefee6262e866d264fd681 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 16:36:30 +0200 Subject: Move not allowed to run message to constant and match comment with code --- bot/exts/info/help.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 518330370..6c262e355 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -20,6 +20,8 @@ log = logging.getLogger(__name__) COMMANDS_PER_PAGE = 8 PREFIX = constants.Bot.prefix +NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n" + Category = namedtuple("Category", ["name", "description", "cogs"]) @@ -174,15 +176,15 @@ class CustomHelpCommand(HelpCommand): command_details += f"**Can also use:** {aliases}\n\n" # when command is disabled, show message about it, - # when other CommandError instance is raised, log warning about it - # otherwise check if the user is allowed to run this command + # when other CommandError or user is not allowed to run command, + # add this to help message. try: if not await command.can_run(self.context): - command_details += "***You cannot run this command.***\n\n" + command_details += NOT_ALLOWED_TO_RUN_MESSAGE except DisabledCommand: command_details += "***This command is disabled.***\n\n" except CommandError: - command_details += "***You cannot run this command.***\n\n" + command_details += NOT_ALLOWED_TO_RUN_MESSAGE command_details += f"*{command.help or 'No details provided.'}*\n" embed.description = command_details -- cgit v1.2.3 From 8a2865651556a598b5e96447c6ed4231829c46cf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 17:55:33 +0200 Subject: Merge NotFound caching with HttpException caching with status code --- bot/exts/moderation/infraction/_scheduler.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 6efa5b1e0..5726a5879 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -76,11 +76,9 @@ class InfractionScheduler: # Allowing mod log since this is a passive action that should be logged. try: await apply_coro - except discord.NotFound: - # When user joined and then right after this left again before action completed, this can't add roles - log.info(f"Can't reapply {infraction['type']} to user {infraction['user']} because user left again.") except discord.HTTPException as e: - if e.code == 10007: + # When user joined and then right after this left again before action completed, this can't apply roles + if e.code == 10007 or e.status == 404: log.info(f"Can't reapply {infraction['type']} to user {infraction['user']} because user left again.") else: log.warning( @@ -170,8 +168,6 @@ class InfractionScheduler: if expiry: # Schedule the expiration of the infraction. self.schedule_expiration(infraction) - except discord.NotFound: - log.info(f"Can't apply {infraction['type']} to user {infraction['user']} because user left from guild.") except discord.HTTPException as e: # Accordingly display that applying the infraction failed. # Don't use ctx.message.author; antispam only patches ctx.author. @@ -183,7 +179,7 @@ class InfractionScheduler: log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}" if isinstance(e, discord.Forbidden): log.warning(f"{log_msg}: bot lacks permissions.") - elif e.code == 10007: + elif e.code == 10007 or e.status == 404: log.info( f"Can't apply {infraction['type']} to user {infraction['user']} because user left from guild." ) @@ -358,10 +354,8 @@ class InfractionScheduler: log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.") log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)" log_content = mod_role.mention - except discord.NotFound: - log.info(f"Can't pardon {infraction['type']} for user {infraction['user']} because user left from guild.") except discord.HTTPException as e: - if e.code == 10007: + if e.code == 10007 or e.status == 404: log.info( f"Can't pardon {infraction['type']} for user {infraction['user']} because user left from guild." ) -- cgit v1.2.3 From 50db55dd25f065222213510188e62b0d951b95c8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 17:57:00 +0200 Subject: Fix user leaving from guild log grammar --- bot/exts/moderation/infraction/_scheduler.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 5726a5879..835f3a2e1 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -79,7 +79,9 @@ class InfractionScheduler: except discord.HTTPException as e: # When user joined and then right after this left again before action completed, this can't apply roles if e.code == 10007 or e.status == 404: - log.info(f"Can't reapply {infraction['type']} to user {infraction['user']} because user left again.") + log.info( + f"Can't reapply {infraction['type']} to user {infraction['user']} because user left the guild." + ) else: log.warning( ( @@ -357,7 +359,7 @@ class InfractionScheduler: except discord.HTTPException as e: if e.code == 10007 or e.status == 404: log.info( - f"Can't pardon {infraction['type']} for user {infraction['user']} because user left from guild." + f"Can't pardon {infraction['type']} for user {infraction['user']} because user left the guild." ) else: log.exception(f"Failed to deactivate infraction #{id_} ({type_})") -- cgit v1.2.3 From 7c2ceede521fd0599b0fa1e55b8485008d80e08e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 17:57:52 +0200 Subject: Log exception instead warning for unexpected HttpException --- bot/exts/moderation/infraction/_scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 835f3a2e1..22739d332 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -83,7 +83,7 @@ class InfractionScheduler: f"Can't reapply {infraction['type']} to user {infraction['user']} because user left the guild." ) else: - log.warning( + log.exception( ( f"Got unexpected HTTPException (HTTP {e.status}, Discord code {e.code})" f"when awaiting {infraction['type']} coroutine for {infraction['user']}." -- cgit v1.2.3 From fc5930775ee2ae33ba88264a08c10b83761a8781 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 17:58:25 +0200 Subject: Remove second unnecessary parenthesis --- bot/exts/moderation/infraction/_scheduler.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 22739d332..8a45692d5 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -84,10 +84,8 @@ class InfractionScheduler: ) else: log.exception( - ( - f"Got unexpected HTTPException (HTTP {e.status}, Discord code {e.code})" - f"when awaiting {infraction['type']} coroutine for {infraction['user']}." - ) + f"Got unexpected HTTPException (HTTP {e.status}, Discord code {e.code})" + f"when awaiting {infraction['type']} coroutine for {infraction['user']}." ) else: log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") -- cgit v1.2.3 From eb73d3030d6f1d1aaf16defee9992f6336321f64 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 18:00:34 +0200 Subject: Add failure message when applying infraction fails because user left --- bot/exts/moderation/infraction/_scheduler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 8a45692d5..ca4d18c98 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -359,6 +359,8 @@ class InfractionScheduler: log.info( f"Can't pardon {infraction['type']} for user {infraction['user']} because user left the guild." ) + log_text["Failure"] = f"User left the guild." + log_content = mod_role.mention else: log.exception(f"Failed to deactivate infraction #{id_} ({type_})") log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." -- cgit v1.2.3 From 690ccd246e12d18a8c804b0802772f4a66a96bb8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 18:43:22 +0200 Subject: Fix removing extensions and cogs for bot shutdown --- bot/bot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index cdb4e72a9..06b1bd6e0 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -175,9 +175,13 @@ class Bot(commands.Bot): async def close(self) -> None: """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" # Done before super().close() to allow tasks finish before the HTTP session closes. - with suppress(Exception): - [self.unload_extension(ext) for ext in tuple(self.extensions)] - [self.remove_cog(cog) for cog in tuple(self.cogs)] + for ext in list(self.extensions): + with suppress(Exception): + self.unload_extension(ext) + + for cog in list(self.cogs): + with suppress(Exception): + self.remove_cog(cog) # Wait until all tasks that have to be completed before bot is closing is done log.trace("Waiting for tasks before closing.") -- cgit v1.2.3 From 00ff5738ea29d51d2db4c633a112da0b1a71aedd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 18:49:02 +0200 Subject: Remove unnecessary f-string --- bot/exts/moderation/infraction/_scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index ca4d18c98..44c31cd13 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -359,7 +359,7 @@ class InfractionScheduler: log.info( f"Can't pardon {infraction['type']} for user {infraction['user']} because user left the guild." ) - log_text["Failure"] = f"User left the guild." + log_text["Failure"] = "User left the guild." log_content = mod_role.mention else: log.exception(f"Failed to deactivate infraction #{id_} ({type_})") -- cgit v1.2.3 From 9e4b78b40b407c2bb6d4666767d700b7993a54e5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 4 Dec 2020 14:46:50 +0200 Subject: Create command for showing Discord snowflake creation time --- bot/exts/utils/utils.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 6d8d98695..3f16bc10b 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -9,6 +9,7 @@ from typing import Dict, Optional, Tuple, Union from discord import Colour, Embed, utils from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role +from discord.utils import snowflake_time from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES @@ -16,6 +17,7 @@ from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils import messages from bot.utils.cache import AsyncCache +from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -166,6 +168,21 @@ class Utils(Cog): embed.description = best_match await ctx.send(embed=embed) + @command(aliases=("snf", "snfl")) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + async def snowflake(self, ctx: Context, snowflake: int) -> None: + """Get Discord snowflake creation time.""" + created_at = snowflake_time(snowflake) + embed = Embed( + description=f"**Created at {created_at}** ({time_since(created_at, max_units=3)}).", + colour=Colour.blue() + ) + embed.set_author( + name=f"Snowflake: {snowflake}", + icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true" + ) + await ctx.send(embed=embed) + @command(aliases=("poll",)) @has_any_role(*MODERATION_ROLES) async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: -- cgit v1.2.3 From 72f869a81d882acf2eb3f1714d4f52d01384b0ae Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 4 Dec 2020 14:46:58 +0100 Subject: Add the `s` alias to `infraction search` --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index c58410f8c..b3783cd60 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -197,7 +197,7 @@ class ModManagement(commands.Cog): # endregion # region: Search infractions - @infraction_group.group(name="search", invoke_without_command=True) + @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None: """Searches for infractions in the database.""" if isinstance(query, int): -- cgit v1.2.3 From f537768034a2c9791ca08a91c66b9f97aef8edca Mon Sep 17 00:00:00 2001 From: Steele Date: Sat, 5 Dec 2020 12:08:44 -0500 Subject: Bot relays the infraction reason in the DM. Previously, the infraction DM from the bot gave a formulaic message about the nickname policy. It now gives a slightly different message along with the reason given by the mod. This means that the message the user gets and the infraction reason that gets recorded are now the same. --- bot/exts/moderation/infraction/superstarify.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 96dfb562f..a4327fb95 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -111,7 +111,7 @@ class Superstarify(InfractionScheduler, Cog): member: Member, duration: Expiry, *, - reason: str = None, + reason: str = '', ) -> None: """ Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. @@ -128,15 +128,16 @@ class Superstarify(InfractionScheduler, Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. - An optional reason can be provided. If no reason is given, the original name will be shown - in a generated reason. + An optional reason can be provided, which would be added to a message stating their old nickname + and linking to the nickname policy. """ if await _utils.get_active_infraction(ctx, member, "superstar"): return # Post the infraction to the API old_nick = member.display_name - reason = reason or f"old nick: {old_nick}" + reason = (f"Nickname '{old_nick}' does not comply with our [nickname policy]({NICKNAME_POLICY_URL}). " + f"{reason}") infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) id_ = infraction["id"] @@ -152,7 +153,6 @@ class Superstarify(InfractionScheduler, Cog): old_nick = escape_markdown(old_nick) forced_nick = escape_markdown(forced_nick) - superstar_reason = f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." nickname_info = textwrap.dedent(f""" Old nickname: `{old_nick}` New nickname: `{forced_nick}` @@ -160,7 +160,7 @@ class Superstarify(InfractionScheduler, Cog): successful = await self.apply_infraction( ctx, infraction, member, action(), - user_reason=superstar_reason, + user_reason=reason, additional_info=nickname_info ) -- cgit v1.2.3 From d7e94f2570c69ae04c32bc4bad338b1be0c1da26 Mon Sep 17 00:00:00 2001 From: Steele Date: Sat, 5 Dec 2020 12:09:54 -0500 Subject: Add `starify` and `unstarify` as command aliases. --- bot/exts/moderation/infraction/superstarify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index a4327fb95..e7d1c4da8 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -104,7 +104,7 @@ class Superstarify(InfractionScheduler, Cog): await self.reapply_infraction(infraction, action) - @command(name="superstarify", aliases=("force_nick", "star")) + @command(name="superstarify", aliases=("force_nick", "star", "starify")) async def superstarify( self, ctx: Context, @@ -182,7 +182,7 @@ class Superstarify(InfractionScheduler, Cog): ) await ctx.send(embed=embed) - @command(name="unsuperstarify", aliases=("release_nick", "unstar")) + @command(name="unsuperstarify", aliases=("release_nick", "unstar", "unstarify")) async def unsuperstarify(self, ctx: Context, member: Member) -> None: """Remove the superstarify infraction and allow the user to change their nickname.""" await self.pardon_infraction(ctx, "superstar", member) -- cgit v1.2.3 From e08c39238dabe40abca7ae4eaed6873e26fd051f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 6 Dec 2020 14:15:34 +0000 Subject: Create review-policy.yml --- .github/review-policy.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/review-policy.yml diff --git a/.github/review-policy.yml b/.github/review-policy.yml new file mode 100644 index 000000000..421b30f8a --- /dev/null +++ b/.github/review-policy.yml @@ -0,0 +1,3 @@ +remote: python-discord/.github +path: review-policies/core-developers.yml +ref: main -- cgit v1.2.3 From 0f66fe3040d70de51ece1aa0de38a88b20000221 Mon Sep 17 00:00:00 2001 From: Steele Date: Sun, 6 Dec 2020 11:16:56 -0500 Subject: User gets a more detailed message from the bot Whereas one of my previous commits makes the message the user gets and the infraction that gets recorded the same, the recorded infraction is now shorter, but the message the user gets is more similar to the embed posted in the public channel. We also softened the language of the user-facing message a bit. --- bot/exts/moderation/infraction/superstarify.py | 28 +++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index e7d1c4da8..1d512a4c7 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -136,9 +136,8 @@ class Superstarify(InfractionScheduler, Cog): # Post the infraction to the API old_nick = member.display_name - reason = (f"Nickname '{old_nick}' does not comply with our [nickname policy]({NICKNAME_POLICY_URL}). " - f"{reason}") - infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) + infraction_reason = f'Old nickname: {old_nick}. {reason}' + infraction = await _utils.post_infraction(ctx, member, "superstar", infraction_reason, duration, active=True) id_ = infraction["id"] forced_nick = self.get_nick(id_, member.id) @@ -158,9 +157,21 @@ class Superstarify(InfractionScheduler, Cog): New nickname: `{forced_nick}` """).strip() + formatted_reason = f'**Additional details:** {reason}\n\n' if reason else '' + + embed_reason = ( + f"Your previous nickname, **{old_nick}**, " + f"didn't comply with our nickname policy. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"{formatted_reason}" + f"You will be unable to change your nickname until **{expiry_str}**. " + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) + successful = await self.apply_infraction( ctx, infraction, member, action(), - user_reason=reason, + user_reason=embed_reason, additional_info=nickname_info ) @@ -171,14 +182,7 @@ class Superstarify(InfractionScheduler, Cog): embed = Embed( title="Congratulations!", colour=constants.Colours.soft_orange, - description=( - f"Your previous nickname, **{old_nick}**, " - f"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 **{expiry_str}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) + description=embed_reason ) await ctx.send(embed=embed) -- cgit v1.2.3 From 345ee39e7cc5449e563817c4f30895638c66c206 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Sun, 6 Dec 2020 13:29:35 -0500 Subject: Update CODEOWNERS for @Den4200 --- .github/CODEOWNERS | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 642676078..73e303325 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,3 @@ -# Request Dennis for any PR -* @Den4200 - # Extensions **/bot/exts/backend/sync/** @MarkKoz **/bot/exts/filters/*token_remover.py @MarkKoz @@ -9,8 +6,8 @@ bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz bot/exts/utils/snekbox.py @MarkKoz @Akarys42 bot/exts/help_channels/** @MarkKoz @Akarys42 -bot/exts/moderation/** @Akarys42 @mbaruh -bot/exts/info/** @Akarys42 @mbaruh +bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 +bot/exts/info/** @Akarys42 @mbaruh @Den4200 bot/exts/filters/** @mbaruh # Utils @@ -26,9 +23,9 @@ tests/bot/exts/test_cogs.py @MarkKoz tests/** @Akarys42 # CI & Docker -.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ -Dockerfile @MarkKoz @Akarys42 -docker-compose.yml @MarkKoz @Akarys42 +.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ @Den4200 +Dockerfile @MarkKoz @Akarys42 @Den4200 +docker-compose.yml @MarkKoz @Akarys42 @Den4200 # Tools Pipfile* @Akarys42 -- cgit v1.2.3 From 032b64f625d9d16f532ba0e895a412bc24ee9659 Mon Sep 17 00:00:00 2001 From: Steele Date: Mon, 7 Dec 2020 11:04:38 -0500 Subject: Use the original wording of the public embed, but change the title to "Superstarified!" Per internal staff discussion, we'll keep the wording of the message. --- bot/exts/moderation/infraction/superstarify.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 1d512a4c7..ffc470c54 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -157,32 +157,29 @@ class Superstarify(InfractionScheduler, Cog): New nickname: `{forced_nick}` """).strip() - formatted_reason = f'**Additional details:** {reason}\n\n' if reason else '' - - embed_reason = ( + user_message = ( f"Your previous nickname, **{old_nick}**, " - f"didn't comply with our nickname policy. " + f"was so bad that we have decided to change it. " f"Your new nickname will be **{forced_nick}**.\n\n" - f"{formatted_reason}" + "{reason}" f"You will be unable to change your nickname until **{expiry_str}**. " "If you're confused by this, please read our " f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) + ).format successful = await self.apply_infraction( ctx, infraction, member, action(), - user_reason=embed_reason, + user_reason=user_message(reason=f'**Additional details:** {reason}\n\n' if reason else ''), additional_info=nickname_info ) - # Send an embed with the infraction information to the invoking context if - # superstar was successful. + # Send an embed with to the invoking context if superstar was successful. if successful: log.trace(f"Sending superstar #{id_} embed.") embed = Embed( - title="Congratulations!", + title="Superstarified!", colour=constants.Colours.soft_orange, - description=embed_reason + description=user_message(reason='') ) await ctx.send(embed=embed) -- cgit v1.2.3 From 9d00ef35afd5b64c070003a7941cc38d98bdf9cc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 9 Dec 2020 07:56:21 +0200 Subject: Add sf alias to snowflake command --- bot/exts/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 3f16bc10b..87abbe4de 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -168,7 +168,7 @@ class Utils(Cog): embed.description = best_match await ctx.send(embed=embed) - @command(aliases=("snf", "snfl")) + @command(aliases=("snf", "snfl", "sf")) @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) async def snowflake(self, ctx: Context, snowflake: int) -> None: """Get Discord snowflake creation time.""" -- cgit v1.2.3 From e0335bbb3fe1c35259647be2e23fb09fb2b09284 Mon Sep 17 00:00:00 2001 From: Steele Date: Wed, 9 Dec 2020 19:58:25 -0500 Subject: Create Verify cog for new `!verify` command. `!verify` command allows moderators to apply the Developer role to a user. `!verify` is therefore removed as an alias for `!accept`. --- bot/exts/moderation/verification.py | 2 +- bot/exts/moderation/verify.py | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 bot/exts/moderation/verify.py diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c599156d0..b1c94185a 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -756,7 +756,7 @@ class Verification(Cog): log.trace(f"Bumping verification stats in category: {category}") self.bot.stats.incr(f"verification.{category}") - @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) + @command(name='accept', aliases=('verified', 'accepted'), hidden=True) @has_no_roles(constants.Roles.verified) @in_whitelist(channels=(constants.Channels.verification,)) async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args diff --git a/bot/exts/moderation/verify.py b/bot/exts/moderation/verify.py new file mode 100644 index 000000000..09f50efde --- /dev/null +++ b/bot/exts/moderation/verify.py @@ -0,0 +1,45 @@ +import logging + +from discord import Member, Role +from discord.ext.commands import Cog, Context, command, has_any_role + +from bot.bot import Bot +from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles + +log = logging.getLogger(__name__) + + +class Verify(Cog): + """Command for applying verification roles.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.developer_role: Role = None + + @Cog.listener() + async def on_ready(self) -> None: + """Sets `self.developer_role` to the Role object once the bot is online.""" + await self.bot.wait_until_guild_available() + self.developer_role = self.bot.get_guild(Guild.id).get_role(Roles.verified) + + @command(name='verify') + @has_any_role(*MODERATION_ROLES) + async def apply_developer_role(self, ctx: Context, user: Member) -> None: + """Command for moderators to apply the Developer role to any user.""" + log.trace(f'verify command called by {ctx.author} for {user.id}.') + if self.developer_role is None: + await self.on_ready() + + if self.developer_role in user.roles: + log.trace(f'{user.id} is already a developer, aborting.') + await ctx.send(f'{Emojis.cross_mark} {user} is already a developer.') + return + + await user.add_roles(self.developer_role) + log.trace(f'Developer role successfully applied to {user.id}') + await ctx.send(f'{Emojis.check_mark} Developer role role applied to {user}.') + + +def setup(bot: Bot) -> None: + """Load the Verify cog.""" + bot.add_cog(Verify(bot)) -- cgit v1.2.3 From 0833ad51cfbd93df2d5a655255e6161334b4efe6 Mon Sep 17 00:00:00 2001 From: Steele Date: Wed, 9 Dec 2020 22:59:02 -0500 Subject: Delete verify.py, integrate `!verify` command into verification.py. There wasn't any reason the command needed its own cog, so the exact same functionality is now in the Verification cog. --- bot/exts/moderation/verification.py | 16 +++++++++++++ bot/exts/moderation/verify.py | 45 ------------------------------------- 2 files changed, 16 insertions(+), 45 deletions(-) delete mode 100644 bot/exts/moderation/verify.py diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index b1c94185a..c42c6588f 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -848,6 +848,22 @@ class Verification(Cog): else: return True + @command(name='verify') + @has_any_role(*constants.MODERATION_ROLES) + async def apply_developer_role(self, ctx: Context, user: discord.Member) -> None: + """Command for moderators to apply the Developer role to any user.""" + log.trace(f'verify command called by {ctx.author} for {user.id}.') + developer_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.verified) + + if developer_role in user.roles: + log.trace(f'{user.id} is already a developer, aborting.') + await ctx.send(f'{constants.Emojis.cross_mark} {user} is already a developer.') + return + + await user.add_roles(developer_role) + log.trace(f'Developer role successfully applied to {user.id}') + await ctx.send(f'{constants.Emojis.check_mark} Developer role applied to {user}.') + # endregion diff --git a/bot/exts/moderation/verify.py b/bot/exts/moderation/verify.py deleted file mode 100644 index 09f50efde..000000000 --- a/bot/exts/moderation/verify.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging - -from discord import Member, Role -from discord.ext.commands import Cog, Context, command, has_any_role - -from bot.bot import Bot -from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles - -log = logging.getLogger(__name__) - - -class Verify(Cog): - """Command for applying verification roles.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.developer_role: Role = None - - @Cog.listener() - async def on_ready(self) -> None: - """Sets `self.developer_role` to the Role object once the bot is online.""" - await self.bot.wait_until_guild_available() - self.developer_role = self.bot.get_guild(Guild.id).get_role(Roles.verified) - - @command(name='verify') - @has_any_role(*MODERATION_ROLES) - async def apply_developer_role(self, ctx: Context, user: Member) -> None: - """Command for moderators to apply the Developer role to any user.""" - log.trace(f'verify command called by {ctx.author} for {user.id}.') - if self.developer_role is None: - await self.on_ready() - - if self.developer_role in user.roles: - log.trace(f'{user.id} is already a developer, aborting.') - await ctx.send(f'{Emojis.cross_mark} {user} is already a developer.') - return - - await user.add_roles(self.developer_role) - log.trace(f'Developer role successfully applied to {user.id}') - await ctx.send(f'{Emojis.check_mark} Developer role role applied to {user}.') - - -def setup(bot: Bot) -> None: - """Load the Verify cog.""" - bot.add_cog(Verify(bot)) -- cgit v1.2.3 From 47a2607ac85c7cf808152c301fb6969723915389 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 10 Dec 2020 15:39:09 +0200 Subject: Use Snowflake converter for snowflake command --- bot/exts/utils/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 87abbe4de..8e7e6ba36 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -13,6 +13,7 @@ from discord.utils import snowflake_time from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES +from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils import messages @@ -170,7 +171,7 @@ class Utils(Cog): @command(aliases=("snf", "snfl", "sf")) @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) - async def snowflake(self, ctx: Context, snowflake: int) -> None: + async def snowflake(self, ctx: Context, snowflake: Snowflake) -> None: """Get Discord snowflake creation time.""" created_at = snowflake_time(snowflake) embed = Embed( -- cgit v1.2.3 From def97dd4c9d43bf2a5275a860a9eeb8e91bdb5a9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Thu, 10 Dec 2020 21:30:58 +0100 Subject: Send a custom workflow status embed to Discord This commit introduces the same custom status embed as is already being used for Sir Lancebot. The default embeds GitHub sends are disabled, as they were causing slight issues with rate limits from time to time. It works like this: - The Lint & Test workflow stores an artifact with PR information, if we are linting/testing a PR. - Whenever we reach the end of a workflow run sequence, a status embed is send with the conclusion status. Signed-off-by: Sebastiaan Zeeff --- .github/workflows/lint-test.yml | 22 +++++++++++ .github/workflows/status_embed.yaml | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 .github/workflows/status_embed.yaml diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 5444fc3de..a38f031fa 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -113,3 +113,25 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: coveralls + + # Prepare the Pull Request Payload artifact. If this fails, we + # we fail silently using the `continue-on-error` option. It's + # nice if this succeeds, but if it fails for any reason, it + # does not mean that our lint-test checks failed. + - name: Prepare Pull Request Payload artifact + id: prepare-artifact + if: always() && github.event_name == 'pull_request' + continue-on-error: true + run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json + + # This only makes sense if the previous step succeeded. To + # get the original outcome of the previous step before the + # `continue-on-error` conclusion is applied, we use the + # `.outcome` value. This step also fails silently. + - name: Upload a Build Artifact + if: steps.prepare-artifact.outcome == 'success' + continue-on-error: true + uses: actions/upload-artifact@v2 + with: + name: pull-request-payload + path: pull_request_payload.json diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml new file mode 100644 index 000000000..b6a71b887 --- /dev/null +++ b/.github/workflows/status_embed.yaml @@ -0,0 +1,78 @@ +name: Status Embed + +on: + workflow_run: + workflows: + - Lint & Test + - Build + - Deploy + types: + - completed + +jobs: + status_embed: + # We need to send a status embed whenever the workflow + # sequence we're running terminates. There are a number + # of situations in which that happens: + # + # 1. We reach the end of the Deploy workflow, without + # it being skipped. + # + # 2. A `pull_request` triggered a Lint & Test workflow, + # as the sequence always terminates with one run. + # + # 3. If any workflow ends in failure or was cancelled. + if: >- + (github.event.workflow_run.name == 'Deploy' && github.event.workflow_run.conclusion != 'skipped') || + github.event.workflow_run.event == 'pull_request' || + github.event.workflow_run.conclusion == 'failure' || + github.event.workflow_run.conclusion == 'cancelled' + name: Send Status Embed to Discord + runs-on: ubuntu-latest + + steps: + # A workflow_run event does not contain all the information + # we need for a PR embed. That's why we upload an artifact + # with that information in the Lint workflow. + - name: Get Pull Request Information + id: pr_info + if: github.event.workflow_run.event == 'pull_request' + run: | + curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json + DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') + [ -z "$DOWNLOAD_URL" ] && exit 1 + wget --quiet --header="Authorization: token $GITHUB_TOKEN" -O pull_request_payload.zip $DOWNLOAD_URL || exit 2 + unzip -p pull_request_payload.zip > pull_request_payload.json + [ -s pull_request_payload.json ] || exit 3 + echo "::set-output name=pr_author_login::$(jq -r '.user.login // empty' pull_request_payload.json)" + echo "::set-output name=pr_number::$(jq -r '.number // empty' pull_request_payload.json)" + echo "::set-output name=pr_title::$(jq -r '.title // empty' pull_request_payload.json)" + echo "::set-output name=pr_source::$(jq -r '.head.label // empty' pull_request_payload.json)" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Send an informational status embed to Discord instead of the + # standard embeds that Discord sends. This embed will contain + # more information and we can fine tune when we actually want + # to send an embed. + - name: GitHub Actions Status Embed for Discord + uses: SebastiaanZ/github-status-embed-for-discord@v0.2.1 + with: + # Our GitHub Actions webhook + webhook_id: '784184528997842985' + webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} + + # Workflow information + workflow_name: ${{ github.event.workflow_run.name }} + run_id: ${{ github.event.workflow_run.id }} + run_number: ${{ github.event.workflow_run.run_number }} + status: ${{ github.event.workflow_run.conclusion }} + actor: ${{ github.actor }} + repository: ${{ github.repository }} + ref: ${{ github.ref }} + sha: ${{ github.event.workflow_run.head_sha }} + + pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} + pr_number: ${{ steps.pr_info.outputs.pr_number }} + pr_title: ${{ steps.pr_info.outputs.pr_title }} + pr_source: ${{ steps.pr_info.outputs.pr_source }} -- cgit v1.2.3 From 9250608d1ec80fcc098e0174f5204f157fab9b8e Mon Sep 17 00:00:00 2001 From: Xithrius Date: Thu, 10 Dec 2020 16:34:47 -0800 Subject: Compressed if into or statements. --- bot/exts/info/information.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a8adb817b..5d94d73e9 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -382,15 +382,8 @@ class Information(Cog): if verified_at is not None: verified_at = time_since(parser.isoparse(user_activity["verified_at"]), max_units=3) - if user_activity["total_messages"]: - activity_output.append(user_activity['total_messages']) - else: - activity_output.append("No messages") - - if user_activity["activity_blocks"]: - activity_output.append(user_activity["activity_blocks"]) - else: - activity_output.append("No activity") + activity_output.append(user_activity['total_messages'] or "No messages") + activity_output.append(user_activity["activity_blocks"] or "No activity") activity_output = "\n".join( f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output)) -- cgit v1.2.3 From 223455d979cc794f857fc77e6211837c9639cca9 Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Thu, 10 Dec 2020 16:38:48 -0800 Subject: Compressed embed building Co-authored-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/info/information.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a8adb817b..648f283bc 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -230,17 +230,11 @@ class Information(Cog): if on_server: joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - if is_mod_channel(ctx.channel): - membership = textwrap.dedent(f""" - Joined: {joined} - Verified: {verified_at} - Roles: {roles or None} - """).strip() - else: - membership = textwrap.dedent(f""" - Joined: {joined} - Roles: {roles or None} - """).strip() + membership = {"Joined": joined, "Verified": verified_at, "Roles": roles or None} + if not is_mod_channel(ctx.channel): + membership.pop("Verified") + + membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()])) else: roles = None membership = "The user is not a member of the server" -- cgit v1.2.3 From dd2f29feae436a550b73b20d43166a9548840f47 Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Thu, 10 Dec 2020 16:39:29 -0800 Subject: Slightly reformatted activity block building. Co-authored-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/info/information.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 648f283bc..22a32cdb5 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -387,7 +387,8 @@ class Information(Cog): activity_output.append("No activity") activity_output = "\n".join( - f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output)) + f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) + ) return verified_at, ("Activity", activity_output) -- cgit v1.2.3 From 77a8e420a69fdafb9fe96739d9d728c7a5d3638f Mon Sep 17 00:00:00 2001 From: Xithrius Date: Thu, 10 Dec 2020 16:57:35 -0800 Subject: Added docstring for the user activity function. --- bot/exts/info/information.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 26cf5fee3..8eec22c58 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -361,7 +361,12 @@ class Information(Cog): return "Nominations", "\n".join(output) async def user_verification_and_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: - """Gets the time of verification and amount of messages for `member`.""" + """ + Gets the time of verification and amount of messages for `member`. + + Fetches information from the metricity database that's hosted by the site. + If the database returns a code besides a 404, then many parts of the bot are broken including this one. + """ activity_output = [] verified_at = False -- cgit v1.2.3 From 35f7ecda15e017afd184a94404d21c3f97cd0583 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Dec 2020 06:45:21 +0100 Subject: Make sure PR build artifact is always uploaded GitHub Actions has an implicit status condition, `success()`, that is added whenever an `if` condition lacks a status function check of its own. In this case, while the upload step did check for the outcome of the previous "always" step, it did not have an actual status check and, thus, only ran on success. Since we always want to upload the artifact, even if other steps failed, I've added the "always" status function now. --- .github/workflows/lint-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index a38f031fa..6fa8e8333 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -129,7 +129,7 @@ jobs: # `continue-on-error` conclusion is applied, we use the # `.outcome` value. This step also fails silently. - name: Upload a Build Artifact - if: steps.prepare-artifact.outcome == 'success' + if: always() && steps.prepare-artifact.outcome == 'success' continue-on-error: true uses: actions/upload-artifact@v2 with: -- cgit v1.2.3 From 2fa5b78e357bf45e23e188dc501180ed241237d1 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Fri, 11 Dec 2020 05:06:03 -0800 Subject: Added catching for unparsable short ISO dates. --- bot/exts/info/information.py | 11 +++++++---- tests/bot/exts/info/test_information.py | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 8eec22c58..0c04d7cd0 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -230,7 +230,7 @@ class Information(Cog): if on_server: joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - membership = {"Joined": joined, "Verified": verified_at, "Roles": roles or None} + membership = {"Joined": joined, "Verified": verified_at or "False", "Roles": roles or None} if not is_mod_channel(ctx.channel): membership.pop("Verified") @@ -377,9 +377,12 @@ class Information(Cog): activity_output = "No activity" else: - verified_at = user_activity['verified_at'] - if verified_at is not None: - verified_at = time_since(parser.isoparse(user_activity["verified_at"]), max_units=3) + try: + if (verified_at := user_activity['verified_at']) is not None: + verified_at = time_since(parser.isoparse(verified_at), max_units=3) + except ValueError: + log.warning('Could not parse ISO string correctly for user verification date.') + verified_at = None activity_output.append(user_activity['total_messages'] or "No messages") activity_output.append(user_activity["activity_blocks"] or "No activity") diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index daede54c5..254b0a867 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -355,6 +355,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" Joined: {"1 year ago"} + Verified: {"False"} Roles: &Moderators """).strip(), embed.fields[1].value -- cgit v1.2.3 From 9f1bbe528311afaf5a56ebafdac7a629c9ce238e Mon Sep 17 00:00:00 2001 From: Xithrius Date: Fri, 11 Dec 2020 05:11:48 -0800 Subject: Single to double quotes & warning includes user ID. --- bot/exts/info/information.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 0c04d7cd0..187950689 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -371,20 +371,20 @@ class Information(Cog): verified_at = False try: - user_activity = await self.bot.api_client.get(f'bot/users/{user.id}/metricity_data') + user_activity = await self.bot.api_client.get(f"bot/users/{user.id}/metricity_data") except ResponseCodeError as e: if e.status == 404: activity_output = "No activity" else: try: - if (verified_at := user_activity['verified_at']) is not None: + if (verified_at := user_activity["verified_at"]) is not None: verified_at = time_since(parser.isoparse(verified_at), max_units=3) except ValueError: - log.warning('Could not parse ISO string correctly for user verification date.') + log.warning(f"Could not parse ISO string correctly for user {user.id} verification date.") verified_at = None - activity_output.append(user_activity['total_messages'] or "No messages") + activity_output.append(user_activity["total_messages"] or "No messages") activity_output.append(user_activity["activity_blocks"] or "No activity") activity_output = "\n".join( -- cgit v1.2.3 From 628bd4ffd1717eaed9372287c59fae1b23d4cbdf Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 12 Dec 2020 02:08:35 +0000 Subject: Comma separators in metricity data in user command --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 187950689..178d48a67 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -384,8 +384,8 @@ class Information(Cog): log.warning(f"Could not parse ISO string correctly for user {user.id} verification date.") verified_at = None - activity_output.append(user_activity["total_messages"] or "No messages") - activity_output.append(user_activity["activity_blocks"] or "No activity") + activity_output.append(f"{user_activity['total_messages']:,}" or "No messages") + activity_output.append(f"{user_activity['activity_blocks']:,}" or "No activity") activity_output = "\n".join( f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) -- cgit v1.2.3 From b98c7f35916b9e5a41945030d87227394bafa1d5 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 12 Dec 2020 02:29:52 +0000 Subject: Update comma code to fix tests --- bot/exts/info/information.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 178d48a67..2543d1e28 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -384,8 +384,15 @@ class Information(Cog): log.warning(f"Could not parse ISO string correctly for user {user.id} verification date.") verified_at = None - activity_output.append(f"{user_activity['total_messages']:,}" or "No messages") - activity_output.append(f"{user_activity['activity_blocks']:,}" or "No activity") + if messages := user_activity["total_messages"]: + activity_output.append(f"{messages:,}") + else: + activity_output.append("No messages") + + if activity_blocks := user_activity["activity_blocks"]: + activity_output.append(f"{activity_blocks:,}") + else: + activity_output.append("No activity") activity_output = "\n".join( f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) -- cgit v1.2.3 From c3597108c8d191fd527de0f532e0bda238c3c50e Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 12 Dec 2020 02:34:43 +0000 Subject: Revert "Update comma code to fix tests" This reverts commit b98c7f35916b9e5a41945030d87227394bafa1d5. --- bot/exts/info/information.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 2543d1e28..178d48a67 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -384,15 +384,8 @@ class Information(Cog): log.warning(f"Could not parse ISO string correctly for user {user.id} verification date.") verified_at = None - if messages := user_activity["total_messages"]: - activity_output.append(f"{messages:,}") - else: - activity_output.append("No messages") - - if activity_blocks := user_activity["activity_blocks"]: - activity_output.append(f"{activity_blocks:,}") - else: - activity_output.append("No activity") + activity_output.append(f"{user_activity['total_messages']:,}" or "No messages") + activity_output.append(f"{user_activity['activity_blocks']:,}" or "No activity") activity_output = "\n".join( f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) -- cgit v1.2.3 From bcab67375c778fb30c86d8edd19bed854b0f8b45 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 12 Dec 2020 02:34:51 +0000 Subject: Revert "Comma separators in metricity data in user command" This reverts commit 628bd4ffd1717eaed9372287c59fae1b23d4cbdf. --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 178d48a67..187950689 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -384,8 +384,8 @@ class Information(Cog): log.warning(f"Could not parse ISO string correctly for user {user.id} verification date.") verified_at = None - activity_output.append(f"{user_activity['total_messages']:,}" or "No messages") - activity_output.append(f"{user_activity['activity_blocks']:,}" or "No activity") + activity_output.append(user_activity["total_messages"] or "No messages") + activity_output.append(user_activity["activity_blocks"] or "No activity") activity_output = "\n".join( f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) -- cgit v1.2.3 From 93fb7413e7f98ced1a56f5dc00aea363e7a16625 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 14 Dec 2020 22:01:16 +0100 Subject: Fix codeblock escape On some devices the previous escaping didn't work properly, escaping all backticks will make sure none of them get registered as Markdown --- bot/resources/tags/codeblock.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index 8d48bdf06..ac64656e5 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -1,7 +1,7 @@ Here's how to format Python code on Discord: -\```py +\`\`\`py print('Hello world!') -\``` +\`\`\` **These are backticks, not quotes.** Check [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) out if you can't find the backtick key. -- cgit v1.2.3 From ab0785b06157e9628c02bdb5aaecef0d5d8c5cb4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 16 Dec 2020 17:40:14 +0200 Subject: Add codeowner entries for ks129 --- .github/CODEOWNERS | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 73e303325..ad813d893 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,9 +6,11 @@ bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz bot/exts/utils/snekbox.py @MarkKoz @Akarys42 bot/exts/help_channels/** @MarkKoz @Akarys42 -bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 +bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129 bot/exts/info/** @Akarys42 @mbaruh @Den4200 bot/exts/filters/** @mbaruh +bot/exts/fun/** @ks129 +bot/exts/utils/** @ks129 # Utils bot/utils/extensions.py @MarkKoz -- cgit v1.2.3 From 14e71609e8e40be0832b66df7a0c309ba262659a Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 16 Dec 2020 23:50:23 +0000 Subject: Update verification.py --- bot/exts/moderation/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c42c6588f..7aa559617 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -565,11 +565,11 @@ class Verification(Cog): raw_member = await self.bot.http.get_member(member.guild.id, member.id) - # If the user has the is_pending flag set, they will be using the alternate + # If the user has the pending flag set, they will be using the alternate # gate and will not need a welcome DM with verification instructions. # We will send them an alternate DM once they verify with the welcome # video. - if raw_member.get("is_pending"): + if raw_member.get("pending"): await self.member_gating_cache.set(member.id, True) # TODO: Temporary, remove soon after asking joe. -- cgit v1.2.3 From 9d96d490e33861bc037e693d0d8f885c05f28fc2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 17 Dec 2020 17:53:15 +0200 Subject: Log info instead error for watchchannel consume task cancel --- bot/exts/moderation/watchchannels/_watchchannel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 8894762f3..f9fc12dc3 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -347,7 +347,7 @@ class WatchChannel(metaclass=CogABCMeta): try: task.result() except asyncio.CancelledError: - self.log.error( + self.log.info( f"The consume task of {type(self).__name__} was canceled. Messages may be lost." ) -- cgit v1.2.3 From 4aa7429cc967c43d2797c9bf7a24968c97855822 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Thu, 17 Dec 2020 16:10:13 +0000 Subject: Re-lock Pipfile --- Pipfile.lock | 284 +++++++++++++++++++++++++++++------------------------------ 1 file changed, 142 insertions(+), 142 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 215c64bed..2fbaf8803 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d08ba836e630ae64a560ef879216d24801e8069dddf7d51b00e885efcf24c2ff" + "sha256": "66cc236b0ecc19515967049cf42fd8cde76db4f5803f3ab492a0558a9de447f4" }, "pipfile-spec": 6, "requires": { @@ -137,51 +137,51 @@ }, "certifi": { "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.11.8" + "version": "==2020.12.5" }, "cffi": { "hashes": [ - "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", - "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", - "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", - "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", - "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", - "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", - "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", - "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", - "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", - "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", - "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", - "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", - "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", - "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", - "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", - "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", - "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", - "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", - "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", - "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", - "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", - "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", - "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", - "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", - "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", - "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", - "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", - "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", - "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", - "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", - "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", - "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", - "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", - "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", - "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", - "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" - ], - "version": "==1.14.3" + "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e", + "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", + "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", + "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", + "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", + "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", + "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", + "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", + "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", + "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698", + "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2", + "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", + "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", + "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009", + "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03", + "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", + "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", + "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53", + "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35", + "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", + "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", + "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", + "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", + "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", + "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", + "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", + "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", + "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", + "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", + "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", + "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", + "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", + "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", + "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", + "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", + "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f" + ], + "version": "==1.14.4" }, "chardet": { "hashes": [ @@ -200,11 +200,11 @@ }, "coloredlogs": { "hashes": [ - "sha256:346f58aad6afd48444c2468618623638dadab76e4e70d5e10822676f2d32226a", - "sha256:a1fab193d2053aa6c0a97608c4342d031f1f93a3d1218432c59322441d31a505" + "sha256:7ef1a7219870c7f02c218a2f2877ce68f2f8e087bb3a55bd6fbaa2a4362b4d52", + "sha256:e244a892f9d97ffd2c60f15bf1d2582ef7f9ac0f848d132249004184785702b3" ], "index": "pypi", - "version": "==14.0" + "version": "==14.3" }, "deepdiff": { "hashes": [ @@ -239,10 +239,10 @@ }, "fakeredis": { "hashes": [ - "sha256:8070b7fce16f828beaef2c757a4354af91698685d5232404f1aeeb233529c7a5", - "sha256:f8c8ea764d7b6fd801e7f5486e3edd32ca991d506186f1923a01fc072e33c271" + "sha256:01cb47d2286825a171fb49c0e445b1fa9307087e07cbb3d027ea10dbff108b6a", + "sha256:2c6041cf0225889bc403f3949838b2c53470a95a9e2d4272422937786f5f8f73" ], - "version": "==1.4.4" + "version": "==1.4.5" }, "feedparser": { "hashes": [ @@ -315,11 +315,11 @@ }, "humanfriendly": { "hashes": [ - "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12", - "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080" + "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d", + "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==8.2" + "version": "==9.1" }, "idna": { "hashes": [ @@ -347,54 +347,54 @@ }, "lxml": { "hashes": [ - "sha256:098fb713b31050463751dcc694878e1d39f316b86366fb9fe3fbbe5396ac9fab", - "sha256:0e89f5d422988c65e6936e4ec0fe54d6f73f3128c80eb7ecc3b87f595523607b", - "sha256:189ad47203e846a7a4951c17694d845b6ade7917c47c64b29b86526eefc3adf5", - "sha256:1d87936cb5801c557f3e981c9c193861264c01209cb3ad0964a16310ca1b3301", - "sha256:211b3bcf5da70c2d4b84d09232534ad1d78320762e2c59dedc73bf01cb1fc45b", - "sha256:2358809cc64394617f2719147a58ae26dac9e21bae772b45cfb80baa26bfca5d", - "sha256:23c83112b4dada0b75789d73f949dbb4e8f29a0a3511647024a398ebd023347b", - "sha256:24e811118aab6abe3ce23ff0d7d38932329c513f9cef849d3ee88b0f848f2aa9", - "sha256:2d5896ddf5389560257bbe89317ca7bcb4e54a02b53a3e572e1ce4226512b51b", - "sha256:2d6571c48328be4304aee031d2d5046cbc8aed5740c654575613c5a4f5a11311", - "sha256:2e311a10f3e85250910a615fe194839a04a0f6bc4e8e5bb5cac221344e3a7891", - "sha256:302160eb6e9764168e01d8c9ec6becddeb87776e81d3fcb0d97954dd51d48e0a", - "sha256:3a7a380bfecc551cfd67d6e8ad9faa91289173bdf12e9cfafbd2bdec0d7b1ec1", - "sha256:3d9b2b72eb0dbbdb0e276403873ecfae870599c83ba22cadff2db58541e72856", - "sha256:475325e037fdf068e0c2140b818518cf6bc4aa72435c407a798b2db9f8e90810", - "sha256:4b7572145054330c8e324a72d808c8c8fbe12be33368db28c39a255ad5f7fb51", - "sha256:4fff34721b628cce9eb4538cf9a73d02e0f3da4f35a515773cce6f5fe413b360", - "sha256:56eff8c6fb7bc4bcca395fdff494c52712b7a57486e4fbde34c31bb9da4c6cc4", - "sha256:573b2f5496c7e9f4985de70b9bbb4719ffd293d5565513e04ac20e42e6e5583f", - "sha256:7ecaef52fd9b9535ae5f01a1dd2651f6608e4ec9dc136fc4dfe7ebe3c3ddb230", - "sha256:803a80d72d1f693aa448566be46ffd70882d1ad8fc689a2e22afe63035eb998a", - "sha256:8862d1c2c020cb7a03b421a9a7b4fe046a208db30994fc8ff68c627a7915987f", - "sha256:9b06690224258db5cd39a84e993882a6874676f5de582da57f3df3a82ead9174", - "sha256:a71400b90b3599eb7bf241f947932e18a066907bf84617d80817998cee81e4bf", - "sha256:bb252f802f91f59767dcc559744e91efa9df532240a502befd874b54571417bd", - "sha256:be1ebf9cc25ab5399501c9046a7dcdaa9e911802ed0e12b7d620cd4bbf0518b3", - "sha256:be7c65e34d1b50ab7093b90427cbc488260e4b3a38ef2435d65b62e9fa3d798a", - "sha256:c0dac835c1a22621ffa5e5f999d57359c790c52bbd1c687fe514ae6924f65ef5", - "sha256:c152b2e93b639d1f36ec5a8ca24cde4a8eefb2b6b83668fcd8e83a67badcb367", - "sha256:d182eada8ea0de61a45a526aa0ae4bcd222f9673424e65315c35820291ff299c", - "sha256:d18331ea905a41ae71596502bd4c9a2998902328bbabd29e3d0f5f8569fabad1", - "sha256:d20d32cbb31d731def4b1502294ca2ee99f9249b63bc80e03e67e8f8e126dea8", - "sha256:d4ad7fd3269281cb471ad6c7bafca372e69789540d16e3755dd717e9e5c9d82f", - "sha256:d6f8c23f65a4bfe4300b85f1f40f6c32569822d08901db3b6454ab785d9117cc", - "sha256:d84d741c6e35c9f3e7406cb7c4c2e08474c2a6441d59322a00dcae65aac6315d", - "sha256:e65c221b2115a91035b55a593b6eb94aa1206fa3ab374f47c6dc10d364583ff9", - "sha256:f98b6f256be6cec8dd308a8563976ddaff0bdc18b730720f6f4bee927ffe926f" + "sha256:0448576c148c129594d890265b1a83b9cd76fd1f0a6a04620753d9a6bcfd0a4d", + "sha256:127f76864468d6630e1b453d3ffbbd04b024c674f55cf0a30dc2595137892d37", + "sha256:1471cee35eba321827d7d53d104e7b8c593ea3ad376aa2df89533ce8e1b24a01", + "sha256:2363c35637d2d9d6f26f60a208819e7eafc4305ce39dc1d5005eccc4593331c2", + "sha256:2e5cc908fe43fe1aa299e58046ad66981131a66aea3129aac7770c37f590a644", + "sha256:2e6fd1b8acd005bd71e6c94f30c055594bbd0aa02ef51a22bbfa961ab63b2d75", + "sha256:366cb750140f221523fa062d641393092813b81e15d0e25d9f7c6025f910ee80", + "sha256:42ebca24ba2a21065fb546f3e6bd0c58c3fe9ac298f3a320147029a4850f51a2", + "sha256:4e751e77006da34643ab782e4a5cc21ea7b755551db202bc4d3a423b307db780", + "sha256:4fb85c447e288df535b17ebdebf0ec1cf3a3f1a8eba7e79169f4f37af43c6b98", + "sha256:50c348995b47b5a4e330362cf39fc503b4a43b14a91c34c83b955e1805c8e308", + "sha256:535332fe9d00c3cd455bd3dd7d4bacab86e2d564bdf7606079160fa6251caacf", + "sha256:535f067002b0fd1a4e5296a8f1bf88193080ff992a195e66964ef2a6cfec5388", + "sha256:5be4a2e212bb6aa045e37f7d48e3e1e4b6fd259882ed5a00786f82e8c37ce77d", + "sha256:60a20bfc3bd234d54d49c388950195d23a5583d4108e1a1d47c9eef8d8c042b3", + "sha256:648914abafe67f11be7d93c1a546068f8eff3c5fa938e1f94509e4a5d682b2d8", + "sha256:681d75e1a38a69f1e64ab82fe4b1ed3fd758717bed735fb9aeaa124143f051af", + "sha256:68a5d77e440df94011214b7db907ec8f19e439507a70c958f750c18d88f995d2", + "sha256:69a63f83e88138ab7642d8f61418cf3180a4d8cd13995df87725cb8b893e950e", + "sha256:6e4183800f16f3679076dfa8abf2db3083919d7e30764a069fb66b2b9eff9939", + "sha256:6fd8d5903c2e53f49e99359b063df27fdf7acb89a52b6a12494208bf61345a03", + "sha256:791394449e98243839fa822a637177dd42a95f4883ad3dec2a0ce6ac99fb0a9d", + "sha256:7a7669ff50f41225ca5d6ee0a1ec8413f3a0d8aa2b109f86d540887b7ec0d72a", + "sha256:7e9eac1e526386df7c70ef253b792a0a12dd86d833b1d329e038c7a235dfceb5", + "sha256:7ee8af0b9f7de635c61cdd5b8534b76c52cd03536f29f51151b377f76e214a1a", + "sha256:8246f30ca34dc712ab07e51dc34fea883c00b7ccb0e614651e49da2c49a30711", + "sha256:8c88b599e226994ad4db29d93bc149aa1aff3dc3a4355dd5757569ba78632bdf", + "sha256:923963e989ffbceaa210ac37afc9b906acebe945d2723e9679b643513837b089", + "sha256:94d55bd03d8671686e3f012577d9caa5421a07286dd351dfef64791cf7c6c505", + "sha256:97db258793d193c7b62d4e2586c6ed98d51086e93f9a3af2b2034af01450a74b", + "sha256:a9d6bc8642e2c67db33f1247a77c53476f3a166e09067c0474facb045756087f", + "sha256:cd11c7e8d21af997ee8079037fff88f16fda188a9776eb4b81c7e4c9c0a7d7fc", + "sha256:d8d3d4713f0c28bdc6c806a278d998546e8efc3498949e3ace6e117462ac0a5e", + "sha256:e0bfe9bb028974a481410432dbe1b182e8191d5d40382e5b8ff39cdd2e5c5931", + "sha256:f4822c0660c3754f1a41a655e37cb4dbbc9be3d35b125a37fab6f82d47674ebc", + "sha256:f83d281bb2a6217cd806f4cf0ddded436790e66f393e124dfe9731f6b3fb9afe", + "sha256:fc37870d6716b137e80d19241d0e2cff7a7643b925dfa49b4c8ebd1295eb506e" ], "index": "pypi", - "version": "==4.6.1" + "version": "==4.6.2" }, "markdownify": { "hashes": [ - "sha256:30be8340724e706c9e811c27fe8c1542cf74a15b46827924fff5c54b40dd9b0d", - "sha256:a69588194fd76634f0139d6801b820fd652dc5eeba9530e90d323dfdc0155252" + "sha256:901c6106533f4a0b79cfe7c700c4df6b15cf782aa6236fd13161bf2608e2c591", + "sha256:f40874e3113a170697f0e74ea7aeee2d66eb9973201a5fbcc68ef8ce6bfbcf8a" ], "index": "pypi", - "version": "==0.5.3" + "version": "==0.6.0" }, "markupsafe": { "hashes": [ @@ -475,11 +475,11 @@ }, "packaging": { "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858", + "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.4" + "version": "==20.8" }, "pamqp": { "hashes": [ @@ -532,11 +532,11 @@ }, "pygments": { "hashes": [ - "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0", - "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773" + "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716", + "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08" ], "markers": "python_version >= '3.5'", - "version": "==2.7.2" + "version": "==2.7.3" }, "pyparsing": { "hashes": [ @@ -563,18 +563,18 @@ }, "pyyaml": { "hashes": [ - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], "index": "pypi", @@ -590,19 +590,19 @@ }, "requests": { "hashes": [ - "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", - "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], "index": "pypi", - "version": "==2.25.0" + "version": "==2.25.1" }, "sentry-sdk": { "hashes": [ - "sha256:1052f0ed084e532f66cb3e4ba617960d820152aee8b93fc6c05bd53861768c1c", - "sha256:4c42910a55a6b1fe694d5e4790d5188d105d77b5a6346c1c64cbea8c06c0e8b7" + "sha256:0a711ec952441c2ec89b8f5d226c33bc697914f46e876b44a4edd3e7864cf4d0", + "sha256:737a094e49a529dd0fdcaafa9e97cf7c3d5eb964bd229821d640bc77f3502b3f" ], "index": "pypi", - "version": "==0.19.4" + "version": "==0.19.5" }, "six": { "hashes": [ @@ -628,11 +628,11 @@ }, "soupsieve": { "hashes": [ - "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", - "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" + "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851", + "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e" ], "markers": "python_version >= '3.0'", - "version": "==2.0.1" + "version": "==2.1" }, "sphinx": { "hashes": [ @@ -748,10 +748,10 @@ }, "certifi": { "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.11.8" + "version": "==2020.12.5" }, "cfgv": { "hashes": [ @@ -854,11 +854,11 @@ }, "flake8-bugbear": { "hashes": [ - "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63", - "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162" + "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538", + "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703" ], "index": "pypi", - "version": "==20.1.4" + "version": "==20.11.1" }, "flake8-docstrings": { "hashes": [ @@ -893,11 +893,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd", - "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3" + "sha256:2821c79e83c656652d5ac6d3650ca370ed3c9752edb5383b1d50dee5bd8a383f", + "sha256:6cdd51e0d2f221e43ff4d5ac6331b1d95bbf4a5408906e36da913acaaed890e0" ], "index": "pypi", - "version": "==4.1.0" + "version": "==4.2.0" }, "flake8-todo": { "hashes": [ @@ -908,11 +908,11 @@ }, "identify": { "hashes": [ - "sha256:5dd84ac64a9a115b8e0b27d1756b244b882ad264c3c423f42af8235a6e71ca12", - "sha256:c9504ba6a043ee2db0a9d69e43246bc138034895f6338d5aed1b41e4a73b1513" + "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5", + "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.9" + "version": "==1.5.10" }, "idna": { "hashes": [ @@ -946,11 +946,11 @@ }, "pre-commit": { "hashes": [ - "sha256:22e6aa3bd571debb01eb7d34483f11c01b65237be4eebbf30c3d4fb65762d315", - "sha256:905ebc9b534b991baec87e934431f2d0606ba27f2b90f7f652985f5a5b8b6ae6" + "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0", + "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4" ], "index": "pypi", - "version": "==2.8.2" + "version": "==2.9.3" }, "pycodestyle": { "hashes": [ @@ -978,18 +978,18 @@ }, "pyyaml": { "hashes": [ - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], "index": "pypi", @@ -997,11 +997,11 @@ }, "requests": { "hashes": [ - "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", - "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], "index": "pypi", - "version": "==2.25.0" + "version": "==2.25.1" }, "six": { "hashes": [ @@ -1036,11 +1036,11 @@ }, "virtualenv": { "hashes": [ - "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2", - "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380" + "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c", + "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.1.0" + "version": "==20.2.2" } } } -- cgit v1.2.3 From fc1f7ac9747a747f902a16de4cd6865c5b394568 Mon Sep 17 00:00:00 2001 From: Steele Date: Thu, 17 Dec 2020 22:19:42 -0500 Subject: User gets the bot DM when verified via `!verify`. `ALTERNATE_VERIFIED_MESSAGE` now begins "You're now verified!" instead of "Thanks for accepting our rules!". --- bot/exts/moderation/verification.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 7aa559617..c413d36cf 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -55,7 +55,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! """ ALTERNATE_VERIFIED_MESSAGE = f""" -Thanks for accepting our rules! +You're now verified! You can find a copy of our rules for reference at . @@ -861,6 +861,7 @@ class Verification(Cog): return await user.add_roles(developer_role) + await safe_dm(user.send(ALTERNATE_VERIFIED_MESSAGE)) log.trace(f'Developer role successfully applied to {user.id}') await ctx.send(f'{constants.Emojis.check_mark} Developer role applied to {user}.') -- cgit v1.2.3 From 2b09d739074f6d1ae259e234ea2ab787711d839d Mon Sep 17 00:00:00 2001 From: Steele Date: Thu, 17 Dec 2020 22:26:06 -0500 Subject: Responses from the bot mention the user. Previously, responses from the bot would say the name of the user rather than mentioning them. --- bot/exts/moderation/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c413d36cf..8985a932f 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -857,13 +857,13 @@ class Verification(Cog): if developer_role in user.roles: log.trace(f'{user.id} is already a developer, aborting.') - await ctx.send(f'{constants.Emojis.cross_mark} {user} is already a developer.') + await ctx.send(f'{constants.Emojis.cross_mark} {user.mention} is already a developer.') return await user.add_roles(developer_role) await safe_dm(user.send(ALTERNATE_VERIFIED_MESSAGE)) log.trace(f'Developer role successfully applied to {user.id}') - await ctx.send(f'{constants.Emojis.check_mark} Developer role applied to {user}.') + await ctx.send(f'{constants.Emojis.check_mark} Developer role applied to {user.mention}.') # endregion -- cgit v1.2.3 From a8a3e829c926694ba9e95fc712a9cfd5ccf84c2f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 01:40:11 +0000 Subject: Handling pending flag changes on users --- bot/exts/moderation/verification.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 7aa559617..ff308a3b3 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -182,6 +182,8 @@ class Verification(Cog): self.bot = bot self.bot.loop.create_task(self._maybe_start_tasks()) + self.pending_members = set() + def cog_unload(self) -> None: """ Cancel internal tasks. @@ -570,18 +572,7 @@ class Verification(Cog): # We will send them an alternate DM once they verify with the welcome # video. if raw_member.get("pending"): - await self.member_gating_cache.set(member.id, True) - - # TODO: Temporary, remove soon after asking joe. - await self.mod_log.send_log_message( - icon_url=self.bot.user.avatar_url, - colour=discord.Colour.blurple(), - title="New native gated user", - channel_id=constants.Channels.user_log, - text=f"<@{member.id}> ({member.id})", - ) - - return + self.pending_members.add(member.id) log.trace(f"Sending on join message to new member: {member.id}") try: @@ -589,6 +580,17 @@ class Verification(Cog): except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") + @Cog.listener() + async def on_socket_response(self, msg: dict) -> None: + """Check if the users pending status has changed and send them them a welcome message.""" + if msg.get("t") == "GUILD_MEMBER_UPDATE": + user_id = int(msg["user"]["id"]) + + if user_id in self.pending_members: + self.pending_members.remove(user_id) + if member := self.bot.get_guild(constants.Guild.id).get_member(user_id): + await safe_dm(member.send(ALTERNATE_VERIFIED_MESSAGE)) + @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Check if we need to send a verification DM to a gated user.""" -- cgit v1.2.3 From 0be0d86271c84ca1b2980b552aaf31c78e84fcda Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 01:45:54 +0000 Subject: Correctly check if the user is pending --- bot/exts/moderation/verification.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index ff308a3b3..cc8abee42 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -584,12 +584,13 @@ class Verification(Cog): async def on_socket_response(self, msg: dict) -> None: """Check if the users pending status has changed and send them them a welcome message.""" if msg.get("t") == "GUILD_MEMBER_UPDATE": - user_id = int(msg["user"]["id"]) + user_id = int(msg["d"]["user"]["id"]) - if user_id in self.pending_members: - self.pending_members.remove(user_id) - if member := self.bot.get_guild(constants.Guild.id).get_member(user_id): - await safe_dm(member.send(ALTERNATE_VERIFIED_MESSAGE)) + if msg["d"]["pending"] is False: + if user_id in self.pending_members: + self.pending_members.remove(user_id) + if member := self.bot.get_guild(constants.Guild.id).get_member(user_id): + await safe_dm(member.send(ALTERNATE_VERIFIED_MESSAGE)) @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: -- cgit v1.2.3 From 583792136152eb4b06ed0dda1bc0b95a0d8ebad1 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 02:11:56 +0000 Subject: Fix minor verification bugs --- bot/exts/moderation/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index cc8abee42..581d7e0bf 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -572,7 +572,7 @@ class Verification(Cog): # We will send them an alternate DM once they verify with the welcome # video. if raw_member.get("pending"): - self.pending_members.add(member.id) + return self.pending_members.add(member.id) log.trace(f"Sending on join message to new member: {member.id}") try: @@ -586,7 +586,7 @@ class Verification(Cog): if msg.get("t") == "GUILD_MEMBER_UPDATE": user_id = int(msg["d"]["user"]["id"]) - if msg["d"]["pending"] is False: + if msg["d"].get("pending") is False: if user_id in self.pending_members: self.pending_members.remove(user_id) if member := self.bot.get_guild(constants.Guild.id).get_member(user_id): -- cgit v1.2.3 From fb4d82ca09c213b88da6845a5eb433e4e5e9961a Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 02:37:15 +0000 Subject: Install git in Docker container --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Dockerfile b/Dockerfile index 06a538b2a..0b1674e7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,11 @@ ENV PIP_NO_CACHE_DIR=false \ PIPENV_IGNORE_VIRTUALENVS=1 \ PIPENV_NOSPIN=1 +RUN apt-get -y update \ + && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* + # Install pipenv RUN pip install -U pipenv -- cgit v1.2.3 From 6ed94dab7f2b7d0a71775670664b661fa1766e5c Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 02:40:39 +0000 Subject: Bump discord.py to a unreleased ref --- Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pipfile b/Pipfile index ae80ae2ae..02b60b681 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -"discord.py" = "~=1.5.0" +discord-py = {git = "https://github.com/Rapptz/discord.py.git", ref = "93f102ca907af6722ee03638766afd53dfe93a7f"} feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" -- cgit v1.2.3 From 9e61bed6bb3b8485c4b829a7072f4cea1e52e079 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 02:41:20 +0000 Subject: Update verification.py to use on_member_update, closes #1330 --- bot/exts/moderation/verification.py | 39 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 581d7e0bf..ad05888df 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -570,9 +570,9 @@ class Verification(Cog): # If the user has the pending flag set, they will be using the alternate # gate and will not need a welcome DM with verification instructions. # We will send them an alternate DM once they verify with the welcome - # video. + # video when they pass the gate. if raw_member.get("pending"): - return self.pending_members.add(member.id) + return log.trace(f"Sending on join message to new member: {member.id}") try: @@ -580,34 +580,19 @@ class Verification(Cog): except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") - @Cog.listener() - async def on_socket_response(self, msg: dict) -> None: - """Check if the users pending status has changed and send them them a welcome message.""" - if msg.get("t") == "GUILD_MEMBER_UPDATE": - user_id = int(msg["d"]["user"]["id"]) - - if msg["d"].get("pending") is False: - if user_id in self.pending_members: - self.pending_members.remove(user_id) - if member := self.bot.get_guild(constants.Guild.id).get_member(user_id): - await safe_dm(member.send(ALTERNATE_VERIFIED_MESSAGE)) - @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Check if we need to send a verification DM to a gated user.""" - before_roles = [role.id for role in before.roles] - after_roles = [role.id for role in after.roles] - - if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: - if await self.member_gating_cache.pop(after.id): - try: - # If the member has not received a DM from our !accept command - # and has gone through the alternate gating system we should send - # our alternate welcome DM which includes info such as our welcome - # video. - await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) - except discord.HTTPException: - log.exception("DM dispatch failed on unexpected error code") + + if before.pending is True and after.pending is False: + try: + # If the member has not received a DM from our !accept command + # and has gone through the alternate gating system we should send + # our alternate welcome DM which includes info such as our welcome + # video. + await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) + except discord.HTTPException: + log.exception("DM dispatch failed on unexpected error code") @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From fe7f7ad3e54a99f1861c20c9afa50c88bad4d7f3 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 02:47:51 +0000 Subject: Lock Pipfile --- Pipfile.lock | 470 ++++++++++++++++++++++++++++++++++------------------------- 1 file changed, 269 insertions(+), 201 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 541db1627..c99a1d07d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3ccb368599709d2970f839fc3721cfeebcd5a2700fed7231b2ce38a080828325" + "sha256": "1a759ddc72f37c3861b988fc99013690188e7c3e053eadc346d08054e912ec10" }, "pipfile-spec": 6, "requires": { @@ -34,22 +34,46 @@ }, "aiohttp": { "hashes": [ - "sha256:1a4160579ffbc1b69e88cb6ca8bb0fbd4947dfcbf9fb1e2a4fc4c7a4a986c1fe", - "sha256:206c0ccfcea46e1bddc91162449c20c72f308aebdcef4977420ef329c8fcc599", - "sha256:2ad493de47a8f926386fa6d256832de3095ba285f325db917c7deae0b54a9fc8", - "sha256:319b490a5e2beaf06891f6711856ea10591cfe84fe9f3e71a721aa8f20a0872a", - "sha256:470e4c90da36b601676fe50c49a60d34eb8c6593780930b1aa4eea6f508dfa37", - "sha256:60f4caa3b7f7a477f66ccdd158e06901e1d235d572283906276e3803f6b098f5", - "sha256:66d64486172b032db19ea8522328b19cfb78a3e1e5b62ab6a0567f93f073dea0", - "sha256:687461cd974722110d1763b45c5db4d2cdee8d50f57b00c43c7590d1dd77fc5c", - "sha256:698cd7bc3c7d1b82bb728bae835724a486a8c376647aec336aa21a60113c3645", - "sha256:797456399ffeef73172945708810f3277f794965eb6ec9bd3a0c007c0476be98", - "sha256:a885432d3cabc1287bcf88ea94e1826d3aec57fd5da4a586afae4591b061d40d", - "sha256:c506853ba52e516b264b106321c424d03f3ddef2813246432fa9d1cefd361c81", - "sha256:fb83326d8295e8840e4ba774edf346e87eca78ba8a89c55d2690352842c15ba5" + "sha256:0b795072bb1bf87b8620120a6373a3c61bfcb8da7e5c2377f4bb23ff4f0b62c9", + "sha256:0d438c8ca703b1b714e82ed5b7a4412c82577040dadff479c08405e2a715564f", + "sha256:16a3cb5df5c56f696234ea9e65e227d1ebe9c18aa774d36ff42f532139066a5f", + "sha256:1edfd82a98c5161497bbb111b2b70c0813102ad7e0aa81cbeb34e64c93863005", + "sha256:2406dc1dda01c7f6060ab586e4601f18affb7a6b965c50a8c90ff07569cf782a", + "sha256:2858b2504c8697beb9357be01dc47ef86438cc1cb36ecb6991796d19475faa3e", + "sha256:2a7b7640167ab536c3cb90cfc3977c7094f1c5890d7eeede8b273c175c3910fd", + "sha256:3228b7a51e3ed533f5472f54f70fd0b0a64c48dc1649a0f0e809bec312934d7a", + "sha256:328b552513d4f95b0a2eea4c8573e112866107227661834652a8984766aa7656", + "sha256:39f4b0a6ae22a1c567cb0630c30dd082481f95c13ca528dc501a7766b9c718c0", + "sha256:3b0036c978cbcc4a4512278e98e3e6d9e6b834dc973206162eddf98b586ef1c6", + "sha256:3ea8c252d8df5e9166bcf3d9edced2af132f4ead8ac422eac723c5781063709a", + "sha256:41608c0acbe0899c852281978492f9ce2c6fbfaf60aff0cefc54a7c4516b822c", + "sha256:59d11674964b74a81b149d4ceaff2b674b3b0e4d0f10f0be1533e49c4a28408b", + "sha256:5e479df4b2d0f8f02133b7e4430098699450e1b2a826438af6bec9a400530957", + "sha256:684850fb1e3e55c9220aad007f8386d8e3e477c4ec9211ae54d968ecdca8c6f9", + "sha256:6ccc43d68b81c424e46192a778f97da94ee0630337c9bbe5b2ecc9b0c1c59001", + "sha256:6d42debaf55450643146fabe4b6817bb2a55b23698b0434107e892a43117285e", + "sha256:710376bf67d8ff4500a31d0c207b8941ff4fba5de6890a701d71680474fe2a60", + "sha256:756ae7efddd68d4ea7d89c636b703e14a0c686688d42f588b90778a3c2fc0564", + "sha256:77149002d9386fae303a4a162e6bce75cc2161347ad2ba06c2f0182561875d45", + "sha256:78e2f18a82b88cbc37d22365cf8d2b879a492faedb3f2975adb4ed8dfe994d3a", + "sha256:7d9b42127a6c0bdcc25c3dcf252bb3ddc70454fac593b1b6933ae091396deb13", + "sha256:8389d6044ee4e2037dca83e3f6994738550f6ee8cfb746762283fad9b932868f", + "sha256:9c1a81af067e72261c9cbe33ea792893e83bc6aa987bfbd6fdc1e5e7b22777c4", + "sha256:c1e0920909d916d3375c7a1fdb0b1c78e46170e8bb42792312b6eb6676b2f87f", + "sha256:c68fdf21c6f3573ae19c7ee65f9ff185649a060c9a06535e9c3a0ee0bbac9235", + "sha256:c733ef3bdcfe52a1a75564389bad4064352274036e7e234730526d155f04d914", + "sha256:c9c58b0b84055d8bc27b7df5a9d141df4ee6ff59821f922dd73155861282f6a3", + "sha256:d03abec50df423b026a5aa09656bd9d37f1e6a49271f123f31f9b8aed5dc3ea3", + "sha256:d2cfac21e31e841d60dc28c0ec7d4ec47a35c608cb8906435d47ef83ffb22150", + "sha256:dcc119db14757b0c7bce64042158307b9b1c76471e655751a61b57f5a0e4d78e", + "sha256:df3a7b258cc230a65245167a202dd07320a5af05f3d41da1488ba0fa05bc9347", + "sha256:df48a623c58180874d7407b4d9ec06a19b84ed47f60a3884345b1a5099c1818b", + "sha256:e1b95972a0ae3f248a899cdbac92ba2e01d731225f566569311043ce2226f5e7", + "sha256:f326b3c1bbfda5b9308252ee0dcb30b612ee92b0e105d4abec70335fab5b1245", + "sha256:f411cb22115cb15452d099fec0ee636b06cf81bfb40ed9c02d30c8dc2bc2e3d1" ], "index": "pypi", - "version": "==3.6.3" + "version": "==3.7.3" }, "aioping": { "hashes": [ @@ -129,51 +153,51 @@ }, "certifi": { "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.11.8" + "version": "==2020.12.5" }, "cffi": { "hashes": [ - "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", - "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", - "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", - "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", - "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", - "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", - "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", - "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", - "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", - "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", - "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", - "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", - "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", - "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", - "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", - "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", - "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", - "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", - "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", - "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", - "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", - "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", - "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", - "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", - "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", - "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", - "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", - "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", - "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", - "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", - "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", - "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", - "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", - "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", - "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", - "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" - ], - "version": "==1.14.3" + "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e", + "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", + "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", + "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", + "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", + "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", + "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", + "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", + "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", + "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698", + "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2", + "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", + "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", + "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009", + "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03", + "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", + "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", + "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53", + "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35", + "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", + "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", + "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", + "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", + "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", + "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", + "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", + "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", + "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", + "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", + "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", + "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", + "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", + "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", + "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", + "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", + "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f" + ], + "version": "==1.14.4" }, "chardet": { "hashes": [ @@ -192,11 +216,11 @@ }, "coloredlogs": { "hashes": [ - "sha256:346f58aad6afd48444c2468618623638dadab76e4e70d5e10822676f2d32226a", - "sha256:a1fab193d2053aa6c0a97608c4342d031f1f93a3d1218432c59322441d31a505" + "sha256:7ef1a7219870c7f02c218a2f2877ce68f2f8e087bb3a55bd6fbaa2a4362b4d52", + "sha256:e244a892f9d97ffd2c60f15bf1d2582ef7f9ac0f848d132249004184785702b3" ], "index": "pypi", - "version": "==14.0" + "version": "==14.3" }, "deepdiff": { "hashes": [ @@ -206,13 +230,9 @@ "index": "pypi", "version": "==4.3.2" }, - "discord.py": { - "hashes": [ - "sha256:2367359e31f6527f8a936751fc20b09d7495dd6a76b28c8fb13d4ca6c55b7563", - "sha256:def00dc50cf36d21346d71bc89f0cad8f18f9a3522978dc18c7796287d47de8b" - ], - "index": "pypi", - "version": "==1.5.1" + "discord-py": { + "git": "https://github.com/Rapptz/discord.py.git", + "ref": "93f102ca907af6722ee03638766afd53dfe93a7f" }, "docutils": { "hashes": [ @@ -231,10 +251,10 @@ }, "fakeredis": { "hashes": [ - "sha256:8070b7fce16f828beaef2c757a4354af91698685d5232404f1aeeb233529c7a5", - "sha256:f8c8ea764d7b6fd801e7f5486e3edd32ca991d506186f1923a01fc072e33c271" + "sha256:01cb47d2286825a171fb49c0e445b1fa9307087e07cbb3d027ea10dbff108b6a", + "sha256:2c6041cf0225889bc403f3949838b2c53470a95a9e2d4272422937786f5f8f73" ], - "version": "==1.4.4" + "version": "==1.4.5" }, "feedparser": { "hashes": [ @@ -307,11 +327,11 @@ }, "humanfriendly": { "hashes": [ - "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12", - "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080" + "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d", + "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==8.2" + "version": "==9.1" }, "idna": { "hashes": [ @@ -339,54 +359,54 @@ }, "lxml": { "hashes": [ - "sha256:098fb713b31050463751dcc694878e1d39f316b86366fb9fe3fbbe5396ac9fab", - "sha256:0e89f5d422988c65e6936e4ec0fe54d6f73f3128c80eb7ecc3b87f595523607b", - "sha256:189ad47203e846a7a4951c17694d845b6ade7917c47c64b29b86526eefc3adf5", - "sha256:1d87936cb5801c557f3e981c9c193861264c01209cb3ad0964a16310ca1b3301", - "sha256:211b3bcf5da70c2d4b84d09232534ad1d78320762e2c59dedc73bf01cb1fc45b", - "sha256:2358809cc64394617f2719147a58ae26dac9e21bae772b45cfb80baa26bfca5d", - "sha256:23c83112b4dada0b75789d73f949dbb4e8f29a0a3511647024a398ebd023347b", - "sha256:24e811118aab6abe3ce23ff0d7d38932329c513f9cef849d3ee88b0f848f2aa9", - "sha256:2d5896ddf5389560257bbe89317ca7bcb4e54a02b53a3e572e1ce4226512b51b", - "sha256:2d6571c48328be4304aee031d2d5046cbc8aed5740c654575613c5a4f5a11311", - "sha256:2e311a10f3e85250910a615fe194839a04a0f6bc4e8e5bb5cac221344e3a7891", - "sha256:302160eb6e9764168e01d8c9ec6becddeb87776e81d3fcb0d97954dd51d48e0a", - "sha256:3a7a380bfecc551cfd67d6e8ad9faa91289173bdf12e9cfafbd2bdec0d7b1ec1", - "sha256:3d9b2b72eb0dbbdb0e276403873ecfae870599c83ba22cadff2db58541e72856", - "sha256:475325e037fdf068e0c2140b818518cf6bc4aa72435c407a798b2db9f8e90810", - "sha256:4b7572145054330c8e324a72d808c8c8fbe12be33368db28c39a255ad5f7fb51", - "sha256:4fff34721b628cce9eb4538cf9a73d02e0f3da4f35a515773cce6f5fe413b360", - "sha256:56eff8c6fb7bc4bcca395fdff494c52712b7a57486e4fbde34c31bb9da4c6cc4", - "sha256:573b2f5496c7e9f4985de70b9bbb4719ffd293d5565513e04ac20e42e6e5583f", - "sha256:7ecaef52fd9b9535ae5f01a1dd2651f6608e4ec9dc136fc4dfe7ebe3c3ddb230", - "sha256:803a80d72d1f693aa448566be46ffd70882d1ad8fc689a2e22afe63035eb998a", - "sha256:8862d1c2c020cb7a03b421a9a7b4fe046a208db30994fc8ff68c627a7915987f", - "sha256:9b06690224258db5cd39a84e993882a6874676f5de582da57f3df3a82ead9174", - "sha256:a71400b90b3599eb7bf241f947932e18a066907bf84617d80817998cee81e4bf", - "sha256:bb252f802f91f59767dcc559744e91efa9df532240a502befd874b54571417bd", - "sha256:be1ebf9cc25ab5399501c9046a7dcdaa9e911802ed0e12b7d620cd4bbf0518b3", - "sha256:be7c65e34d1b50ab7093b90427cbc488260e4b3a38ef2435d65b62e9fa3d798a", - "sha256:c0dac835c1a22621ffa5e5f999d57359c790c52bbd1c687fe514ae6924f65ef5", - "sha256:c152b2e93b639d1f36ec5a8ca24cde4a8eefb2b6b83668fcd8e83a67badcb367", - "sha256:d182eada8ea0de61a45a526aa0ae4bcd222f9673424e65315c35820291ff299c", - "sha256:d18331ea905a41ae71596502bd4c9a2998902328bbabd29e3d0f5f8569fabad1", - "sha256:d20d32cbb31d731def4b1502294ca2ee99f9249b63bc80e03e67e8f8e126dea8", - "sha256:d4ad7fd3269281cb471ad6c7bafca372e69789540d16e3755dd717e9e5c9d82f", - "sha256:d6f8c23f65a4bfe4300b85f1f40f6c32569822d08901db3b6454ab785d9117cc", - "sha256:d84d741c6e35c9f3e7406cb7c4c2e08474c2a6441d59322a00dcae65aac6315d", - "sha256:e65c221b2115a91035b55a593b6eb94aa1206fa3ab374f47c6dc10d364583ff9", - "sha256:f98b6f256be6cec8dd308a8563976ddaff0bdc18b730720f6f4bee927ffe926f" + "sha256:0448576c148c129594d890265b1a83b9cd76fd1f0a6a04620753d9a6bcfd0a4d", + "sha256:127f76864468d6630e1b453d3ffbbd04b024c674f55cf0a30dc2595137892d37", + "sha256:1471cee35eba321827d7d53d104e7b8c593ea3ad376aa2df89533ce8e1b24a01", + "sha256:2363c35637d2d9d6f26f60a208819e7eafc4305ce39dc1d5005eccc4593331c2", + "sha256:2e5cc908fe43fe1aa299e58046ad66981131a66aea3129aac7770c37f590a644", + "sha256:2e6fd1b8acd005bd71e6c94f30c055594bbd0aa02ef51a22bbfa961ab63b2d75", + "sha256:366cb750140f221523fa062d641393092813b81e15d0e25d9f7c6025f910ee80", + "sha256:42ebca24ba2a21065fb546f3e6bd0c58c3fe9ac298f3a320147029a4850f51a2", + "sha256:4e751e77006da34643ab782e4a5cc21ea7b755551db202bc4d3a423b307db780", + "sha256:4fb85c447e288df535b17ebdebf0ec1cf3a3f1a8eba7e79169f4f37af43c6b98", + "sha256:50c348995b47b5a4e330362cf39fc503b4a43b14a91c34c83b955e1805c8e308", + "sha256:535332fe9d00c3cd455bd3dd7d4bacab86e2d564bdf7606079160fa6251caacf", + "sha256:535f067002b0fd1a4e5296a8f1bf88193080ff992a195e66964ef2a6cfec5388", + "sha256:5be4a2e212bb6aa045e37f7d48e3e1e4b6fd259882ed5a00786f82e8c37ce77d", + "sha256:60a20bfc3bd234d54d49c388950195d23a5583d4108e1a1d47c9eef8d8c042b3", + "sha256:648914abafe67f11be7d93c1a546068f8eff3c5fa938e1f94509e4a5d682b2d8", + "sha256:681d75e1a38a69f1e64ab82fe4b1ed3fd758717bed735fb9aeaa124143f051af", + "sha256:68a5d77e440df94011214b7db907ec8f19e439507a70c958f750c18d88f995d2", + "sha256:69a63f83e88138ab7642d8f61418cf3180a4d8cd13995df87725cb8b893e950e", + "sha256:6e4183800f16f3679076dfa8abf2db3083919d7e30764a069fb66b2b9eff9939", + "sha256:6fd8d5903c2e53f49e99359b063df27fdf7acb89a52b6a12494208bf61345a03", + "sha256:791394449e98243839fa822a637177dd42a95f4883ad3dec2a0ce6ac99fb0a9d", + "sha256:7a7669ff50f41225ca5d6ee0a1ec8413f3a0d8aa2b109f86d540887b7ec0d72a", + "sha256:7e9eac1e526386df7c70ef253b792a0a12dd86d833b1d329e038c7a235dfceb5", + "sha256:7ee8af0b9f7de635c61cdd5b8534b76c52cd03536f29f51151b377f76e214a1a", + "sha256:8246f30ca34dc712ab07e51dc34fea883c00b7ccb0e614651e49da2c49a30711", + "sha256:8c88b599e226994ad4db29d93bc149aa1aff3dc3a4355dd5757569ba78632bdf", + "sha256:923963e989ffbceaa210ac37afc9b906acebe945d2723e9679b643513837b089", + "sha256:94d55bd03d8671686e3f012577d9caa5421a07286dd351dfef64791cf7c6c505", + "sha256:97db258793d193c7b62d4e2586c6ed98d51086e93f9a3af2b2034af01450a74b", + "sha256:a9d6bc8642e2c67db33f1247a77c53476f3a166e09067c0474facb045756087f", + "sha256:cd11c7e8d21af997ee8079037fff88f16fda188a9776eb4b81c7e4c9c0a7d7fc", + "sha256:d8d3d4713f0c28bdc6c806a278d998546e8efc3498949e3ace6e117462ac0a5e", + "sha256:e0bfe9bb028974a481410432dbe1b182e8191d5d40382e5b8ff39cdd2e5c5931", + "sha256:f4822c0660c3754f1a41a655e37cb4dbbc9be3d35b125a37fab6f82d47674ebc", + "sha256:f83d281bb2a6217cd806f4cf0ddded436790e66f393e124dfe9731f6b3fb9afe", + "sha256:fc37870d6716b137e80d19241d0e2cff7a7643b925dfa49b4c8ebd1295eb506e" ], "index": "pypi", - "version": "==4.6.1" + "version": "==4.6.2" }, "markdownify": { "hashes": [ - "sha256:30be8340724e706c9e811c27fe8c1542cf74a15b46827924fff5c54b40dd9b0d", - "sha256:a69588194fd76634f0139d6801b820fd652dc5eeba9530e90d323dfdc0155252" + "sha256:901c6106533f4a0b79cfe7c700c4df6b15cf782aa6236fd13161bf2608e2c591", + "sha256:f40874e3113a170697f0e74ea7aeee2d66eb9973201a5fbcc68ef8ce6bfbcf8a" ], "index": "pypi", - "version": "==0.5.3" + "version": "==0.6.0" }, "markupsafe": { "hashes": [ @@ -437,26 +457,46 @@ }, "multidict": { "hashes": [ - "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a", - "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000", - "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2", - "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507", - "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5", - "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7", - "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d", - "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463", - "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19", - "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3", - "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b", - "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c", - "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87", - "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7", - "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430", - "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", - "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" + "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a", + "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93", + "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632", + "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656", + "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79", + "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7", + "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d", + "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5", + "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224", + "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26", + "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea", + "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348", + "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6", + "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76", + "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1", + "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f", + "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952", + "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a", + "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37", + "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9", + "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359", + "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8", + "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da", + "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3", + "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d", + "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf", + "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841", + "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d", + "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93", + "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f", + "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647", + "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635", + "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456", + "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda", + "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5", + "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", + "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" ], - "markers": "python_version >= '3.5'", - "version": "==4.7.6" + "markers": "python_version >= '3.6'", + "version": "==5.1.0" }, "ordered-set": { "hashes": [ @@ -467,11 +507,11 @@ }, "packaging": { "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858", + "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.4" + "version": "==20.8" }, "pamqp": { "hashes": [ @@ -524,11 +564,11 @@ }, "pygments": { "hashes": [ - "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0", - "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773" + "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716", + "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08" ], "markers": "python_version >= '3.5'", - "version": "==2.7.2" + "version": "==2.7.3" }, "pyparsing": { "hashes": [ @@ -555,18 +595,18 @@ }, "pyyaml": { "hashes": [ - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], "index": "pypi", @@ -582,19 +622,19 @@ }, "requests": { "hashes": [ - "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", - "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], "index": "pypi", - "version": "==2.25.0" + "version": "==2.25.1" }, "sentry-sdk": { "hashes": [ - "sha256:1052f0ed084e532f66cb3e4ba617960d820152aee8b93fc6c05bd53861768c1c", - "sha256:4c42910a55a6b1fe694d5e4790d5188d105d77b5a6346c1c64cbea8c06c0e8b7" + "sha256:0a711ec952441c2ec89b8f5d226c33bc697914f46e876b44a4edd3e7864cf4d0", + "sha256:737a094e49a529dd0fdcaafa9e97cf7c3d5eb964bd229821d640bc77f3502b3f" ], "index": "pypi", - "version": "==0.19.4" + "version": "==0.19.5" }, "six": { "hashes": [ @@ -620,11 +660,11 @@ }, "soupsieve": { "hashes": [ - "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", - "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" + "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851", + "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e" ], "markers": "python_version >= '3.0'", - "version": "==2.0.1" + "version": "==2.1" }, "sphinx": { "hashes": [ @@ -690,6 +730,14 @@ "index": "pypi", "version": "==3.3.0" }, + "typing-extensions": { + "hashes": [ + "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", + "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", + "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + ], + "version": "==3.7.4.3" + }, "urllib3": { "hashes": [ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", @@ -700,26 +748,46 @@ }, "yarl": { "hashes": [ - "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", - "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", - "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", - "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", - "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", - "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", - "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", - "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", - "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", - "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", - "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", - "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", - "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", - "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", - "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", - "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", - "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" + "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e", + "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434", + "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366", + "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3", + "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec", + "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959", + "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e", + "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c", + "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6", + "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a", + "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6", + "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424", + "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e", + "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f", + "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50", + "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2", + "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc", + "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4", + "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970", + "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10", + "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0", + "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406", + "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896", + "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643", + "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721", + "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478", + "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724", + "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e", + "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8", + "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96", + "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25", + "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76", + "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2", + "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2", + "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c", + "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", + "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" ], - "markers": "python_version >= '3.5'", - "version": "==1.5.1" + "markers": "python_version >= '3.6'", + "version": "==1.6.3" } }, "develop": { @@ -740,10 +808,10 @@ }, "certifi": { "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.11.8" + "version": "==2020.12.5" }, "cfgv": { "hashes": [ @@ -846,11 +914,11 @@ }, "flake8-bugbear": { "hashes": [ - "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63", - "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162" + "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538", + "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703" ], "index": "pypi", - "version": "==20.1.4" + "version": "==20.11.1" }, "flake8-docstrings": { "hashes": [ @@ -885,11 +953,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd", - "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3" + "sha256:2821c79e83c656652d5ac6d3650ca370ed3c9752edb5383b1d50dee5bd8a383f", + "sha256:6cdd51e0d2f221e43ff4d5ac6331b1d95bbf4a5408906e36da913acaaed890e0" ], "index": "pypi", - "version": "==4.1.0" + "version": "==4.2.0" }, "flake8-todo": { "hashes": [ @@ -900,11 +968,11 @@ }, "identify": { "hashes": [ - "sha256:5dd84ac64a9a115b8e0b27d1756b244b882ad264c3c423f42af8235a6e71ca12", - "sha256:c9504ba6a043ee2db0a9d69e43246bc138034895f6338d5aed1b41e4a73b1513" + "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5", + "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.9" + "version": "==1.5.10" }, "idna": { "hashes": [ @@ -938,11 +1006,11 @@ }, "pre-commit": { "hashes": [ - "sha256:22e6aa3bd571debb01eb7d34483f11c01b65237be4eebbf30c3d4fb65762d315", - "sha256:905ebc9b534b991baec87e934431f2d0606ba27f2b90f7f652985f5a5b8b6ae6" + "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0", + "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4" ], "index": "pypi", - "version": "==2.8.2" + "version": "==2.9.3" }, "pycodestyle": { "hashes": [ @@ -970,18 +1038,18 @@ }, "pyyaml": { "hashes": [ - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], "index": "pypi", @@ -989,11 +1057,11 @@ }, "requests": { "hashes": [ - "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", - "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], "index": "pypi", - "version": "==2.25.0" + "version": "==2.25.1" }, "six": { "hashes": [ @@ -1028,11 +1096,11 @@ }, "virtualenv": { "hashes": [ - "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2", - "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380" + "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c", + "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.1.0" + "version": "==20.2.2" } } } -- cgit v1.2.3 From 17e1ca32651ca9c16d94afc9987fecb80a2ea176 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 02:49:56 +0000 Subject: Fix linting errors --- bot/exts/moderation/verification.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index ad05888df..2b298950c 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -583,7 +583,6 @@ class Verification(Cog): @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Check if we need to send a verification DM to a gated user.""" - if before.pending is True and after.pending is False: try: # If the member has not received a DM from our !accept command -- cgit v1.2.3 From d6e842f76e4f65cbd5d131cc341c8059a728393c Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 02:54:44 +0000 Subject: Remove member_gating_cache RedisDict --- bot/exts/moderation/verification.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 2b298950c..6239cf522 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -174,9 +174,6 @@ class Verification(Cog): # ] task_cache = RedisCache() - # Create a cache for storing recipients of the alternate welcome DM. - member_gating_cache = RedisCache() - def __init__(self, bot: Bot) -> None: """Start internal tasks.""" self.bot = bot -- cgit v1.2.3 From 539aaa28eae1204e90380167560a2ce57c4aea6e Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 03:05:06 +0000 Subject: Update discord.py name in Pipfile --- Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pipfile b/Pipfile index 02b60b681..1f866e0ee 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord-py = {git = "https://github.com/Rapptz/discord.py.git", ref = "93f102ca907af6722ee03638766afd53dfe93a7f"} +"discord.py" = {git = "https://github.com/Rapptz/discord.py.git", ref = "93f102ca907af6722ee03638766afd53dfe93a7f"} feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" -- cgit v1.2.3 From ee8c6d1f40a38a8f24c89e3103048bfe10bd1709 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 03:20:41 +0000 Subject: relock lockfile --- Pipfile.lock | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Pipfile.lock b/Pipfile.lock index c99a1d07d..aad0069db 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1a759ddc72f37c3861b988fc99013690188e7c3e053eadc346d08054e912ec10" + "sha256": "3621b325f6395169e53a68d2f740232e10430fbca0150d936efd01a62d844b2c" }, "pipfile-spec": 6, "requires": { @@ -234,6 +234,10 @@ "git": "https://github.com/Rapptz/discord.py.git", "ref": "93f102ca907af6722ee03638766afd53dfe93a7f" }, + "discord.py": { + "git": "https://github.com/Rapptz/discord.py.git", + "ref": "93f102ca907af6722ee03638766afd53dfe93a7f" + }, "docutils": { "hashes": [ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", -- cgit v1.2.3 From fa60dc9b7bbbc8bdf06afc46c672015598c5df66 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 03:55:14 +0000 Subject: Remove usage of joined_at metricity API item --- bot/constants.py | 2 +- bot/exts/info/information.py | 19 +++++-------------- bot/exts/moderation/voice_gate.py | 10 ++-------- config-default.yml | 2 +- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 08ae0d52f..c4bb6b2d6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -606,7 +606,7 @@ class Verification(metaclass=YAMLGetter): class VoiceGate(metaclass=YAMLGetter): section = "voice_gate" - minimum_days_verified: int + minimum_days_member: int minimum_messages: int bot_message_delete_delay: int minimum_activity_blocks: int diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 187950689..5450ff377 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -225,12 +225,12 @@ class Information(Cog): if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): badges.append(emoji) - verified_at, activity = await self.user_verification_and_messages(user) + activity = await self.user_messages(user) if on_server: joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - membership = {"Joined": joined, "Verified": verified_at or "False", "Roles": roles or None} + membership = {"Joined": joined, "Verified": user.pending, "Roles": roles or None} if not is_mod_channel(ctx.channel): membership.pop("Verified") @@ -360,30 +360,21 @@ class Information(Cog): return "Nominations", "\n".join(output) - async def user_verification_and_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: + async def user_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: """ - Gets the time of verification and amount of messages for `member`. + Gets the amount of messages for `member`. Fetches information from the metricity database that's hosted by the site. If the database returns a code besides a 404, then many parts of the bot are broken including this one. """ activity_output = [] - verified_at = False try: user_activity = await self.bot.api_client.get(f"bot/users/{user.id}/metricity_data") except ResponseCodeError as e: if e.status == 404: activity_output = "No activity" - else: - try: - if (verified_at := user_activity["verified_at"]) is not None: - verified_at = time_since(parser.isoparse(verified_at), max_units=3) - except ValueError: - log.warning(f"Could not parse ISO string correctly for user {user.id} verification date.") - verified_at = None - activity_output.append(user_activity["total_messages"] or "No messages") activity_output.append(user_activity["activity_blocks"] or "No activity") @@ -391,7 +382,7 @@ class Information(Cog): f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) ) - return verified_at, ("Activity", activity_output) + return ("Activity", activity_output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 4d48d2c1b..b8f37adf2 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -29,7 +29,7 @@ FAILED_MESSAGE = ( ) MESSAGE_FIELD_MAP = { - "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days", + "joined_at": f"have been on the server for less than {GateConf.minimum_days_member} days", "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", "activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks", @@ -149,14 +149,8 @@ class VoiceGate(Cog): await ctx.author.send(embed=embed) return - # Pre-parse this for better code style - if data["verified_at"] is not None: - data["verified_at"] = parser.isoparse(data["verified_at"]) - else: - data["verified_at"] = datetime.utcnow() - timedelta(days=3) - checks = { - "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), + "joined_at": ctx.author.joined_at > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), "total_messages": data["total_messages"] < GateConf.minimum_messages, "voice_banned": data["voice_banned"], "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks diff --git a/config-default.yml b/config-default.yml index 006743342..3f3f66962 100644 --- a/config-default.yml +++ b/config-default.yml @@ -526,7 +526,7 @@ verification: voice_gate: - minimum_days_verified: 3 # How many days the user must have been verified for + minimum_days_member: 3 # How many days the user must have been a member for minimum_messages: 50 # How many messages a user must have to be eligible for voice bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate minimum_activity_blocks: 3 # Number of 10 minute blocks during which a user must have been active -- cgit v1.2.3 From 3be657effd8ad6d5f54a536c12f5b82ea91fd571 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 03:56:49 +0000 Subject: Remove unused dateutil imports --- bot/exts/info/information.py | 1 - bot/exts/moderation/voice_gate.py | 1 - 2 files changed, 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 5450ff377..15f96db3a 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,7 +6,6 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from dateutil import parser from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index b8f37adf2..cc0ac0118 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -5,7 +5,6 @@ from datetime import datetime, timedelta import discord from async_rediscache import RedisCache -from dateutil import parser from discord import Colour, Member, VoiceState from discord.ext.commands import Cog, Context, command -- cgit v1.2.3 From 688908d1d996708525b9125a20e7c72b4413b252 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 04:00:59 +0000 Subject: Fix pending tests --- bot/exts/info/information.py | 4 ++-- tests/bot/exts/info/test_information.py | 2 +- tests/helpers.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 15f96db3a..2057876e4 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -229,9 +229,9 @@ class Information(Cog): if on_server: joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - membership = {"Joined": joined, "Verified": user.pending, "Roles": roles or None} + membership = {"Joined": joined, "Pending": user.pending, "Roles": roles or None} if not is_mod_channel(ctx.channel): - membership.pop("Verified") + membership.pop("Pending") membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()])) else: diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 254b0a867..043cce8de 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -355,7 +355,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" Joined: {"1 year ago"} - Verified: {"False"} + Pending: {"False"} Roles: &Moderators """).strip(), embed.fields[1].value diff --git a/tests/helpers.py b/tests/helpers.py index 870f66197..496363ae3 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -230,7 +230,7 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin spec_set = member_instance def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: - default_kwargs = {'name': 'member', 'id': next(self.discord_id), 'bot': False} + default_kwargs = {'name': 'member', 'id': next(self.discord_id), 'bot': False, "pending": False} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.roles = [MockRole(name="@everyone", position=1, id=0)] -- cgit v1.2.3 From 620fa3fb82d1de8424a699a1b6676eeddd04eba2 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Fri, 18 Dec 2020 23:06:44 -0500 Subject: Downgrade `markdownify` from 0.6.0 to 0.5.3. 0.6.0 brought breaking changes to markdownify, so we'll downgrade. --- Pipfile | 2 +- Pipfile.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Pipfile b/Pipfile index 1f866e0ee..23422869d 100644 --- a/Pipfile +++ b/Pipfile @@ -18,7 +18,7 @@ deepdiff = "~=4.0" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" -markdownify = "~=0.4" +markdownify = "==0.5.3" more_itertools = "~=8.2" python-dateutil = "~=2.8" pyyaml = "~=5.1" diff --git a/Pipfile.lock b/Pipfile.lock index aad0069db..ca72fb0f3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3621b325f6395169e53a68d2f740232e10430fbca0150d936efd01a62d844b2c" + "sha256": "bfaf61339c0cebb10d76f2e14ff967030008b8512b5f0b4c23c9e8997aab4552" }, "pipfile-spec": 6, "requires": { @@ -406,11 +406,11 @@ }, "markdownify": { "hashes": [ - "sha256:901c6106533f4a0b79cfe7c700c4df6b15cf782aa6236fd13161bf2608e2c591", - "sha256:f40874e3113a170697f0e74ea7aeee2d66eb9973201a5fbcc68ef8ce6bfbcf8a" + "sha256:30be8340724e706c9e811c27fe8c1542cf74a15b46827924fff5c54b40dd9b0d", + "sha256:a69588194fd76634f0139d6801b820fd652dc5eeba9530e90d323dfdc0155252" ], "index": "pypi", - "version": "==0.6.0" + "version": "==0.5.3" }, "markupsafe": { "hashes": [ -- cgit v1.2.3 From 975562236d356ca43f8ed037d7048eb410abfe26 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 04:10:26 +0000 Subject: Fix invalid config name in voice gate --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index cc0ac0118..0cbce6a51 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -149,7 +149,7 @@ class VoiceGate(Cog): return checks = { - "joined_at": ctx.author.joined_at > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), + "joined_at": ctx.author.joined_at > datetime.utcnow() - timedelta(days=GateConf.minimum_days_member), "total_messages": data["total_messages"] < GateConf.minimum_messages, "voice_banned": data["voice_banned"], "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks -- cgit v1.2.3 From c4545d8f5206e296bcecfd87236be57fbc91b778 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 04:21:52 +0000 Subject: Fix silence command to use guild default role --- bot/exts/moderation/silence.py | 14 +++++++------- tests/bot/exts/moderation/test_silence.py | 9 --------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index e6712b3b6..a942d5294 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -93,7 +93,7 @@ class Silence(commands.Cog): await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) - self._verified_role = guild.get_role(Roles.verified) + self._everyone_role = guild.default_role self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) await self._reschedule() @@ -142,7 +142,7 @@ class Silence(commands.Cog): async def _unsilence_wrapper(self, channel: TextChannel) -> None: """Unsilence `channel` and send a success/failure message.""" if not await self._unsilence(channel): - overwrite = channel.overwrites_for(self._verified_role) + overwrite = channel.overwrites_for(self._everyone_role) if overwrite.send_messages is False or overwrite.add_reactions is False: await channel.send(MSG_UNSILENCE_MANUAL) else: @@ -152,14 +152,14 @@ class Silence(commands.Cog): async def _set_silence_overwrites(self, channel: TextChannel) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" - overwrite = channel.overwrites_for(self._verified_role) + overwrite = channel.overwrites_for(self._everyone_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): return False overwrite.update(send_messages=False, add_reactions=False) - await channel.set_permissions(self._verified_role, overwrite=overwrite) + await channel.set_permissions(self._everyone_role, overwrite=overwrite) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) return True @@ -188,14 +188,14 @@ class Silence(commands.Cog): log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False - overwrite = channel.overwrites_for(self._verified_role) + overwrite = channel.overwrites_for(self._everyone_role) if prev_overwrites is None: log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") overwrite.update(send_messages=None, add_reactions=None) else: overwrite.update(**json.loads(prev_overwrites)) - await channel.set_permissions(self._verified_role, overwrite=overwrite) + await channel.set_permissions(self._everyone_role, overwrite=overwrite) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.scheduler.cancel(channel.id) @@ -207,7 +207,7 @@ class Silence(commands.Cog): await self._mod_alerts_channel.send( f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " - f"overwrites for {self._verified_role.mention} are at their desired values." + f"overwrites for {self._everyone_role.mention} are at their desired values." ) return True diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 104293d8e..5c89a2f2a 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -116,15 +116,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_async_init_got_role(self): - """Got `Roles.verified` role from guild.""" - guild = self.bot.get_guild() - guild.get_role.side_effect = lambda id_: Mock(id=id_) - - await self.cog._async_init() - self.assertEqual(self.cog._verified_role.id, Roles.verified) - @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_got_channels(self): """Got channels from bot.""" -- cgit v1.2.3 From d76a1a676fbdb1f79814ae50b4a28ebc746bccf6 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 04:22:07 +0000 Subject: kaizen: remove role check from bot account commands --- bot/exts/utils/bot.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 69d623581..03c98677f 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -17,13 +17,11 @@ class BotCog(Cog, name="Bot"): self.bot = bot @group(invoke_without_command=True, name="bot", hidden=True) - @has_any_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" await ctx.send_help(ctx.command) @botinfo_group.command(name='about', aliases=('info',), hidden=True) - @has_any_role(Roles.verified) async def about_command(self, ctx: Context) -> None: """Get information about the bot.""" embed = Embed( -- cgit v1.2.3 From bc98110449933086fdfee7d949074caf9c4f0553 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 04:23:21 +0000 Subject: Remove unused import --- bot/exts/utils/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 03c98677f..a4c828f95 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -5,7 +5,7 @@ from discord import Embed, TextChannel from discord.ext.commands import Cog, Context, command, group, has_any_role from bot.bot import Bot -from bot.constants import Guild, MODERATION_ROLES, Roles, URLs +from bot.constants import Guild, MODERATION_ROLES, URLs log = logging.getLogger(__name__) -- cgit v1.2.3 From 9799020de67c9350ee57f9ee3edff348a718cf6b Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 19 Dec 2020 04:27:01 +0000 Subject: Fix silence tests --- tests/bot/exts/moderation/test_silence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 5c89a2f2a..fa5fc9e81 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -293,7 +293,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(self.overwrite.send_messages) self.assertFalse(self.overwrite.add_reactions) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, + self.cog._everyone_role, overwrite=self.overwrite ) @@ -426,7 +426,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's `send_message` and `add_reactions` overwrites were restored.""" await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, + self.cog._everyone_role, overwrite=self.overwrite, ) @@ -440,7 +440,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, + self.cog._everyone_role, overwrite=self.overwrite, ) -- cgit v1.2.3 From 8f425fc4ab1af10c4d601ebe755cdedf2fa2a0fd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 15:33:58 +0200 Subject: Pump Sentry SDK version from 0.14 to 0.19 --- Pipfile | 2 +- Pipfile.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Pipfile b/Pipfile index 23422869d..3ff653749 100644 --- a/Pipfile +++ b/Pipfile @@ -23,7 +23,7 @@ more_itertools = "~=8.2" python-dateutil = "~=2.8" pyyaml = "~=5.1" requests = "~=2.22" -sentry-sdk = "~=0.14" +sentry-sdk = "~=0.19" sphinx = "~=2.2" statsd = "~=3.3" emoji = "~=0.6" diff --git a/Pipfile.lock b/Pipfile.lock index ca72fb0f3..085d3d829 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "bfaf61339c0cebb10d76f2e14ff967030008b8512b5f0b4c23c9e8997aab4552" + "sha256": "1ba637e521c654a23bcc82950e155f5366219eae00bbf809170a371122961a4f" }, "pipfile-spec": 6, "requires": { @@ -957,11 +957,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:2821c79e83c656652d5ac6d3650ca370ed3c9752edb5383b1d50dee5bd8a383f", - "sha256:6cdd51e0d2f221e43ff4d5ac6331b1d95bbf4a5408906e36da913acaaed890e0" + "sha256:52e5f2f987d3d5597538d5941153409ebcab571635835b78f522c7bf03ca23bc", + "sha256:76e36fbbfdc8e3c5017f9a216c2855a298be85bc0631e66777f4e6a07a859dc4" ], "index": "pypi", - "version": "==4.2.0" + "version": "==4.2.1" }, "flake8-todo": { "hashes": [ -- cgit v1.2.3 From 4ebbb5db954fb82a66341cbae13ce6db7641f891 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 15:38:15 +0200 Subject: Create workflow for creating Sentry release --- .github/workflows/sentry_release.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/sentry_release.yml diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml new file mode 100644 index 000000000..b0e6876f8 --- /dev/null +++ b/.github/workflows/sentry_release.yml @@ -0,0 +1,24 @@ +name: Create Sentry release + +on: + push: + branches: + - master + +jobs: + create_sentry_release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@master + + - name: Create a Sentry.io release + uses: tclindner/sentry-releases-action@v1.2.0 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: python-discord + SENTRY_PROJECT: bot + with: + tagName: ${{ github.sha }} + environment: production + releaseNamePrefix: pydis-bot@ -- cgit v1.2.3 From d744dc779cd7c3b128cf28830f54a0dc2aecc574 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 19 Dec 2020 10:45:33 -0800 Subject: APIClient: simplify session creation Making the class-reusable is not worth the added complexity. --- bot/api.py | 42 ++++++------------------------------------ bot/bot.py | 7 ++++--- 2 files changed, 10 insertions(+), 39 deletions(-) diff --git a/bot/api.py b/bot/api.py index 4b8520582..6436c2b8b 100644 --- a/bot/api.py +++ b/bot/api.py @@ -50,52 +50,22 @@ class APIClient: self.session = None self.loop = loop - self._ready = asyncio.Event(loop=loop) - self._creation_task = None - self._default_session_kwargs = kwargs - - self.recreate() + # It has to be instantiated in a task/coroutine to avoid warnings from aiohttp. + self._creation_task = self.loop.create_task(self._create_session(**kwargs)) @staticmethod def _url_for(endpoint: str) -> str: return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" async def _create_session(self, **session_kwargs) -> None: - """ - Create the aiohttp session with `session_kwargs` and set the ready event. - - `session_kwargs` is merged with `_default_session_kwargs` and overwrites its values. - If an open session already exists, it will first be closed. - """ - await self.close() - self.session = aiohttp.ClientSession(**{**self._default_session_kwargs, **session_kwargs}) - self._ready.set() + """Create the aiohttp session with `session_kwargs`.""" + self.session = aiohttp.ClientSession(**session_kwargs) async def close(self) -> None: """Close the aiohttp session and unset the ready event.""" if self.session: await self.session.close() - self._ready.clear() - - def recreate(self, force: bool = False, **session_kwargs) -> None: - """ - Schedule the aiohttp session to be created with `session_kwargs` if it's been closed. - - If `force` is True, the session will be recreated even if an open one exists. If a task to - create the session is pending, it will be cancelled. - - `session_kwargs` is merged with the kwargs given when the `APIClient` was created and - overwrites those default kwargs. - """ - if force or self.session is None or self.session.closed: - if force and self._creation_task: - self._creation_task.cancel() - - # Don't schedule a task if one is already in progress. - if force or self._creation_task is None or self._creation_task.done(): - self._creation_task = self.loop.create_task(self._create_session(**session_kwargs)) - async 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: @@ -108,7 +78,7 @@ class APIClient: async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Send an HTTP request to the site API and return the JSON response.""" - await self._ready.wait() + await self._creation_task async with self.session.request(method.upper(), self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) @@ -132,7 +102,7 @@ class APIClient: async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]: """Site API DELETE.""" - await self._ready.wait() + await self._creation_task async with self.session.delete(self._url_for(endpoint), **kwargs) as resp: if resp.status == 204: diff --git a/bot/bot.py b/bot/bot.py index f71f5d1fb..1715f3ca3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -32,7 +32,7 @@ class Bot(commands.Bot): self.http_session: Optional[aiohttp.ClientSession] = None self.redis_session = redis_session - self.api_client = api.APIClient(loop=self.loop) + self.api_client = None self.filter_list_cache = defaultdict(dict) self._connector = None @@ -112,7 +112,7 @@ class Bot(commands.Bot): ) self.http_session = aiohttp.ClientSession(connector=self._connector) - self.api_client.recreate(force=True, connector=self._connector) + self.api_client = api.APIClient(loop=self.loop, connector=self._connector) # Build the FilterList cache self.loop.create_task(self.cache_filter_list_data()) @@ -194,7 +194,8 @@ class Bot(commands.Bot): """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" await super().close() - await self.api_client.close() + if self.api_client: + await self.api_client.close() if self.http_session: await self.http_session.close() -- cgit v1.2.3 From 2021c78635de4ae5a9f9835944c87cba699b41c0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 19 Dec 2020 10:46:26 -0800 Subject: APIClient: remove obsolete function --- bot/api.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/api.py b/bot/api.py index 6436c2b8b..ce3f2ada3 100644 --- a/bot/api.py +++ b/bot/api.py @@ -110,17 +110,3 @@ class APIClient: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - - -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. - """ - try: - asyncio.get_running_loop() - except RuntimeError: - return False - return True -- cgit v1.2.3 From 598c4a50c0c4f2e0c2139d6c349ee183010dbf78 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 19 Dec 2020 10:59:09 -0800 Subject: Bot: cease support of Bot.clear() Supporting the function means supporting re-use of a closed Bot. However, this functionality is not relied upon by anything nor will it ever be in the foreseeable future. Support of it required scheduling any needed startup coroutines as tasks. This made augmenting the Bot clunky and didn't make it easy to wait for startup coroutines to complete before logging in. --- bot/bot.py | 79 ++++++++++++++++++++++---------------------------------------- 1 file changed, 28 insertions(+), 51 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 1715f3ca3..b01cbea43 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -32,7 +32,7 @@ class Bot(commands.Bot): self.http_session: Optional[aiohttp.ClientSession] = None self.redis_session = redis_session - self.api_client = None + self.api_client: Optional[api.APIClient] = None self.filter_list_cache = defaultdict(dict) self._connector = None @@ -77,46 +77,6 @@ class Bot(commands.Bot): for item in full_cache: self.insert_item_into_filter_list_cache(item) - def _recreate(self) -> None: - """Re-create the connector, aiohttp session, the APIClient and the Redis session.""" - # Use asyncio for DNS resolution instead of threads so threads aren't spammed. - # Doesn't seem to have any state with regards to being closed, so no need to worry? - self._resolver = aiohttp.AsyncResolver() - - # Its __del__ does send a warning but it doesn't always show up for some reason. - if self._connector and not self._connector._closed: - log.warning( - "The previous connector was not closed; it will remain open and be overwritten" - ) - - if self.redis_session.closed: - # If the RedisSession was somehow closed, we try to reconnect it - # here. Normally, this shouldn't happen. - self.loop.create_task(self.redis_session.connect()) - - # Use AF_INET as its socket family to prevent HTTPS related problems both locally - # and in production. - self._connector = aiohttp.TCPConnector( - resolver=self._resolver, - family=socket.AF_INET, - ) - - # Client.login() will call HTTPClient.static_login() which will create a session using - # this connector attribute. - self.http.connector = self._connector - - # Its __del__ does send a warning but it doesn't always show up for some reason. - if self.http_session and not self.http_session.closed: - log.warning( - "The previous session was not closed; it will remain open and be overwritten" - ) - - self.http_session = aiohttp.ClientSession(connector=self._connector) - self.api_client = api.APIClient(loop=self.loop, connector=self._connector) - - # Build the FilterList cache - self.loop.create_task(self.cache_filter_list_data()) - @classmethod def create(cls) -> "Bot": """Create and return an instance of a Bot.""" @@ -180,15 +140,8 @@ class Bot(commands.Bot): return command def clear(self) -> None: - """ - Clears the internal state of the bot and recreates the connector and sessions. - - Will cause a DeprecationWarning if called outside a coroutine. - """ - # Because discord.py recreates the HTTPClient session, may as well follow suit and recreate - # our own stuff here too. - self._recreate() - super().clear() + """Not implemented! Re-instantiate the bot instead of attempting to re-use a closed one.""" + raise NotImplementedError("Re-using a Bot object after closing it is not supported.") async def close(self) -> None: """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" @@ -230,7 +183,31 @@ class Bot(commands.Bot): async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" - self._recreate() + # Use asyncio for DNS resolution instead of threads so threads aren't spammed. + self._resolver = aiohttp.AsyncResolver() + + # Use AF_INET as its socket family to prevent HTTPS related problems both locally + # and in production. + self._connector = aiohttp.TCPConnector( + resolver=self._resolver, + family=socket.AF_INET, + ) + + # Client.login() will call HTTPClient.static_login() which will create a session using + # this connector attribute. + self.http.connector = self._connector + + self.http_session = aiohttp.ClientSession(connector=self._connector) + self.api_client = api.APIClient(loop=self.loop, connector=self._connector) + + if self.redis_session.closed: + # If the RedisSession was somehow closed, we try to reconnect it + # here. Normally, this shouldn't happen. + await self.redis_session.connect() + + # Build the FilterList cache + await self.cache_filter_list_data() + await self.stats.create_socket() await super().login(*args, **kwargs) -- cgit v1.2.3 From c58fbe828781444fc282151c06c2f4fb9057fe59 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 19 Dec 2020 11:29:05 -0800 Subject: APIClient: create the session directly in __init__ The client is already instantiated in a coroutine and aiohttp won't complain. Therefore, scheduling a task to create the session is redundant. --- bot/api.py | 29 +++++++++-------------------- bot/bot.py | 2 +- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/bot/api.py b/bot/api.py index ce3f2ada3..d93f9f2ba 100644 --- a/bot/api.py +++ b/bot/api.py @@ -37,34 +37,27 @@ class APIClient: session: Optional[aiohttp.ClientSession] = None loop: asyncio.AbstractEventLoop = None - def __init__(self, loop: asyncio.AbstractEventLoop, **kwargs): + def __init__(self, **session_kwargs): auth_headers = { 'Authorization': f"Token {Keys.site_api}" } - if 'headers' in kwargs: - kwargs['headers'].update(auth_headers) + if 'headers' in session_kwargs: + session_kwargs['headers'].update(auth_headers) else: - kwargs['headers'] = auth_headers + session_kwargs['headers'] = auth_headers - self.session = None - self.loop = loop - - # It has to be instantiated in a task/coroutine to avoid warnings from aiohttp. - self._creation_task = self.loop.create_task(self._create_session(**kwargs)) + # aiohttp will complain if APIClient gets instantiated outside a coroutine. Thankfully, we + # don't and shouldn't need to do that, so we can avoid scheduling a task to create it. + self.session = aiohttp.ClientSession(**session_kwargs) @staticmethod def _url_for(endpoint: str) -> str: return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" - async def _create_session(self, **session_kwargs) -> None: - """Create the aiohttp session with `session_kwargs`.""" - self.session = aiohttp.ClientSession(**session_kwargs) - async def close(self) -> None: - """Close the aiohttp session and unset the ready event.""" - if self.session: - await self.session.close() + """Close the aiohttp session.""" + await self.session.close() async 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.""" @@ -78,8 +71,6 @@ class APIClient: async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Send an HTTP request to the site API and return the JSON response.""" - await self._creation_task - async with self.session.request(method.upper(), self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() @@ -102,8 +93,6 @@ class APIClient: async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]: """Site API DELETE.""" - await self._creation_task - async with self.session.delete(self._url_for(endpoint), **kwargs) as resp: if resp.status == 204: return None diff --git a/bot/bot.py b/bot/bot.py index b01cbea43..4ebe0a5c3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -198,7 +198,7 @@ class Bot(commands.Bot): self.http.connector = self._connector self.http_session = aiohttp.ClientSession(connector=self._connector) - self.api_client = api.APIClient(loop=self.loop, connector=self._connector) + self.api_client = api.APIClient(connector=self._connector) if self.redis_session.closed: # If the RedisSession was somehow closed, we try to reconnect it -- cgit v1.2.3 From 52ee0dab1e59089a7acc9f08078dee0df3fa40e6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 19 Dec 2020 11:33:37 -0800 Subject: Remove obsolete test cases Forgot to remove these when removing `loop_is_running` in a previous commit. --- tests/bot/test_api.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py index 99e942813..76bcb481d 100644 --- a/tests/bot/test_api.py +++ b/tests/bot/test_api.py @@ -13,14 +13,6 @@ class APIClientTests(unittest.IsolatedAsyncioTestCase): cls.error_api_response = MagicMock() cls.error_api_response.status = 999 - def test_loop_is_not_running_by_default(self): - """The event loop should not be running by default.""" - self.assertFalse(api.loop_is_running()) - - async def test_loop_is_running_in_async_context(self): - """The event loop should be running in an async context.""" - self.assertTrue(api.loop_is_running()) - def test_response_code_error_default_initialization(self): """Test the default initialization of `ResponseCodeError` without `text` or `json`""" error = api.ResponseCodeError(response=self.error_api_response) -- cgit v1.2.3 From d3492b961618f4d6d0ec6622cffda3dadcd4b7a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 19 Dec 2020 11:39:19 -0800 Subject: Fix flake8 pre-commit hook running through PyCharm --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 876d32b15..1597592ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,6 @@ repos: name: Flake8 description: This hook runs flake8 within our project's pipenv environment. entry: pipenv run flake8 - language: python + language: system types: [python] require_serial: true -- cgit v1.2.3 From 756fa37d3727e6448dd352d767b0e52c79691d1c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 08:58:55 +0200 Subject: Consume Git SHA build arg and add to it to environment --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0b1674e7a..5d0380b44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,14 @@ FROM python:3.8-slim +# Define Git SHA build argument +ARG git_sha="development" + # Set pip to have cleaner logs and no saved cache ENV PIP_NO_CACHE_DIR=false \ PIPENV_HIDE_EMOJIS=1 \ PIPENV_IGNORE_VIRTUALENVS=1 \ - PIPENV_NOSPIN=1 + PIPENV_NOSPIN=1 \ + GIT_SHA=$git_sha RUN apt-get -y update \ && apt-get install -y \ -- cgit v1.2.3 From 8da34233fe7d129ee199efdaa92ea9fc67a0e35f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 09:00:32 +0200 Subject: Inject Git SHA in container build workflow --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6152f1543..25bcce848 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,3 +55,5 @@ jobs: tags: | ghcr.io/python-discord/bot:latest ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }} + build-args: | + git_sha=${{ GITHUB_SHA }} -- cgit v1.2.3 From 33ad294ae9fd1128982c6ccd7e183e5b2dfc4752 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 09:00:45 +0200 Subject: Add constant for Git SHA --- bot/constants.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index c4bb6b2d6..92287a930 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -656,6 +656,9 @@ MODERATION_CHANNELS = Guild.moderation_channels # Category combinations MODERATION_CATEGORIES = Guild.moderation_categories +# Git SHA for Sentry +GIT_SHA = os.environ.get("GIT_SHA", "development") + # Bot replies NEGATIVE_REPLIES = [ "Noooooo!!", -- cgit v1.2.3 From 4c7ada8ee41f097e2b9b753114fadca28998da88 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 09:02:16 +0200 Subject: Attach release on Sentry SDK initialization --- bot/log.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/log.py b/bot/log.py index 13141de40..bb44cee8a 100644 --- a/bot/log.py +++ b/bot/log.py @@ -69,7 +69,8 @@ def setup_sentry() -> None: sentry_logging, AioHttpIntegration(), RedisIntegration(), - ] + ], + release=f"bot@{constants.GIT_SHA}" ) -- cgit v1.2.3 From e7ca3af18b92c732fac8f688df17da614457cd54 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 09:03:34 +0200 Subject: Use bot prefix instead pydis-bot for Sentry release workflow --- .github/workflows/sentry_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml index b0e6876f8..b8d92e90a 100644 --- a/.github/workflows/sentry_release.yml +++ b/.github/workflows/sentry_release.yml @@ -21,4 +21,4 @@ jobs: with: tagName: ${{ github.sha }} environment: production - releaseNamePrefix: pydis-bot@ + releaseNamePrefix: bot@ -- cgit v1.2.3 From 981eac1427ce7fd8d16bf79bfef68af86b625b10 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 09:05:02 +0200 Subject: Remove aiohttp integration --- bot/log.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/log.py b/bot/log.py index bb44cee8a..0935666d1 100644 --- a/bot/log.py +++ b/bot/log.py @@ -6,7 +6,6 @@ from pathlib import Path import coloredlogs import sentry_sdk -from sentry_sdk.integrations.aiohttp import AioHttpIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.redis import RedisIntegration @@ -67,7 +66,6 @@ def setup_sentry() -> None: dsn=constants.Bot.sentry_dsn, integrations=[ sentry_logging, - AioHttpIntegration(), RedisIntegration(), ], release=f"bot@{constants.GIT_SHA}" -- cgit v1.2.3 From 477af4efe7a0ed155bf6f5805a2d0fd3674e0e6f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 09:38:49 +0200 Subject: Add GitHub API key to config as environment variable --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index c4bb6b2d6..25a4c4d09 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -493,6 +493,7 @@ class Keys(metaclass=YAMLGetter): section = "keys" site_api: Optional[str] + github: Optional[str] class URLs(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 3f3f66962..ca89bb639 100644 --- a/config-default.yml +++ b/config-default.yml @@ -323,6 +323,7 @@ filter: keys: site_api: !ENV "BOT_API_KEY" + github: !ENV "GITHUB_API_KEY" urls: -- cgit v1.2.3 From de3dd22f3ee6b219ecd1569c56cdd480eadde298 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 09:59:56 +0200 Subject: Move PEP related functions and command to own cog --- bot/exts/utils/pep.py | 153 ++++++++++++++++++++++++++++++++++++++++++++++++ bot/exts/utils/utils.py | 137 +------------------------------------------ 2 files changed, 154 insertions(+), 136 deletions(-) create mode 100644 bot/exts/utils/pep.py diff --git a/bot/exts/utils/pep.py b/bot/exts/utils/pep.py new file mode 100644 index 000000000..71c710087 --- /dev/null +++ b/bot/exts/utils/pep.py @@ -0,0 +1,153 @@ +import logging +from datetime import datetime, timedelta +from email.parser import HeaderParser +from io import StringIO +from typing import Dict, Optional, Tuple + +from discord import Colour, Embed +from discord.ext.commands import Cog, Context, command + +from bot.bot import Bot +from bot.utils.cache import AsyncCache + +log = logging.getLogger(__name__) + +ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + +pep_cache = AsyncCache() + + +class PythonEnhancementProposals(Cog): + """Cog for displaying information about PEPs.""" + + BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" + BASE_GITHUB_PEP_URL = "https://raw.githubusercontent.com/python/peps/master/pep-" + PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" + + def __init__(self, bot: Bot): + self.bot = bot + self.peps: Dict[int, str] = {} + self.last_refreshed_peps: Optional[datetime] = None + self.bot.loop.create_task(self.refresh_peps_urls()) + + async def refresh_peps_urls(self) -> None: + """Refresh PEP URLs listing in every 3 hours.""" + # Wait until HTTP client is available + await self.bot.wait_until_ready() + log.trace("Started refreshing PEP URLs.") + + async with self.bot.http_session.get(self.PEPS_LISTING_API_URL) as resp: + listing = await resp.json() + + log.trace("Got PEP URLs listing from GitHub API") + + for file in listing: + name = file["name"] + if name.startswith("pep-") and name.endswith((".rst", ".txt")): + pep_number = name.replace("pep-", "").split(".")[0] + self.peps[int(pep_number)] = file["download_url"] + + self.last_refreshed_peps = datetime.now() + log.info("Successfully refreshed PEP URLs listing.") + + @staticmethod + def get_pep_zero_embed() -> Embed: + """Get information embed about PEP 0.""" + pep_embed = Embed( + title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + url="https://www.python.org/dev/peps/" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + return pep_embed + + async def validate_pep_number(self, pep_nr: int) -> Optional[Embed]: + """Validate is PEP number valid. When it isn't, return error embed, otherwise None.""" + if ( + pep_nr not in self.peps + and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() + and len(str(pep_nr)) < 5 + ): + await self.refresh_peps_urls() + + if pep_nr not in self.peps: + log.trace(f"PEP {pep_nr} was not found") + return Embed( + title="PEP not found", + description=f"PEP {pep_nr} does not exist.", + colour=Colour.red() + ) + + return None + + def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed: + """Generate PEP embed based on PEP headers data.""" + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_nr} - {pep_header['Title']}**", + description=f"[Link]({self.BASE_PEP_URL}{pep_nr:04})", + ) + + pep_embed.set_thumbnail(url=ICON_URL) + + # Add the interesting information + fields_to_check = ("Status", "Python-Version", "Created", "Type") + for field in fields_to_check: + # Check for a PEP metadata field that is present but has an empty value + # embed field values can't contain an empty string + if pep_header.get(field, ""): + pep_embed.add_field(name=field, value=pep_header[field]) + + return pep_embed + + @pep_cache(arg_offset=1) + async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: + """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" + response = await self.bot.http_session.get(self.peps[pep_nr]) + + if response.status == 200: + log.trace(f"PEP {pep_nr} found") + pep_content = await response.text() + + # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 + pep_header = HeaderParser().parse(StringIO(pep_content)) + return self.generate_pep_embed(pep_header, pep_nr), True + else: + log.trace( + f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." + ) + return Embed( + title="Unexpected error", + description="Unexpected HTTP error during PEP search. Please let us know.", + colour=Colour.red() + ), False + + @command(name='pep', aliases=('get_pep', 'p')) + async def pep_command(self, ctx: Context, pep_number: int) -> None: + """Fetches information about a PEP and sends it to the channel.""" + # Trigger typing in chat to show users that bot is responding + await ctx.trigger_typing() + + # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. + if pep_number == 0: + pep_embed = self.get_pep_zero_embed() + success = True + else: + success = False + if not (pep_embed := await self.validate_pep_number(pep_number)): + pep_embed, success = await self.get_pep_embed(pep_number) + + await ctx.send(embed=pep_embed) + if success: + log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") + self.bot.stats.incr(f"pep_fetches.{pep_number}") + else: + log.trace(f"Getting PEP {pep_number} failed. Error embed sent.") + + +def setup(bot: Bot) -> None: + """Load the PEP cog.""" + bot.add_cog(PythonEnhancementProposals(bot)) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 8e7e6ba36..eb92dfca7 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -2,10 +2,7 @@ import difflib import logging import re import unicodedata -from datetime import datetime, timedelta -from email.parser import HeaderParser -from io import StringIO -from typing import Dict, Optional, Tuple, Union +from typing import Tuple, Union from discord import Colour, Embed, utils from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role @@ -17,7 +14,6 @@ from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils import messages -from bot.utils.cache import AsyncCache from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -44,23 +40,12 @@ If the implementation is easy to explain, it may be a good idea. Namespaces are one honking great idea -- let's do more of those! """ -ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" - -pep_cache = AsyncCache() - class Utils(Cog): """A selection of utilities which don't have a clear category.""" - BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" - BASE_GITHUB_PEP_URL = "https://raw.githubusercontent.com/python/peps/master/pep-" - PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" - def __init__(self, bot: Bot): self.bot = bot - self.peps: Dict[int, str] = {} - self.last_refreshed_peps: Optional[datetime] = None - self.bot.loop.create_task(self.refresh_peps_urls()) @command() @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) @@ -207,126 +192,6 @@ class Utils(Cog): for reaction in options: await message.add_reaction(reaction) - # region: PEP - - async def refresh_peps_urls(self) -> None: - """Refresh PEP URLs listing in every 3 hours.""" - # Wait until HTTP client is available - await self.bot.wait_until_ready() - log.trace("Started refreshing PEP URLs.") - - async with self.bot.http_session.get(self.PEPS_LISTING_API_URL) as resp: - listing = await resp.json() - - log.trace("Got PEP URLs listing from GitHub API") - - for file in listing: - name = file["name"] - if name.startswith("pep-") and name.endswith((".rst", ".txt")): - pep_number = name.replace("pep-", "").split(".")[0] - self.peps[int(pep_number)] = file["download_url"] - - self.last_refreshed_peps = datetime.now() - log.info("Successfully refreshed PEP URLs listing.") - - @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: int) -> None: - """Fetches information about a PEP and sends it to the channel.""" - # Trigger typing in chat to show users that bot is responding - await ctx.trigger_typing() - - # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. - if pep_number == 0: - pep_embed = self.get_pep_zero_embed() - success = True - else: - success = False - if not (pep_embed := await self.validate_pep_number(pep_number)): - pep_embed, success = await self.get_pep_embed(pep_number) - - await ctx.send(embed=pep_embed) - if success: - log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") - self.bot.stats.incr(f"pep_fetches.{pep_number}") - else: - log.trace(f"Getting PEP {pep_number} failed. Error embed sent.") - - @staticmethod - def get_pep_zero_embed() -> Embed: - """Get information embed about PEP 0.""" - pep_embed = Embed( - title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - url="https://www.python.org/dev/peps/" - ) - pep_embed.set_thumbnail(url=ICON_URL) - pep_embed.add_field(name="Status", value="Active") - pep_embed.add_field(name="Created", value="13-Jul-2000") - pep_embed.add_field(name="Type", value="Informational") - - return pep_embed - - async def validate_pep_number(self, pep_nr: int) -> Optional[Embed]: - """Validate is PEP number valid. When it isn't, return error embed, otherwise None.""" - if ( - pep_nr not in self.peps - and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() - and len(str(pep_nr)) < 5 - ): - await self.refresh_peps_urls() - - if pep_nr not in self.peps: - log.trace(f"PEP {pep_nr} was not found") - return Embed( - title="PEP not found", - description=f"PEP {pep_nr} does not exist.", - colour=Colour.red() - ) - - return None - - def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed: - """Generate PEP embed based on PEP headers data.""" - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_nr} - {pep_header['Title']}**", - description=f"[Link]({self.BASE_PEP_URL}{pep_nr:04})", - ) - - pep_embed.set_thumbnail(url=ICON_URL) - - # Add the interesting information - fields_to_check = ("Status", "Python-Version", "Created", "Type") - for field in fields_to_check: - # Check for a PEP metadata field that is present but has an empty value - # embed field values can't contain an empty string - if pep_header.get(field, ""): - pep_embed.add_field(name=field, value=pep_header[field]) - - return pep_embed - - @pep_cache(arg_offset=1) - async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: - """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" - response = await self.bot.http_session.get(self.peps[pep_nr]) - - if response.status == 200: - log.trace(f"PEP {pep_nr} found") - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - return self.generate_pep_embed(pep_header, pep_nr), True - else: - log.trace( - f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." - ) - return Embed( - title="Unexpected error", - description="Unexpected HTTP error during PEP search. Please let us know.", - colour=Colour.red() - ), False - # endregion - def setup(bot: Bot) -> None: """Load the Utils cog.""" -- cgit v1.2.3 From d9fed5807429bb8029b8d623abed67ee03d211a4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 10:02:49 +0200 Subject: Set last PEPs listing at beginning of function --- bot/exts/utils/pep.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/pep.py b/bot/exts/utils/pep.py index 71c710087..d60a40658 100644 --- a/bot/exts/utils/pep.py +++ b/bot/exts/utils/pep.py @@ -35,6 +35,7 @@ class PythonEnhancementProposals(Cog): # Wait until HTTP client is available await self.bot.wait_until_ready() log.trace("Started refreshing PEP URLs.") + self.last_refreshed_peps = datetime.now() async with self.bot.http_session.get(self.PEPS_LISTING_API_URL) as resp: listing = await resp.json() @@ -47,7 +48,6 @@ class PythonEnhancementProposals(Cog): pep_number = name.replace("pep-", "").split(".")[0] self.peps[int(pep_number)] = file["download_url"] - self.last_refreshed_peps = datetime.now() log.info("Successfully refreshed PEP URLs listing.") @staticmethod -- cgit v1.2.3 From b7ab1595fd4d9e8e9f8e0e2285fe4b4dfdc2674d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 10:04:31 +0200 Subject: Make last PEPs listing refresh non-optional --- bot/exts/utils/pep.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/pep.py b/bot/exts/utils/pep.py index d60a40658..e0b06d63e 100644 --- a/bot/exts/utils/pep.py +++ b/bot/exts/utils/pep.py @@ -27,7 +27,8 @@ class PythonEnhancementProposals(Cog): def __init__(self, bot: Bot): self.bot = bot self.peps: Dict[int, str] = {} - self.last_refreshed_peps: Optional[datetime] = None + # To avoid situations where we don't have last datetime, set this to now. + self.last_refreshed_peps: datetime = datetime.now() self.bot.loop.create_task(self.refresh_peps_urls()) async def refresh_peps_urls(self) -> None: -- cgit v1.2.3 From 4527f9a674a28952d1da670e934921d12cdc14b6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 10:28:43 +0200 Subject: Log warning and return early when can't get PEP URLs from API --- bot/exts/utils/pep.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/utils/pep.py b/bot/exts/utils/pep.py index e0b06d63e..d642c902a 100644 --- a/bot/exts/utils/pep.py +++ b/bot/exts/utils/pep.py @@ -39,6 +39,10 @@ class PythonEnhancementProposals(Cog): self.last_refreshed_peps = datetime.now() async with self.bot.http_session.get(self.PEPS_LISTING_API_URL) as resp: + if resp.status != 200: + log.warning(f"Fetching PEP URLs from GitHub API failed with code {resp.status}") + return + listing = await resp.json() log.trace("Got PEP URLs listing from GitHub API") -- cgit v1.2.3 From d55adafde54d6b695f6e2ba91c3813c45ea95d0e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 10:31:43 +0200 Subject: Implement GitHub API authorization header --- bot/exts/utils/pep.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/pep.py b/bot/exts/utils/pep.py index d642c902a..df9ad2ba9 100644 --- a/bot/exts/utils/pep.py +++ b/bot/exts/utils/pep.py @@ -8,6 +8,7 @@ from discord import Colour, Embed from discord.ext.commands import Cog, Context, command from bot.bot import Bot +from bot.constants import Keys from bot.utils.cache import AsyncCache log = logging.getLogger(__name__) @@ -16,6 +17,10 @@ ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" pep_cache = AsyncCache() +GITHUB_API_HEADERS = {} +if Keys.github: + GITHUB_API_HEADERS["Authorization"] = f"token {Keys.github}" + class PythonEnhancementProposals(Cog): """Cog for displaying information about PEPs.""" @@ -38,7 +43,10 @@ class PythonEnhancementProposals(Cog): log.trace("Started refreshing PEP URLs.") self.last_refreshed_peps = datetime.now() - async with self.bot.http_session.get(self.PEPS_LISTING_API_URL) as resp: + async with self.bot.http_session.get( + self.PEPS_LISTING_API_URL, + headers=GITHUB_API_HEADERS + ) as resp: if resp.status != 200: log.warning(f"Fetching PEP URLs from GitHub API failed with code {resp.status}") return -- cgit v1.2.3 From 9870072310e5a2d1ccf6d5a035d1f1044343ff5f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 10:35:05 +0200 Subject: Remove unused constant --- bot/exts/utils/pep.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/utils/pep.py b/bot/exts/utils/pep.py index df9ad2ba9..873f32a8e 100644 --- a/bot/exts/utils/pep.py +++ b/bot/exts/utils/pep.py @@ -26,7 +26,6 @@ class PythonEnhancementProposals(Cog): """Cog for displaying information about PEPs.""" BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" - BASE_GITHUB_PEP_URL = "https://raw.githubusercontent.com/python/peps/master/pep-" PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" def __init__(self, bot: Bot): -- cgit v1.2.3 From d5988993e71e6dcd78b454a29d7132fc11fb6e71 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 17:22:20 +0200 Subject: Fix wrong way for getting Git SHA --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25bcce848..6c97e8784 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,4 +56,4 @@ jobs: ghcr.io/python-discord/bot:latest ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }} build-args: | - git_sha=${{ GITHUB_SHA }} + git_sha=${{ github.sha }} -- cgit v1.2.3 From 6115f5a9f9c4c72e3ec7cac02372f10135b836bc Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 20 Dec 2020 16:56:27 +0100 Subject: Add the clear alias to the clean command --- bot/exts/utils/clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index bf25cb4c2..8acaf9131 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -191,7 +191,7 @@ class Clean(Cog): channel_id=Channels.mod_log, ) - @group(invoke_without_command=True, name="clean", aliases=["purge"]) + @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) @has_any_role(*MODERATION_ROLES) async def clean_group(self, ctx: Context) -> None: """Commands for cleaning messages in channels.""" -- cgit v1.2.3 From ce46567546488f87f458b5d4fe1894d90e848044 Mon Sep 17 00:00:00 2001 From: Steele Date: Tue, 22 Dec 2020 20:30:08 -0500 Subject: Rewrite `!verify` to account for new native-gate-only verification. Renamed method; if not `user.pending`, adds and immediately removes an arbitrary role (namely the Announcements role), which verifies the user. --- bot/exts/moderation/verification.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index ca3e97e2e..dbd3c42a6 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -834,20 +834,21 @@ class Verification(Cog): @command(name='verify') @has_any_role(*constants.MODERATION_ROLES) - async def apply_developer_role(self, ctx: Context, user: discord.Member) -> None: - """Command for moderators to apply the Developer role to any user.""" + async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None: + """Command for moderators to verify any user.""" log.trace(f'verify command called by {ctx.author} for {user.id}.') - developer_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.verified) - if developer_role in user.roles: - log.trace(f'{user.id} is already a developer, aborting.') - await ctx.send(f'{constants.Emojis.cross_mark} {user.mention} is already a developer.') + if user.pending: + log.trace(f'{user.id} is already verified, aborting.') + await ctx.send(f'{constants.Emojis.cross_mark} {user.mention} is already verified.') return - await user.add_roles(developer_role) - await safe_dm(user.send(ALTERNATE_VERIFIED_MESSAGE)) - log.trace(f'Developer role successfully applied to {user.id}') - await ctx.send(f'{constants.Emojis.check_mark} Developer role applied to {user.mention}.') + # Adding a role automatically verifies the user, so we add and remove the Announcements role. + temporary_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.announcements) + await user.add_roles(temporary_role) + await user.remove_roles(temporary_role) + log.trace(f'{user.id} manually verified.') + await ctx.send(f'{constants.Emojis.check_mark} {user.mention} is now verified.') # endregion -- cgit v1.2.3 From dd546b8970f9643dd1ff4a2f09c8a675d6bec5a8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 23 Dec 2020 17:31:03 +0200 Subject: Move constants out from class --- bot/exts/utils/pep.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/utils/pep.py b/bot/exts/utils/pep.py index 873f32a8e..8ac96bbdb 100644 --- a/bot/exts/utils/pep.py +++ b/bot/exts/utils/pep.py @@ -14,6 +14,8 @@ from bot.utils.cache import AsyncCache log = logging.getLogger(__name__) ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" +BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" +PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" pep_cache = AsyncCache() @@ -25,9 +27,6 @@ if Keys.github: class PythonEnhancementProposals(Cog): """Cog for displaying information about PEPs.""" - BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" - PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" - def __init__(self, bot: Bot): self.bot = bot self.peps: Dict[int, str] = {} @@ -43,7 +42,7 @@ class PythonEnhancementProposals(Cog): self.last_refreshed_peps = datetime.now() async with self.bot.http_session.get( - self.PEPS_LISTING_API_URL, + PEPS_LISTING_API_URL, headers=GITHUB_API_HEADERS ) as resp: if resp.status != 200: @@ -100,7 +99,7 @@ class PythonEnhancementProposals(Cog): # Assemble the embed pep_embed = Embed( title=f"**PEP {pep_nr} - {pep_header['Title']}**", - description=f"[Link]({self.BASE_PEP_URL}{pep_nr:04})", + description=f"[Link]({BASE_PEP_URL}{pep_nr:04})", ) pep_embed.set_thumbnail(url=ICON_URL) -- cgit v1.2.3 From 361a21205f76a80b54f5816dd96eddda6c55fadb Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 23 Dec 2020 20:12:03 +0200 Subject: Move PEP cog to info extensions category --- bot/exts/info/pep.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++ bot/exts/utils/pep.py | 164 -------------------------------------------------- 2 files changed, 164 insertions(+), 164 deletions(-) create mode 100644 bot/exts/info/pep.py delete mode 100644 bot/exts/utils/pep.py diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py new file mode 100644 index 000000000..8ac96bbdb --- /dev/null +++ b/bot/exts/info/pep.py @@ -0,0 +1,164 @@ +import logging +from datetime import datetime, timedelta +from email.parser import HeaderParser +from io import StringIO +from typing import Dict, Optional, Tuple + +from discord import Colour, Embed +from discord.ext.commands import Cog, Context, command + +from bot.bot import Bot +from bot.constants import Keys +from bot.utils.cache import AsyncCache + +log = logging.getLogger(__name__) + +ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" +BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" +PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" + +pep_cache = AsyncCache() + +GITHUB_API_HEADERS = {} +if Keys.github: + GITHUB_API_HEADERS["Authorization"] = f"token {Keys.github}" + + +class PythonEnhancementProposals(Cog): + """Cog for displaying information about PEPs.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.peps: Dict[int, str] = {} + # To avoid situations where we don't have last datetime, set this to now. + self.last_refreshed_peps: datetime = datetime.now() + self.bot.loop.create_task(self.refresh_peps_urls()) + + async def refresh_peps_urls(self) -> None: + """Refresh PEP URLs listing in every 3 hours.""" + # Wait until HTTP client is available + await self.bot.wait_until_ready() + log.trace("Started refreshing PEP URLs.") + self.last_refreshed_peps = datetime.now() + + async with self.bot.http_session.get( + PEPS_LISTING_API_URL, + headers=GITHUB_API_HEADERS + ) as resp: + if resp.status != 200: + log.warning(f"Fetching PEP URLs from GitHub API failed with code {resp.status}") + return + + listing = await resp.json() + + log.trace("Got PEP URLs listing from GitHub API") + + for file in listing: + name = file["name"] + if name.startswith("pep-") and name.endswith((".rst", ".txt")): + pep_number = name.replace("pep-", "").split(".")[0] + self.peps[int(pep_number)] = file["download_url"] + + log.info("Successfully refreshed PEP URLs listing.") + + @staticmethod + def get_pep_zero_embed() -> Embed: + """Get information embed about PEP 0.""" + pep_embed = Embed( + title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + url="https://www.python.org/dev/peps/" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + return pep_embed + + async def validate_pep_number(self, pep_nr: int) -> Optional[Embed]: + """Validate is PEP number valid. When it isn't, return error embed, otherwise None.""" + if ( + pep_nr not in self.peps + and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() + and len(str(pep_nr)) < 5 + ): + await self.refresh_peps_urls() + + if pep_nr not in self.peps: + log.trace(f"PEP {pep_nr} was not found") + return Embed( + title="PEP not found", + description=f"PEP {pep_nr} does not exist.", + colour=Colour.red() + ) + + return None + + def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed: + """Generate PEP embed based on PEP headers data.""" + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_nr} - {pep_header['Title']}**", + description=f"[Link]({BASE_PEP_URL}{pep_nr:04})", + ) + + pep_embed.set_thumbnail(url=ICON_URL) + + # Add the interesting information + fields_to_check = ("Status", "Python-Version", "Created", "Type") + for field in fields_to_check: + # Check for a PEP metadata field that is present but has an empty value + # embed field values can't contain an empty string + if pep_header.get(field, ""): + pep_embed.add_field(name=field, value=pep_header[field]) + + return pep_embed + + @pep_cache(arg_offset=1) + async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: + """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" + response = await self.bot.http_session.get(self.peps[pep_nr]) + + if response.status == 200: + log.trace(f"PEP {pep_nr} found") + pep_content = await response.text() + + # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 + pep_header = HeaderParser().parse(StringIO(pep_content)) + return self.generate_pep_embed(pep_header, pep_nr), True + else: + log.trace( + f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." + ) + return Embed( + title="Unexpected error", + description="Unexpected HTTP error during PEP search. Please let us know.", + colour=Colour.red() + ), False + + @command(name='pep', aliases=('get_pep', 'p')) + async def pep_command(self, ctx: Context, pep_number: int) -> None: + """Fetches information about a PEP and sends it to the channel.""" + # Trigger typing in chat to show users that bot is responding + await ctx.trigger_typing() + + # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. + if pep_number == 0: + pep_embed = self.get_pep_zero_embed() + success = True + else: + success = False + if not (pep_embed := await self.validate_pep_number(pep_number)): + pep_embed, success = await self.get_pep_embed(pep_number) + + await ctx.send(embed=pep_embed) + if success: + log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") + self.bot.stats.incr(f"pep_fetches.{pep_number}") + else: + log.trace(f"Getting PEP {pep_number} failed. Error embed sent.") + + +def setup(bot: Bot) -> None: + """Load the PEP cog.""" + bot.add_cog(PythonEnhancementProposals(bot)) diff --git a/bot/exts/utils/pep.py b/bot/exts/utils/pep.py deleted file mode 100644 index 8ac96bbdb..000000000 --- a/bot/exts/utils/pep.py +++ /dev/null @@ -1,164 +0,0 @@ -import logging -from datetime import datetime, timedelta -from email.parser import HeaderParser -from io import StringIO -from typing import Dict, Optional, Tuple - -from discord import Colour, Embed -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot -from bot.constants import Keys -from bot.utils.cache import AsyncCache - -log = logging.getLogger(__name__) - -ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" -BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" -PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" - -pep_cache = AsyncCache() - -GITHUB_API_HEADERS = {} -if Keys.github: - GITHUB_API_HEADERS["Authorization"] = f"token {Keys.github}" - - -class PythonEnhancementProposals(Cog): - """Cog for displaying information about PEPs.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.peps: Dict[int, str] = {} - # To avoid situations where we don't have last datetime, set this to now. - self.last_refreshed_peps: datetime = datetime.now() - self.bot.loop.create_task(self.refresh_peps_urls()) - - async def refresh_peps_urls(self) -> None: - """Refresh PEP URLs listing in every 3 hours.""" - # Wait until HTTP client is available - await self.bot.wait_until_ready() - log.trace("Started refreshing PEP URLs.") - self.last_refreshed_peps = datetime.now() - - async with self.bot.http_session.get( - PEPS_LISTING_API_URL, - headers=GITHUB_API_HEADERS - ) as resp: - if resp.status != 200: - log.warning(f"Fetching PEP URLs from GitHub API failed with code {resp.status}") - return - - listing = await resp.json() - - log.trace("Got PEP URLs listing from GitHub API") - - for file in listing: - name = file["name"] - if name.startswith("pep-") and name.endswith((".rst", ".txt")): - pep_number = name.replace("pep-", "").split(".")[0] - self.peps[int(pep_number)] = file["download_url"] - - log.info("Successfully refreshed PEP URLs listing.") - - @staticmethod - def get_pep_zero_embed() -> Embed: - """Get information embed about PEP 0.""" - pep_embed = Embed( - title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - url="https://www.python.org/dev/peps/" - ) - pep_embed.set_thumbnail(url=ICON_URL) - pep_embed.add_field(name="Status", value="Active") - pep_embed.add_field(name="Created", value="13-Jul-2000") - pep_embed.add_field(name="Type", value="Informational") - - return pep_embed - - async def validate_pep_number(self, pep_nr: int) -> Optional[Embed]: - """Validate is PEP number valid. When it isn't, return error embed, otherwise None.""" - if ( - pep_nr not in self.peps - and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() - and len(str(pep_nr)) < 5 - ): - await self.refresh_peps_urls() - - if pep_nr not in self.peps: - log.trace(f"PEP {pep_nr} was not found") - return Embed( - title="PEP not found", - description=f"PEP {pep_nr} does not exist.", - colour=Colour.red() - ) - - return None - - def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed: - """Generate PEP embed based on PEP headers data.""" - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_nr} - {pep_header['Title']}**", - description=f"[Link]({BASE_PEP_URL}{pep_nr:04})", - ) - - pep_embed.set_thumbnail(url=ICON_URL) - - # Add the interesting information - fields_to_check = ("Status", "Python-Version", "Created", "Type") - for field in fields_to_check: - # Check for a PEP metadata field that is present but has an empty value - # embed field values can't contain an empty string - if pep_header.get(field, ""): - pep_embed.add_field(name=field, value=pep_header[field]) - - return pep_embed - - @pep_cache(arg_offset=1) - async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: - """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" - response = await self.bot.http_session.get(self.peps[pep_nr]) - - if response.status == 200: - log.trace(f"PEP {pep_nr} found") - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - return self.generate_pep_embed(pep_header, pep_nr), True - else: - log.trace( - f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." - ) - return Embed( - title="Unexpected error", - description="Unexpected HTTP error during PEP search. Please let us know.", - colour=Colour.red() - ), False - - @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: int) -> None: - """Fetches information about a PEP and sends it to the channel.""" - # Trigger typing in chat to show users that bot is responding - await ctx.trigger_typing() - - # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. - if pep_number == 0: - pep_embed = self.get_pep_zero_embed() - success = True - else: - success = False - if not (pep_embed := await self.validate_pep_number(pep_number)): - pep_embed, success = await self.get_pep_embed(pep_number) - - await ctx.send(embed=pep_embed) - if success: - log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") - self.bot.stats.incr(f"pep_fetches.{pep_number}") - else: - log.trace(f"Getting PEP {pep_number} failed. Error embed sent.") - - -def setup(bot: Bot) -> None: - """Load the PEP cog.""" - bot.add_cog(PythonEnhancementProposals(bot)) -- cgit v1.2.3 From f397102efc3c1551f37d1ac9cb45d07043487a37 Mon Sep 17 00:00:00 2001 From: Steele Date: Wed, 23 Dec 2020 18:54:01 -0500 Subject: `ALTERNATE_VERIFIED_MESSAGE`: "You're" -> "You are". --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index dbd3c42a6..6a4319705 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -55,7 +55,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! """ ALTERNATE_VERIFIED_MESSAGE = f""" -You're now verified! +You are now verified! You can find a copy of our rules for reference at . -- cgit v1.2.3 From 68cbca003c508dd7287120e73a558e160f09c276 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> Date: Thu, 24 Dec 2020 10:35:19 -0500 Subject: `if user.pending` -> `if not user.pending` This was a logic error. This functionality is unfortunately difficult to test outside of production. --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 6a4319705..ce91dcb15 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -838,7 +838,7 @@ class Verification(Cog): """Command for moderators to verify any user.""" log.trace(f'verify command called by {ctx.author} for {user.id}.') - if user.pending: + if not user.pending: log.trace(f'{user.id} is already verified, aborting.') await ctx.send(f'{constants.Emojis.cross_mark} {user.mention} is already verified.') return -- cgit v1.2.3 From dd2497338721dff3d34b7127883c2c6d65cd08c5 Mon Sep 17 00:00:00 2001 From: Steele Date: Fri, 25 Dec 2020 14:10:43 -0500 Subject: `!user` command says if user is "Verified" Previously, `!user` said if the user is "Pending", whereas "Verified" is the boolean opposite. --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 2057876e4..b2138b03f 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -229,9 +229,9 @@ class Information(Cog): if on_server: joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - membership = {"Joined": joined, "Pending": user.pending, "Roles": roles or None} + membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None} if not is_mod_channel(ctx.channel): - membership.pop("Pending") + membership.pop("Verified") membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()])) else: -- cgit v1.2.3 From 203454f187c4937b755e869568ff7d21a9b5a718 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 27 Dec 2020 17:01:17 +0200 Subject: Add check does user can see channel in raw command --- bot/exts/info/information.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 2057876e4..c1db0c230 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -423,6 +423,10 @@ class Information(Cog): @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" + if ctx.author not in message.channel.members: + await ctx.send(":x: You can't get message from channel that you don't see.") + return + # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling # doing this extra request is also much easier than trying to convert everything back into a dictionary again raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) -- cgit v1.2.3 From cca72be223cc7c6a3f88d6f813c6557b152b5bce Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 27 Dec 2020 17:02:02 +0200 Subject: Enable raw command --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c1db0c230..f7e36ac97 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -419,7 +419,7 @@ class Information(Cog): return out.rstrip() @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True, enabled=False) + @group(invoke_without_command=True) @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" @@ -458,7 +458,7 @@ class Information(Cog): for page in paginator.pages: await ctx.send(page) - @raw.command(enabled=False) + @raw.command() async def json(self, ctx: Context, message: Message) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" await ctx.invoke(self.raw, message=message, json=True) -- cgit v1.2.3 From b54e72025c6034c66ec5f527f69af6612ae5bec1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 28 Dec 2020 13:01:40 +0200 Subject: Update raw command no permission error message Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f7e36ac97..94c207ee3 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -424,7 +424,7 @@ class Information(Cog): async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" if ctx.author not in message.channel.members: - await ctx.send(":x: You can't get message from channel that you don't see.") + await ctx.send(":x: You do not have permissions to see the channel this message is in.") return # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling -- cgit v1.2.3 From fc8a1246b281fd0d495955e0b84c6fc75a59ba4d Mon Sep 17 00:00:00 2001 From: Steele Date: Wed, 30 Dec 2020 16:39:49 -0500 Subject: "Pending: False" to "Verified: True" to agree with new semantics. --- tests/bot/exts/info/test_information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 043cce8de..d077be960 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -355,7 +355,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" Joined: {"1 year ago"} - Pending: {"False"} + Verified: {"True"} Roles: &Moderators """).strip(), embed.fields[1].value -- cgit v1.2.3 From ecd9bd0eefe963fb61dbfdef9fe4fb61fdd85b94 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 31 Dec 2020 22:06:37 +0000 Subject: First draft of env explaintions --- bot/resources/tags/enviroments.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 bot/resources/tags/enviroments.md diff --git a/bot/resources/tags/enviroments.md b/bot/resources/tags/enviroments.md new file mode 100644 index 000000000..c4356254e --- /dev/null +++ b/bot/resources/tags/enviroments.md @@ -0,0 +1,18 @@ +**Python Enviroments** + +The main purpose of Python [virtual environments](https://docs.python.org/3/library/venv.html#venv-def) is to create an isolated environment for Python projects. This means that each project can have its own dependencies, such as third party packages installed using `pip`, regardless of what dependencies every other project has. + +To see the current enviroment in use by python you can run: +```py +>>> import sys +>>> print(sys.executable) +/usr/bin/python3 +``` + +To see the enviroment in use by `pip` you can do `pip debug`, or `pip3 debug` for linux/macOS. The 3rd line of the output will contain the path in use. I.E. `sys.executable: /usr/bin/python3` + +If the python's `sys.executable` doesn't match pip's then they are currently using different enviroments! This may cause python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different enviroment. + +Further reading: +• [Real Python's primer on Python Virtual Environments](https://realpython.com/python-virtual-environments-a-primer) +• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) \ No newline at end of file -- cgit v1.2.3 From 1066212190138529385a8f3bac27faa0ec97ccfa Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 31 Dec 2020 22:28:16 +0000 Subject: Fix linting by adding new line at eof --- bot/resources/tags/enviroments.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/enviroments.md b/bot/resources/tags/enviroments.md index c4356254e..5b2914cd9 100644 --- a/bot/resources/tags/enviroments.md +++ b/bot/resources/tags/enviroments.md @@ -15,4 +15,4 @@ If the python's `sys.executable` doesn't match pip's then they are currently usi Further reading: • [Real Python's primer on Python Virtual Environments](https://realpython.com/python-virtual-environments-a-primer) -• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) \ No newline at end of file +• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) -- cgit v1.2.3 From 70fcbd1cdb742ff33ea6491b383bf2e65fe1b24f Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 31 Dec 2020 22:06:37 +0000 Subject: First draft of env explaintions --- bot/resources/tags/enviroments.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 bot/resources/tags/enviroments.md diff --git a/bot/resources/tags/enviroments.md b/bot/resources/tags/enviroments.md new file mode 100644 index 000000000..5b2914cd9 --- /dev/null +++ b/bot/resources/tags/enviroments.md @@ -0,0 +1,18 @@ +**Python Enviroments** + +The main purpose of Python [virtual environments](https://docs.python.org/3/library/venv.html#venv-def) is to create an isolated environment for Python projects. This means that each project can have its own dependencies, such as third party packages installed using `pip`, regardless of what dependencies every other project has. + +To see the current enviroment in use by python you can run: +```py +>>> import sys +>>> print(sys.executable) +/usr/bin/python3 +``` + +To see the enviroment in use by `pip` you can do `pip debug`, or `pip3 debug` for linux/macOS. The 3rd line of the output will contain the path in use. I.E. `sys.executable: /usr/bin/python3` + +If the python's `sys.executable` doesn't match pip's then they are currently using different enviroments! This may cause python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different enviroment. + +Further reading: +• [Real Python's primer on Python Virtual Environments](https://realpython.com/python-virtual-environments-a-primer) +• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) -- cgit v1.2.3 From c9e8340749ac8ca8013062fb98adfd24c66f0417 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Fri, 1 Jan 2021 20:28:28 -0500 Subject: Update discord.py to fix webhook message publishing. Related to #1342. --- Pipfile | 2 +- Pipfile.lock | 103 ++++++++++++++++++++++++++++++++++------------------------- 2 files changed, 60 insertions(+), 45 deletions(-) diff --git a/Pipfile b/Pipfile index 3ff653749..1a9c271b4 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -"discord.py" = {git = "https://github.com/Rapptz/discord.py.git", ref = "93f102ca907af6722ee03638766afd53dfe93a7f"} +"discord.py" = {git = "https://github.com/Rapptz/discord.py.git", ref = "94f76e63947b102e5de6dae9a2cd687b308033"} feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 085d3d829..17d2f81ba 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1ba637e521c654a23bcc82950e155f5366219eae00bbf809170a371122961a4f" + "sha256": "8cc7415371be66ebc4dbfc3f3f27f19f743f4f1a9952ca30abf385a06047439b" }, "pipfile-spec": 6, "requires": { @@ -232,11 +232,11 @@ }, "discord-py": { "git": "https://github.com/Rapptz/discord.py.git", - "ref": "93f102ca907af6722ee03638766afd53dfe93a7f" + "ref": "94f76e63947b102e5de6dae9a2cd687b308033dd" }, "discord.py": { "git": "https://github.com/Rapptz/discord.py.git", - "ref": "93f102ca907af6722ee03638766afd53dfe93a7f" + "ref": "94f76e63947b102e5de6dae9a2cd687b308033" }, "docutils": { "hashes": [ @@ -592,10 +592,10 @@ }, "pytz": { "hashes": [ - "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", - "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" + "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", + "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" ], - "version": "==2020.4" + "version": "==2020.5" }, "pyyaml": { "hashes": [ @@ -834,43 +834,58 @@ }, "coverage": { "hashes": [ - "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516", - "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259", - "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9", - "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097", - "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0", - "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f", - "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7", - "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c", - "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5", - "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7", - "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729", - "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978", - "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9", - "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f", - "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9", - "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822", - "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418", - "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82", - "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f", - "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d", - "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221", - "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4", - "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21", - "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709", - "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54", - "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d", - "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270", - "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24", - "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751", - "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a", - "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237", - "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7", - "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636", - "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8" + "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297", + "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1", + "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497", + "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606", + "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528", + "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b", + "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4", + "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830", + "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1", + "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f", + "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d", + "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3", + "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8", + "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500", + "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7", + "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb", + "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b", + "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059", + "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b", + "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72", + "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36", + "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277", + "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c", + "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631", + "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff", + "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8", + "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec", + "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b", + "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7", + "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105", + "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b", + "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c", + "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b", + "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98", + "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4", + "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879", + "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f", + "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4", + "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044", + "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e", + "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899", + "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f", + "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448", + "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714", + "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2", + "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d", + "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd", + "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7", + "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae" ], "index": "pypi", - "version": "==5.3" + "version": "==5.3.1" }, "coveralls": { "hashes": [ @@ -972,11 +987,11 @@ }, "identify": { "hashes": [ - "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5", - "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e" + "sha256:7aef7a5104d6254c162990e54a203cdc0fd202046b6c415bd5d636472f6565c4", + "sha256:b2c71bf9f5c482c389cef816f3a15f1c9d7429ad70f497d4a2e522442d80c6de" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.10" + "version": "==1.5.11" }, "idna": { "hashes": [ -- cgit v1.2.3 From 012f90ff552877c3fbfdd59216ab0adfc729ecd8 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 2 Jan 2021 20:54:48 -0800 Subject: Removed possibility of exception via walrus. --- bot/exts/backend/error_handler.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index f3d8537bd..fa3f706e6 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -177,25 +177,25 @@ class ErrorHandler(Cog): for cmd in self.bot.walk_commands(): if not cmd.hidden: raw_commands += (cmd.name, *cmd.aliases) - similar_command_data = difflib.get_close_matches(command_name, raw_commands, 1) - similar_command_name = similar_command_data[0] - similar_command = self.bot.get_command(similar_command_name) - - log_msg = "Cancelling attempt to suggest a command due to failed checks." - try: - if not await similar_command.can_run(ctx): + if similar_command_data := difflib.get_close_matches(command_name, raw_commands, 1): + similar_command_name = similar_command_data[0] + similar_command = self.bot.get_command(similar_command_name) + + log_msg = "Cancelling attempt to suggest a command due to failed checks." + try: + if not await similar_command.can_run(ctx): + log.debug(log_msg) + return + except errors.CommandError as cmd_error: log.debug(log_msg) + await self.on_command_error(ctx, cmd_error) return - except errors.CommandError as cmd_error: - log.debug(log_msg) - await self.on_command_error(ctx, cmd_error) - return - misspelled_content = ctx.message.content - e = Embed() - e.set_author(name="Did you mean:", icon_url=Icons.questionmark) - e.description = f"{misspelled_content.replace(command_name, similar_command_name, 1)}" - await ctx.send(embed=e, delete_after=10.0) + misspelled_content = ctx.message.content + e = Embed() + e.set_author(name="Did you mean:", icon_url=Icons.questionmark) + e.description = f"{misspelled_content.replace(command_name, similar_command_name, 1)}" + await ctx.send(embed=e, delete_after=10.0) async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None: """ -- cgit v1.2.3 From de32c8780d1d812fd0d088438ca9680a6e02b1e3 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 2 Jan 2021 21:54:17 -0800 Subject: No nominaton reason blank replaced by italic None --- bot/exts/moderation/watchchannels/talentpool.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index 7004b559a..fdba6606d 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -246,6 +246,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): log.debug(active) log.debug(type(nomination_object["inserted_at"])) + reason = nomination_object["reason"] or "*None*" + start_date = time.format_infraction(nomination_object["inserted_at"]) if active: lines = textwrap.dedent( @@ -254,7 +256,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): Status: **Active** Date: {start_date} Actor: {actor.mention if actor else actor_id} - Reason: {nomination_object["reason"]} + Reason: {reason} Nomination ID: `{nomination_object["id"]}` =============== """ @@ -267,7 +269,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): Status: Inactive Date: {start_date} Actor: {actor.mention if actor else actor_id} - Reason: {nomination_object["reason"]} + Reason: {reason} End date: {end_date} Unwatch reason: {nomination_object["end_reason"]} -- cgit v1.2.3 From b336e9721e6fb77a66536176e33587b8f9e09111 Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Sat, 2 Jan 2021 23:28:47 -0800 Subject: Set reason to default as an empty string. Co-authored-by: Dennis Pham --- bot/exts/moderation/watchchannels/_watchchannel.py | 5 +---- bot/exts/moderation/watchchannels/talentpool.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index d1e976190..f9fc12dc3 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -285,10 +285,7 @@ class WatchChannel(metaclass=CogABCMeta): else: message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" - # Add reason to the footer if it exists. - footer = f"Added {time_delta} by {actor}" - if reason: - footer += f" | Reason: {reason}" + footer = f"Added {time_delta} by {actor} | Reason: {reason}" embed = Embed(description=f"{msg.author.mention} {message_jump}") embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index fdba6606d..8dd46a951 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -64,7 +64,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: Optional[str] = '') -> None: + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: """ Relay messages sent by the given `user` to the `#talent-pool` channel. -- cgit v1.2.3 From 175738048d78d74d0ac46b3e9849634a93a21b4c Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Sat, 2 Jan 2021 23:29:21 -0800 Subject: Removed unnecessary debugging logs. Co-authored-by: Dennis Pham --- bot/exts/moderation/watchchannels/talentpool.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index 8dd46a951..311c46d3b 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -243,8 +243,6 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): actor = guild.get_member(actor_id) active = nomination_object["active"] - log.debug(active) - log.debug(type(nomination_object["inserted_at"])) reason = nomination_object["reason"] or "*None*" -- cgit v1.2.3 From e0fe8fd1ec73b83c934a350b189210294dbb7637 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 2 Jan 2021 23:31:57 -0800 Subject: Removed 'Optional' import. --- bot/exts/moderation/watchchannels/talentpool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index 311c46d3b..df2ce586e 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -1,7 +1,7 @@ import logging import textwrap from collections import ChainMap -from typing import Optional, Union +from typing import Union from discord import Color, Embed, Member, User from discord.ext.commands import Cog, Context, group, has_any_role -- cgit v1.2.3 From 7ceb161fd3fc3d6e1d231c3847444501289865fc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 3 Jan 2021 10:00:51 +0200 Subject: Remove unnecessary Keys import from utils cog --- bot/exts/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 37e855588..eb92dfca7 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -9,7 +9,7 @@ from discord.ext.commands import BadArgument, Cog, Context, clean_content, comma from discord.utils import snowflake_time from bot.bot import Bot -from bot.constants import Channels, Keys, MODERATION_ROLES, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator -- cgit v1.2.3 From 4dad590ef87a3e67e82b3a43a2ab113efa86abbe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 10:58:16 -0800 Subject: HelpChannels: use a more accurate lock for messages Use the `lock_arg` decorator to keep a separate lock for each channel rather than a single lock used by all messages. Separate the core logic in `on_message` into a separate function to facilitate the use of `lock_arg` - not everything in `on_message` needs to be under the lock. --- bot/exts/help_channels/_cog.py | 82 ++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 983c5d183..76c671641 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -3,6 +3,7 @@ import logging import random import typing as t from datetime import datetime, timezone +from operator import attrgetter import discord import discord.abc @@ -11,11 +12,12 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name -from bot.utils import channel as channel_utils +from bot.utils import channel as channel_utils, lock from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +NAMESPACE = "help" HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ @@ -73,7 +75,6 @@ class HelpChannels(commands.Cog): # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] - self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) def cog_unload(self) -> None: @@ -87,6 +88,31 @@ class HelpChannels(commands.Cog): self.scheduler.cancel_all() + @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) + async def claim_channel(self, message: discord.Message) -> None: + """ + Claim the channel in which the question `message` was sent. + + Move the channel to the In Use category and pin the `message`. Add a cooldown to the + claimant to prevent them from asking another question. + """ + log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") + await self.move_to_in_use(message.channel) + await _cooldown.revoke_send_permissions(message.author, self.scheduler) + + await _message.pin(message) + + # Add user with channel for dormant check. + await _caches.claimants.set(message.channel.id, message.author.id) + + self.bot.stats.incr("help.claimed") + + # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. + timestamp = datetime.now(timezone.utc).timestamp() + await _caches.claim_times.set(message.channel.id, timestamp) + + await _caches.unanswered.set(message.channel.id, True) + def create_channel_queue(self) -> asyncio.Queue: """ Return a queue of dormant channels to use for getting the next available channel. @@ -436,51 +462,13 @@ class HelpChannels(commands.Cog): if message.author.bot: return # Ignore messages sent by bots. - channel = message.channel - - await _message.check_for_answer(message) - - is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or _channel.is_excluded_channel(channel): - return # Ignore messages outside the Available category or in excluded channels. - - log.trace("Waiting for the cog to be ready before processing messages.") - await self.init_task - - log.trace("Acquiring lock to prevent a channel from being processed twice...") - async with self.on_message_lock: - log.trace(f"on_message lock acquired for {message.id}.") - - if not channel_utils.is_in_category(channel, constants.Categories.help_available): - log.debug( - f"Message {message.id} will not make #{channel} ({channel.id}) in-use " - f"because another message in the channel already triggered that." - ) - return - - log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") - await self.move_to_in_use(channel) - await _cooldown.revoke_send_permissions(message.author, self.scheduler) - - await _message.pin(message) - - # Add user with channel for dormant check. - await _caches.claimants.set(channel.id, message.author.id) - - self.bot.stats.incr("help.claimed") - - # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. - timestamp = datetime.now(timezone.utc).timestamp() - await _caches.claim_times.set(channel.id, timestamp) - - await _caches.unanswered.set(channel.id, True) - - log.trace(f"Releasing on_message lock for {message.id}.") - - # Move a dormant channel to the Available category to fill in the gap. - # This is done last and outside the lock because it may wait indefinitely for a channel to - # be put in the queue. - await self.move_to_available() + if channel_utils.is_in_category(message.channel, constants.Categories.help_available): + if not _channel.is_excluded_channel(message.channel): + await self.init_task + await self.claim_channel(message) + await self.move_to_available() # Not in a lock because it may wait indefinitely. + else: + await _message.check_for_answer(message) @commands.Cog.listener() async def on_message_delete(self, msg: discord.Message) -> None: -- cgit v1.2.3 From 025edaa3e5b0774401ab83e925c830928e54e0fe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 11:07:27 -0800 Subject: HelpChannels: await init task at the start of event listeners It feels safer to do this since the init task moves channels to different categories and the listeners check if channels are in certain categories. --- bot/exts/help_channels/_cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 76c671641..a9ca01cdd 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -462,9 +462,10 @@ class HelpChannels(commands.Cog): if message.author.bot: return # Ignore messages sent by bots. + await self.init_task + if channel_utils.is_in_category(message.channel, constants.Categories.help_available): if not _channel.is_excluded_channel(message.channel): - await self.init_task await self.claim_channel(message) await self.move_to_available() # Not in a lock because it may wait indefinitely. else: @@ -477,15 +478,14 @@ class HelpChannels(commands.Cog): The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ + await self.init_task + if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return if not await _message.is_empty(msg.channel): return - log.trace("Waiting for the cog to be ready before processing deleted messages.") - await self.init_task - log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") # Cancel existing dormant task before scheduling new. -- cgit v1.2.3 From 0d330d7470a97660e91280979d064983f7716ecf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 11:21:13 -0800 Subject: HelpChannels: prevent user from claiming multiple channels quickly It's conceivable for a user to be able to quickly send a message in all available channels before the code has a chance to add the cooldown role. Place a lock on the author to prevent the claim code from running multiple times for the same user. --- bot/exts/help_channels/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a9ca01cdd..5695d0d05 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -89,6 +89,7 @@ class HelpChannels(commands.Cog): self.scheduler.cancel_all() @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) + @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) async def claim_channel(self, message: discord.Message) -> None: """ Claim the channel in which the question `message` was sent. -- cgit v1.2.3 From e281bb4981cd16cdc716c2fb28b470c310700ab9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 11:36:13 -0800 Subject: HelpChannels: move function to the channel module --- bot/exts/help_channels/_channel.py | 42 +++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_cog.py | 45 +++----------------------------------- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index e717d7af8..224214b00 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -4,8 +4,10 @@ from datetime import datetime, timedelta import discord +import bot from bot import constants from bot.exts.help_channels import _caches, _message +from bot.utils.channel import try_get_channel log = logging.getLogger(__name__) @@ -55,3 +57,43 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: """Check if a channel should be excluded from the help channel system.""" return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + + +async def move_to_bottom(channel: discord.TextChannel, category_id: int, **options) -> None: + """ + Move the `channel` to the bottom position of `category` and edit channel attributes. + + To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current + positions of the other channels in the category as-is. This should make sure that the channel + really ends up at the bottom of the category. + + If `options` are provided, the channel will be edited after the move is completed. This is the + same order of operations that `discord.TextChannel.edit` uses. For information on available + options, see the documentation on `discord.TextChannel.edit`. While possible, position-related + options should be avoided, as it may interfere with the category move we perform. + """ + # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. + category = await try_get_channel(category_id) + + payload = [{"id": c.id, "position": c.position} for c in category.channels] + + # Calculate the bottom position based on the current highest position in the category. If the + # category is currently empty, we simply use the current position of the channel to avoid making + # unnecessary changes to positions in the guild. + bottom_position = payload[-1]["position"] + 1 if payload else channel.position + + payload.append( + { + "id": channel.id, + "position": bottom_position, + "parent_id": category.id, + "lock_permissions": True, + } + ) + + # We use d.py's method to ensure our request is processed by d.py's rate limit manager + await bot.instance.http.bulk_channel_update(category.guild.id, payload) + + # Now that the channel is moved, we can edit the other attributes + if options: + await channel.edit(**options) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5695d0d05..f25bf132c 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -325,45 +325,6 @@ class HelpChannels(commands.Cog): self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) - async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: - """ - Move the `channel` to the bottom position of `category` and edit channel attributes. - - To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current - positions of the other channels in the category as-is. This should make sure that the channel - really ends up at the bottom of the category. - - If `options` are provided, the channel will be edited after the move is completed. This is the - same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documentation on `discord.TextChannel.edit`. While possible, position-related - options should be avoided, as it may interfere with the category move we perform. - """ - # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await channel_utils.try_get_channel(category_id) - - payload = [{"id": c.id, "position": c.position} for c in category.channels] - - # Calculate the bottom position based on the current highest position in the category. If the - # category is currently empty, we simply use the current position of the channel to avoid making - # unnecessary changes to positions in the guild. - bottom_position = payload[-1]["position"] + 1 if payload else channel.position - - payload.append( - { - "id": channel.id, - "position": bottom_position, - "parent_id": category.id, - "lock_permissions": True, - } - ) - - # We use d.py's method to ensure our request is processed by d.py's rate limit manager - await self.bot.http.bulk_channel_update(category.guild.id, payload) - - # Now that the channel is moved, we can edit the other attributes - if options: - await channel.edit(**options) - async def move_to_available(self) -> None: """Make a channel available.""" log.trace("Making a channel available.") @@ -375,7 +336,7 @@ class HelpChannels(commands.Cog): log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") - await self.move_to_bottom_position( + await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_available, ) @@ -390,7 +351,7 @@ class HelpChannels(commands.Cog): """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await self.move_to_bottom_position( + await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_dormant, ) @@ -446,7 +407,7 @@ class HelpChannels(commands.Cog): """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") - await self.move_to_bottom_position( + await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_in_use, ) -- cgit v1.2.3 From 46973747f5f0dec03e3ea0f87ea07ebbda6b07da Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 12:06:15 -0800 Subject: HelpChannels: refactor stat tracking Move significant code related to stats to a separate module. --- bot/exts/help_channels/_cog.py | 33 ++++++-------------------------- bot/exts/help_channels/_stats.py | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 27 deletions(-) create mode 100644 bot/exts/help_channels/_stats.py diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index f25bf132c..854696f87 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,7 +11,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name +from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats from bot.utils import channel as channel_utils, lock from bot.utils.scheduling import Scheduler @@ -275,20 +275,10 @@ class HelpChannels(commands.Cog): self.close_command.enabled = True await self.init_available() - self.report_stats() + _stats.report_counts() log.info("Cog is ready!") - def report_stats(self) -> None: - """Report the channel count stats.""" - total_in_use = sum(1 for _ in _channel.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in _channel.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in _channel.get_category_channels(self.dormant_category)) - - self.bot.stats.gauge("help.total.in_use", total_in_use) - self.bot.stats.gauge("help.total.available", total_available) - self.bot.stats.gauge("help.total.dormant", total_dormant) - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -341,7 +331,7 @@ class HelpChannels(commands.Cog): category_id=constants.Categories.help_available, ) - self.report_stats() + _stats.report_counts() async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: """ @@ -357,18 +347,7 @@ class HelpChannels(commands.Cog): ) await self.unclaim_channel(channel) - - self.bot.stats.incr(f"help.dormant_calls.{caller}") - - in_use_time = await _channel.get_in_use_time(channel.id) - if in_use_time: - self.bot.stats.timing("help.in_use_time", in_use_time) - - unanswered = await _caches.unanswered.get(channel.id) - if unanswered: - self.bot.stats.incr("help.sessions.unanswered") - elif unanswered is not None: - self.bot.stats.incr("help.sessions.answered") + await _stats.report_complete_session(channel.id, caller) log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") @@ -379,7 +358,7 @@ class HelpChannels(commands.Cog): log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) - self.report_stats() + _stats.report_counts() async def unclaim_channel(self, channel: discord.TextChannel) -> None: """ @@ -416,7 +395,7 @@ class HelpChannels(commands.Cog): log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) - self.report_stats() + _stats.report_counts() @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py new file mode 100644 index 000000000..8e6ff8fe5 --- /dev/null +++ b/bot/exts/help_channels/_stats.py @@ -0,0 +1,41 @@ +import logging + +from more_itertools import ilen + +import bot +from bot import constants +from bot.exts.help_channels import _caches, _channel + +log = logging.getLogger(__name__) + + +def report_counts() -> None: + """Report channel count stats of each help category.""" + for name in ("in_use", "available", "dormant"): + id_ = getattr(constants.Categories, f"help_{name}") + category = bot.instance.get_channel(id_) + + if category: + total = ilen(_channel.get_category_channels(category)) + bot.instance.stats.gauge(f"help.total.{name}", total) + else: + log.warning(f"Couldn't find category {name!r} to track channel count stats.") + + +async def report_complete_session(channel_id: int, caller: str) -> None: + """ + Report stats for a completed help session channel `channel_id`. + + `caller` is used to track stats on how `channel_id` was unclaimed (either 'auto' or 'command'). + """ + bot.instance.stats.incr(f"help.dormant_calls.{caller}") + + in_use_time = await _channel.get_in_use_time(channel_id) + if in_use_time: + bot.instance.stats.timing("help.in_use_time", in_use_time) + + unanswered = await _caches.unanswered.get(channel_id) + if unanswered: + bot.instance.stats.incr("help.sessions.unanswered") + elif unanswered is not None: + bot.instance.stats.incr("help.sessions.answered") -- cgit v1.2.3 From 573839ba58646ec49444087c6477be502553c060 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 12:11:13 -0800 Subject: HelpChannels: remove obsolete channel position trace log --- bot/exts/help_channels/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 854696f87..026832d46 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -349,7 +349,6 @@ class HelpChannels(commands.Cog): await self.unclaim_channel(channel) await _stats.report_complete_session(channel.id, caller) - log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") embed = discord.Embed(description=_message.DORMANT_MSG) await channel.send(embed=embed) -- cgit v1.2.3 From 6dbeecd01acae5661502755ed28949a4ece2b687 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 12:25:29 -0800 Subject: HelpChannels: refactor channel unclaiming Narrow the scope of `move_to_dormant` to just moving the channel. Following the design of `claim_channel`, make `unclaim_channel` handle cooldowns and unpinning. --- bot/exts/help_channels/_cog.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 026832d46..bc42b5c2a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -181,7 +181,7 @@ class HelpChannels(commands.Cog): return if await self.dormant_check(ctx): - await self.move_to_dormant(ctx.channel, "command") + await self.unclaim_channel(ctx.channel, "command") self.scheduler.cancel(ctx.channel.id) async def get_available_candidate(self) -> discord.TextChannel: @@ -228,7 +228,7 @@ class HelpChannels(commands.Cog): elif missing < 0: log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") for channel in channels[:abs(missing)]: - await self.move_to_dormant(channel, "auto") + await self.unclaim_channel(channel, "auto") async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" @@ -301,7 +301,7 @@ class HelpChannels(commands.Cog): f"and will be made dormant." ) - await self.move_to_dormant(channel, "auto") + await self.unclaim_channel(channel, "auto") else: # Cancel the existing task, if any. if has_task: @@ -333,42 +333,36 @@ class HelpChannels(commands.Cog): _stats.report_counts() - async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: - """ - Make the `channel` dormant. - - A caller argument is provided for metrics. - """ + async def move_to_dormant(self, channel: discord.TextChannel) -> None: + """Make the `channel` dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_dormant, ) - await self.unclaim_channel(channel) - await _stats.report_complete_session(channel.id, caller) - log.trace(f"Sending dormant message for #{channel} ({channel.id}).") embed = discord.Embed(description=_message.DORMANT_MSG) await channel.send(embed=embed) - await _message.unpin(channel) - log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) + _stats.report_counts() - async def unclaim_channel(self, channel: discord.TextChannel) -> None: + async def unclaim_channel(self, channel: discord.TextChannel, caller: str) -> None: """ - Mark the channel as unclaimed and remove the cooldown role from the claimant if needed. + Unclaim an in-use help `channel` to make it dormant. - The role is only removed if they have no claimed channels left once the current one is unclaimed. - This method also handles canceling the automatic removal of the cooldown role. + Unpin the claimant's question message and move the channel to the Dormant category. + Remove the cooldown role from the channel claimant if they have no other channels claimed. + Cancel the scheduled cooldown role removal task. + + `caller` is used to track stats on how `channel` was unclaimed (either 'auto' or 'command'). """ claimant_id = await _caches.claimants.pop(channel.id) - # Ignore missing task when cooldown has passed but the channel still isn't dormant. + # Ignore missing tasks because a channel may still be dormant after the cooldown expires. if claimant_id in self.scheduler: self.scheduler.cancel(claimant_id) @@ -381,6 +375,11 @@ class HelpChannels(commands.Cog): if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): await _cooldown.remove_cooldown_role(claimant) + await _message.unpin(channel) + await _stats.report_complete_session(channel.id, caller) + + await self.move_to_dormant(channel) + async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -- cgit v1.2.3 From 361e34529c1dc6c88620842ba92226ce7c87a6e1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 15:01:39 -0800 Subject: Lock: support waiting until a lock is available It's been determined that asyncio.Lock is safe to use in such manner. Therefore, replace LockGuard entirely with asyncio.Lock. --- bot/utils/lock.py | 62 ++++++++++++++++++++++--------------------------------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 7aaafbc88..e44776340 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,3 +1,4 @@ +import asyncio import inspect import logging from collections import defaultdict @@ -16,39 +17,21 @@ _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] ResourceId = Union[Hashable, _IdCallable] -class LockGuard: - """ - A context manager which acquires and releases a lock (mutex). - - Raise RuntimeError if trying to acquire a locked lock. - """ - - def __init__(self): - self._locked = False - - @property - def locked(self) -> bool: - """Return True if currently locked or False if unlocked.""" - return self._locked - - def __enter__(self): - if self._locked: - raise RuntimeError("Cannot acquire a locked lock.") - - self._locked = True - - def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001 - self._locked = False - return False # Indicate any raised exception shouldn't be suppressed. - - -def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable: +def lock( + namespace: Hashable, + resource_id: ResourceId, + *, + raise_error: bool = False, + wait: bool = False, +) -> Callable: """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. - If any other mutually exclusive function currently holds the lock for a resource, do not run the - decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if - the lock cannot be acquired. + If `wait` is True, wait until the lock becomes available. Otherwise, if any other mutually + exclusive function currently holds the lock for a resource, do not run the decorated function + and return None. + + If `raise_error` is True, raise `LockedResourceError` if the lock cannot be acquired. `namespace` is an identifier used to prevent collisions among resource IDs. @@ -78,15 +61,19 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa else: id_ = resource_id - log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + log.trace(f"{name}: getting the lock object for resource {namespace!r}:{id_!r}") # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock_guard = locks.setdefault(id_, LockGuard()) - - if not lock_guard.locked: - log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - with lock_guard: + lock_ = locks.setdefault(id_, asyncio.Lock()) + + # It's safe to check an asyncio.Lock is free before acquiring it because: + # 1. Synchronous code like `if not lock_.locked()` does not yield execution + # 2. `asyncio.Lock.acquire()` does not internally await anything if the lock is free + # 3. awaits only yield execution to the event loop at actual I/O boundaries + if wait or not lock_.locked(): + log.debug(f"{name}: acquiring lock for resource {namespace!r}:{id_!r}...") + async with lock_: return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") @@ -103,6 +90,7 @@ def lock_arg( func: Callable[[Any], _IdCallableReturn] = None, *, raise_error: bool = False, + wait: bool = False, ) -> Callable: """ Apply the `lock` decorator using the value of the arg at the given name/position as the ID. @@ -110,5 +98,5 @@ def lock_arg( `func` is an optional callable or awaitable which will return the ID given the argument value. See `lock` docs for more information. """ - decorator_func = partial(lock, namespace, raise_error=raise_error) + decorator_func = partial(lock, namespace, raise_error=raise_error, wait=wait) return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) -- cgit v1.2.3 From 35de5fbe64624091c0742c28f811a0ea6da7cd4a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 15:52:22 -0800 Subject: HelpChannels: fix race condition between claiming and unclaiming The race condition is when a user claims a channel while their other channel is being unclaimed. Specifically, it's while their cooldown is being removed. The lock ensures that either the cooldown will be re-applied after it's removed or that it won't be removed since `unclaim_channel` will see the user has another claimed channel. --- bot/exts/help_channels/_cog.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index bc42b5c2a..7ebec675a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -90,6 +90,7 @@ class HelpChannels(commands.Cog): @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) + @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True) async def claim_channel(self, message: discord.Message) -> None: """ Claim the channel in which the question `message` was sent. @@ -360,7 +361,20 @@ class HelpChannels(commands.Cog): `caller` is used to track stats on how `channel` was unclaimed (either 'auto' or 'command'). """ - claimant_id = await _caches.claimants.pop(channel.id) + claimant_id = await _caches.claimants.get(channel.id) + coroutine = self._unclaim_channel(channel, claimant_id, caller) + + # It could be possible that there is no claimant cached. In such case, it'd be useless and + # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. + if claimant_id is not None: + decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True) + coroutine = decorator(coroutine) + + return await coroutine + + async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, caller: str) -> None: + """Actual implementation of `unclaim_channel`. See that for full documentation.""" + await _caches.claimants.delete(channel.id) # Ignore missing tasks because a channel may still be dormant after the cooldown expires. if claimant_id in self.scheduler: -- cgit v1.2.3 From 918cdcebf97b754d0c72900503bd3aef96fe9dac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 16:52:10 -0800 Subject: Add asyncio.create_task wrapper which logs exceptions Normally exceptions are only logged when tasks are garbage collected. This wrapper will allow them to be logged immediately through a done callback. This is similar to how the Scheduler logs errors. --- bot/utils/scheduling.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 03f31d78f..4dd036e4f 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -155,3 +155,20 @@ class Scheduler: # Log the exception if one exists. if exception: self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) + + +def create_task(*args, **kwargs) -> asyncio.Task: + """Wrapper for `asyncio.create_task` which logs exceptions raised in the task.""" + task = asyncio.create_task(*args, **kwargs) + task.add_done_callback(_log_task_exception) + return task + + +def _log_task_exception(task: asyncio.Task) -> None: + """Retrieve and log the exception raised in `task` if one exists.""" + with contextlib.suppress(asyncio.CancelledError): + exception = task.exception() + # Log the exception if one exists. + if exception: + log = logging.getLogger(__name__) + log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception) -- cgit v1.2.3 From 180a2116eeca5fbabe81506a4a98f4569138be78 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 17:04:55 -0800 Subject: HelpChannels: make a channel available within the lock If the lock causes the function to abort, a new channel shouldn't be made available. However, the only way to know it's aborted from the outside would be through a return value or global variable. Neither seem as nice as just just using `create_task` within the lock to avoid having `move_to_available` hold the lock. --- bot/exts/help_channels/_cog.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 7ebec675a..e99dd92db 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,8 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats -from bot.utils import channel as channel_utils, lock -from bot.utils.scheduling import Scheduler +from bot.utils import channel as channel_utils, lock, scheduling log = logging.getLogger(__name__) @@ -60,7 +59,7 @@ class HelpChannels(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) + self.scheduler = scheduling.Scheduler(self.__class__.__name__) # Categories self.available_category: discord.CategoryChannel = None @@ -96,7 +95,7 @@ class HelpChannels(commands.Cog): Claim the channel in which the question `message` was sent. Move the channel to the In Use category and pin the `message`. Add a cooldown to the - claimant to prevent them from asking another question. + claimant to prevent them from asking another question. Lastly, make a new channel available. """ log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(message.channel) @@ -115,6 +114,9 @@ class HelpChannels(commands.Cog): await _caches.unanswered.set(message.channel.id, True) + # Not awaited because it may indefinitely hold the lock while waiting for a channel. + scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") + def create_channel_queue(self) -> asyncio.Queue: """ Return a queue of dormant channels to use for getting the next available channel. @@ -420,7 +422,6 @@ class HelpChannels(commands.Cog): if channel_utils.is_in_category(message.channel, constants.Categories.help_available): if not _channel.is_excluded_channel(message.channel): await self.claim_channel(message) - await self.move_to_available() # Not in a lock because it may wait indefinitely. else: await _message.check_for_answer(message) -- cgit v1.2.3 From d98a34ff042418c93b50f4128d16a0847b479083 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:07:22 -0800 Subject: HelpChannels: refactor the close command check --- bot/exts/help_channels/_cog.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e99dd92db..a15e6295e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -154,8 +154,12 @@ class HelpChannels(commands.Cog): log.debug(f"Creating a new dormant channel named {name}.") return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - async def dormant_check(self, ctx: commands.Context) -> bool: - """Return True if the user is the help channel claimant or passes the role check.""" + async def close_check(self, ctx: commands.Context) -> bool: + """Return True if the channel is in use and the user is the claimant or has a whitelisted role.""" + if ctx.channel.category != self.in_use_category: + log.debug(f"{ctx.author} invoked command 'close' outside an in-use help channel") + return False + if await _caches.claimants.get(ctx.channel.id) == ctx.author.id: log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") self.bot.stats.incr("help.dormant_invoke.claimant") @@ -174,16 +178,12 @@ class HelpChannels(commands.Cog): """ Make the current in-use help channel dormant. - Make the channel dormant if the user passes the `dormant_check`, + Make the channel dormant if the user passes the `close_check`, delete the message that invoked this. """ - log.trace("close command invoked; checking if the channel is in-use.") - - if ctx.channel.category != self.in_use_category: - log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") - return - - if await self.dormant_check(ctx): + # Don't use a discord.py check because the check needs to fail silently. + if await self.close_check(ctx): + log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") await self.unclaim_channel(ctx.channel, "command") self.scheduler.cancel(ctx.channel.id) -- cgit v1.2.3 From 6b60dbcdd695220d2e02349d4707197253095639 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:11:49 -0800 Subject: HelpChannels: clarify close command docstring Referencing internal functions in public-facing documentation is not helpful to users. --- bot/exts/help_channels/_cog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a15e6295e..2b7cfcba7 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -178,8 +178,7 @@ class HelpChannels(commands.Cog): """ Make the current in-use help channel dormant. - Make the channel dormant if the user passes the `close_check`, - delete the message that invoked this. + May only be invoked by the channel's claimant or by staff. """ # Don't use a discord.py check because the check needs to fail silently. if await self.close_check(ctx): -- cgit v1.2.3 From 6b02c5cb7c4792e9b7641a80965aa56e43ee6e2a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:22:04 -0800 Subject: HelpChannels: unschedule the dormant task in unclaim_channel Ensure the cancellation will be under the lock once the lock is added. --- bot/exts/help_channels/_cog.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 2b7cfcba7..9d80e193e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -184,7 +184,6 @@ class HelpChannels(commands.Cog): if await self.close_check(ctx): log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") await self.unclaim_channel(ctx.channel, "command") - self.scheduler.cancel(ctx.channel.id) async def get_available_candidate(self) -> discord.TextChannel: """ @@ -392,9 +391,13 @@ class HelpChannels(commands.Cog): await _message.unpin(channel) await _stats.report_complete_session(channel.id, caller) - await self.move_to_dormant(channel) + # Cancel the task that makes the channel dormant only if called by the close command. + # In other cases, the task is either already done or not-existent. + if caller == "command": + self.scheduler.cancel(channel.id) + async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -- cgit v1.2.3 From 3fb3d915bedee9dc3daed11fe2a39588b22fedf9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:29:20 -0800 Subject: HelpChannels: change caller str parameter to a bool Booleans are less error-prone than strings. --- bot/exts/help_channels/_cog.py | 18 +++++++++--------- bot/exts/help_channels/_stats.py | 5 +++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 9d80e193e..bea5fd9c0 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -183,7 +183,7 @@ class HelpChannels(commands.Cog): # Don't use a discord.py check because the check needs to fail silently. if await self.close_check(ctx): log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") - await self.unclaim_channel(ctx.channel, "command") + await self.unclaim_channel(ctx.channel, is_auto=False) async def get_available_candidate(self) -> discord.TextChannel: """ @@ -229,7 +229,7 @@ class HelpChannels(commands.Cog): elif missing < 0: log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") for channel in channels[:abs(missing)]: - await self.unclaim_channel(channel, "auto") + await self.unclaim_channel(channel) async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" @@ -302,7 +302,7 @@ class HelpChannels(commands.Cog): f"and will be made dormant." ) - await self.unclaim_channel(channel, "auto") + await self.unclaim_channel(channel) else: # Cancel the existing task, if any. if has_task: @@ -351,7 +351,7 @@ class HelpChannels(commands.Cog): _stats.report_counts() - async def unclaim_channel(self, channel: discord.TextChannel, caller: str) -> None: + async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None: """ Unclaim an in-use help `channel` to make it dormant. @@ -359,10 +359,10 @@ class HelpChannels(commands.Cog): Remove the cooldown role from the channel claimant if they have no other channels claimed. Cancel the scheduled cooldown role removal task. - `caller` is used to track stats on how `channel` was unclaimed (either 'auto' or 'command'). + Set `is_auto` to True if the channel was automatically closed or False if manually closed. """ claimant_id = await _caches.claimants.get(channel.id) - coroutine = self._unclaim_channel(channel, claimant_id, caller) + coroutine = self._unclaim_channel(channel, claimant_id, is_auto) # It could be possible that there is no claimant cached. In such case, it'd be useless and # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. @@ -372,7 +372,7 @@ class HelpChannels(commands.Cog): return await coroutine - async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, caller: str) -> None: + async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) @@ -390,12 +390,12 @@ class HelpChannels(commands.Cog): await _cooldown.remove_cooldown_role(claimant) await _message.unpin(channel) - await _stats.report_complete_session(channel.id, caller) + await _stats.report_complete_session(channel.id, is_auto) await self.move_to_dormant(channel) # Cancel the task that makes the channel dormant only if called by the close command. # In other cases, the task is either already done or not-existent. - if caller == "command": + if not is_auto: self.scheduler.cancel(channel.id) async def move_to_in_use(self, channel: discord.TextChannel) -> None: diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py index 8e6ff8fe5..b8778e7d9 100644 --- a/bot/exts/help_channels/_stats.py +++ b/bot/exts/help_channels/_stats.py @@ -22,12 +22,13 @@ def report_counts() -> None: log.warning(f"Couldn't find category {name!r} to track channel count stats.") -async def report_complete_session(channel_id: int, caller: str) -> None: +async def report_complete_session(channel_id: int, is_auto: bool) -> None: """ Report stats for a completed help session channel `channel_id`. - `caller` is used to track stats on how `channel_id` was unclaimed (either 'auto' or 'command'). + Set `is_auto` to True if the channel was automatically closed or False if manually closed. """ + caller = "auto" if is_auto else "command" bot.instance.stats.incr(f"help.dormant_calls.{caller}") in_use_time = await _channel.get_in_use_time(channel_id) -- cgit v1.2.3 From e297fb26a5f476050729a93f32d871e46ea1316d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:47:37 -0800 Subject: HelpChannels: fix race condition when unclaiming a channel Place a channel-specific lock on `unclaim_channel`. If both the dormant task and the command simultaneously unclaim a channel, one of them will silently be aborted. Fix #1341 --- bot/exts/help_channels/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index bea5fd9c0..f866e98af 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -351,6 +351,7 @@ class HelpChannels(commands.Cog): _stats.report_counts() + @lock.lock_arg(f"{NAMESPACE}.unclaim", "channel") async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None: """ Unclaim an in-use help `channel` to make it dormant. -- cgit v1.2.3 From 8d50a090baa1f2b64c9ba4e9f1830c5b2a2b80a0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 Jan 2021 17:10:59 -0800 Subject: HelpChannels: fix manual use of lock decorator --- bot/exts/help_channels/_cog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index f866e98af..3bdd896f2 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -363,15 +363,15 @@ class HelpChannels(commands.Cog): Set `is_auto` to True if the channel was automatically closed or False if manually closed. """ claimant_id = await _caches.claimants.get(channel.id) - coroutine = self._unclaim_channel(channel, claimant_id, is_auto) + _unclaim_channel = self._unclaim_channel # It could be possible that there is no claimant cached. In such case, it'd be useless and # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. if claimant_id is not None: decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True) - coroutine = decorator(coroutine) + _unclaim_channel = decorator(_unclaim_channel) - return await coroutine + return await _unclaim_channel(channel, claimant_id, is_auto) async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" -- cgit v1.2.3 From 86babd4a90414d246f1b14cad80a367cc155f2ac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 Jan 2021 17:17:47 -0800 Subject: HelpChannels: fix unclaim exiting too early if claimant is None --- bot/exts/help_channels/_cog.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 3bdd896f2..0995c8a79 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -384,10 +384,8 @@ class HelpChannels(commands.Cog): claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) if claimant is None: log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") - return - - # Remove the cooldown role if the claimant has no other channels left - if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): + elif not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): + # Remove the cooldown role if the claimant has no other channels left await _cooldown.remove_cooldown_role(claimant) await _message.unpin(channel) -- cgit v1.2.3 From bc381bcf16cef694755ff4191d4ee95d7691798a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 Jan 2021 17:21:56 -0800 Subject: Set asyncio logger level to INFO If asyncio's debug mode is enabled, the asyncio logger's level gets set to DEBUG. While other features of the debug mode are useful, the DEBUG log level spams generally irrelevant stuff. --- bot/log.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/log.py b/bot/log.py index 0935666d1..e92233a33 100644 --- a/bot/log.py +++ b/bot/log.py @@ -54,6 +54,9 @@ def setup() -> None: logging.getLogger("chardet").setLevel(logging.WARNING) logging.getLogger("async_rediscache").setLevel(logging.WARNING) + # Set back to the default of INFO even if asyncio's debug mode is enabled. + logging.getLogger("asyncio").setLevel(logging.INFO) + def setup_sentry() -> None: """Set up the Sentry logging integrations.""" -- cgit v1.2.3 From 7fa8c00d77acee310d011e961b64e51f6bb30f20 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Tue, 5 Jan 2021 18:37:27 +0000 Subject: Re-lock Pipfile --- Pipfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 6f0f762f6..6da9588ec 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f8392181c82292d0a30a7b128bac4a5ed0ea517610e940743e612118b5da94b5" + "sha256": "4b0241e9e78d021533671efa48a4f9972842c88dde14dbe89fb480eeb188efee" }, "pipfile-spec": 6, "requires": { @@ -933,11 +933,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1", - "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df" + "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055", + "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e" ], "index": "pypi", - "version": "==2.4.1" + "version": "==2.5.0" }, "flake8-bugbear": { "hashes": [ -- cgit v1.2.3 From 431afbfa48149d315d92c7c3ffb7974e5ae20618 Mon Sep 17 00:00:00 2001 From: xithrius Date: Tue, 5 Jan 2021 21:33:32 -0800 Subject: If user is a staff member, no command suggestions. --- bot/exts/backend/error_handler.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index fa3f706e6..412c42df5 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -9,7 +9,7 @@ from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours, Icons +from bot.constants import Channels, Colours, Icons, STAFF_ROLES from bot.converters import TagNameConverter from bot.errors import LockedResourceError from bot.utils.checks import InWhitelistCheckFailure @@ -159,12 +159,13 @@ class ErrorHandler(Cog): with contextlib.suppress(ResponseCodeError): await ctx.invoke(tags_get_command, tag_name=tag_name) - tags_cog = self.bot.get_cog("Tags") - command_name = ctx.invoked_with - sent = await tags_cog.display_tag(ctx, command_name) + if not any(role.id in STAFF_ROLES for role in ctx.author.roles): + tags_cog = self.bot.get_cog("Tags") + command_name = ctx.invoked_with + sent = await tags_cog.display_tag(ctx, command_name) - if not sent: - await self.send_command_suggestion(ctx, command_name) + if not sent: + await self.send_command_suggestion(ctx, command_name) # Return to not raise the exception return -- cgit v1.2.3 From f2691982ae58e648693174a99af2560d55b43d00 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 Jan 2021 08:51:53 +0200 Subject: Remove unnecessary pass statement Co-authored-by: Dennis Pham --- bot/errors.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index a3484830b..ea6fb36ec 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -22,5 +22,3 @@ class LockedResourceError(RuntimeError): class BrandingError(Exception): """Exception raised by the BrandingManager cog.""" - - pass -- cgit v1.2.3 From eed2abedc0df6a72a990c650f38ee0a1d3088743 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 Jan 2021 08:57:23 +0200 Subject: Remove sir lancebot names from seasons --- bot/seasons.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/bot/seasons.py b/bot/seasons.py index 3b39f03cd..d4a9dfcc5 100644 --- a/bot/seasons.py +++ b/bot/seasons.py @@ -20,7 +20,6 @@ class SeasonBase: """ season_name: str = "Evergreen" - bot_name: str = "SeasonalBot" colour: str = Colours.soft_green description: str = "The default season!" @@ -34,7 +33,6 @@ class Christmas(SeasonBase): """Branding for December.""" season_name = "Festive season" - bot_name = "MerryBot" colour = Colours.soft_red description = ( @@ -51,7 +49,6 @@ class Easter(SeasonBase): """Branding for April.""" season_name = "Easter" - bot_name = "BunnyBot" colour = Colours.bright_green description = ( @@ -68,7 +65,6 @@ class Halloween(SeasonBase): """Branding for October.""" season_name = "Halloween" - bot_name = "NeonBot" colour = Colours.orange description = "Trick or treat?!" @@ -82,7 +78,6 @@ class Pride(SeasonBase): """Branding for June.""" season_name = "Pride" - bot_name = "ProudBot" colour = Colours.pink description = ( @@ -102,7 +97,6 @@ class Valentines(SeasonBase): """Branding for February.""" season_name = "Valentines" - bot_name = "TenderBot" colour = Colours.pink description = "Love is in the air!" @@ -116,7 +110,6 @@ class Wildcard(SeasonBase): """Branding for August.""" season_name = "Wildcard" - bot_name = "RetroBot" colour = Colours.purple description = "A season full of surprises!" -- cgit v1.2.3 From 1496557b757b6cea5448110ccd5143e5a5e82e61 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 Jan 2021 09:35:58 +0200 Subject: Refactor branding manager to keep everything in one directory To keep everything at one place, moved all branding manager special things to one module. --- bot/constants.py | 29 -- bot/decorators.py | 23 +- bot/errors.py | 4 - bot/exts/backend/branding.py | 569 ------------------------------- bot/exts/backend/branding/__init__.py | 7 + bot/exts/backend/branding/_cog.py | 560 ++++++++++++++++++++++++++++++ bot/exts/backend/branding/_constants.py | 49 +++ bot/exts/backend/branding/_decorators.py | 27 ++ bot/exts/backend/branding/_errors.py | 2 + bot/exts/backend/branding/_seasons.py | 175 ++++++++++ bot/seasons.py | 174 ---------- 11 files changed, 821 insertions(+), 798 deletions(-) delete mode 100644 bot/exts/backend/branding.py create mode 100644 bot/exts/backend/branding/__init__.py create mode 100644 bot/exts/backend/branding/_cog.py create mode 100644 bot/exts/backend/branding/_constants.py create mode 100644 bot/exts/backend/branding/_decorators.py create mode 100644 bot/exts/backend/branding/_errors.py create mode 100644 bot/exts/backend/branding/_seasons.py delete mode 100644 bot/seasons.py diff --git a/bot/constants.py b/bot/constants.py index ded6e386d..41a538802 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -651,35 +651,6 @@ class Event(Enum): voice_state_update = "voice_state_update" -class Month(IntEnum): - JANUARY = 1 - FEBRUARY = 2 - MARCH = 3 - APRIL = 4 - MAY = 5 - JUNE = 6 - JULY = 7 - AUGUST = 8 - SEPTEMBER = 9 - OCTOBER = 10 - NOVEMBER = 11 - DECEMBER = 12 - - def __str__(self) -> str: - return self.name.title() - - -class AssetType(Enum): - """ - Discord media assets. - - The values match exactly the kwarg keys that can be passed to `Guild.edit`. - """ - - BANNER = "banner" - SERVER_ICON = "icon" - - # Debug mode DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") diff --git a/bot/decorators.py b/bot/decorators.py index 0b50cc365..063c8f878 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,5 +1,4 @@ import asyncio -import functools import logging import typing as t from contextlib import suppress @@ -9,7 +8,7 @@ from discord import Member, NotFound from discord.ext import commands from discord.ext.commands import Cog, Context -from bot.constants import Channels, DEBUG_MODE, RedirectOutput +from bot.constants import Channels, RedirectOutput from bot.utils import function from bot.utils.checks import in_whitelist_check @@ -154,23 +153,3 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: await func(*args, **kwargs) return wrapper return decorator - - -def mock_in_debug(return_value: t.Any) -> t.Callable: - """ - Short-circuit function execution if in debug mode and return `return_value`. - - The original function name, and the incoming args and kwargs are DEBUG level logged - upon each call. This is useful for expensive operations, i.e. media asset uploads - that are prone to rate-limits but need to be tested extensively. - """ - def decorator(func: t.Callable) -> t.Callable: - @functools.wraps(func) - async def wrapped(*args, **kwargs) -> t.Any: - """Short-circuit and log if in debug mode.""" - if DEBUG_MODE: - log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}") - return return_value - return await func(*args, **kwargs) - return wrapped - return decorator diff --git a/bot/errors.py b/bot/errors.py index ea6fb36ec..65d715203 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -18,7 +18,3 @@ class LockedResourceError(RuntimeError): f"Cannot operate on {self.type.lower()} `{self.id}`; " "it is currently locked and in use by another operation." ) - - -class BrandingError(Exception): - """Exception raised by the BrandingManager cog.""" diff --git a/bot/exts/backend/branding.py b/bot/exts/backend/branding.py deleted file mode 100644 index 7ce85aab2..000000000 --- a/bot/exts/backend/branding.py +++ /dev/null @@ -1,569 +0,0 @@ -import asyncio -import itertools -import logging -import random -import typing as t -from datetime import datetime, time, timedelta - -import arrow -import async_timeout -import discord -from async_rediscache import RedisCache -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import AssetType, Branding, Colours, Emojis, Guild, Keys, MODERATION_ROLES -from bot.decorators import in_whitelist, mock_in_debug -from bot.errors import BrandingError -from bot.seasons import SeasonBase, get_all_seasons, get_current_season, get_season - -log = logging.getLogger(__name__) - -STATUS_OK = 200 # HTTP status code - -FILE_BANNER = "banner.png" -FILE_AVATAR = "avatar.png" -SERVER_ICONS = "server_icons" - -BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" - -PARAMS = {"ref": "master"} # Target branch -HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3 - -# A GitHub token is not necessary for the cog to operate, -# unauthorized requests are however limited to 60 per hour -if Keys.github: - HEADERS["Authorization"] = f"token {Keys.github}" - - -class GitHubFile(t.NamedTuple): - """ - Represents a remote file on GitHub. - - The `sha` hash is kept so that we can determine that a file has changed, - despite its filename remaining unchanged. - """ - - download_url: str - path: str - sha: str - - -def pretty_files(files: t.Iterable[GitHubFile]) -> str: - """Provide a human-friendly representation of `files`.""" - return "\n".join(file.path for file in files) - - -def time_until_midnight() -> timedelta: - """ - Determine amount of time until the next-up UTC midnight. - - The exact `midnight` moment is actually delayed to 5 seconds after, in order - to avoid potential problems due to imprecise sleep. - """ - now = datetime.utcnow() - tomorrow = now + timedelta(days=1) - midnight = datetime.combine(tomorrow, time(second=5)) - - return midnight - now - - -class BrandingManager(commands.Cog): - """ - Manages the guild's branding. - - The purpose of this cog is to help automate the synchronization of the branding - repository with the guild. It is capable of discovering assets in the repository - via GitHub's API, resolving download urls for them, and delegating - to the `bot` instance to upload them to the guild. - - BrandingManager is designed to be entirely autonomous. Its `daemon` background task awakens - once a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single - season. The daemon can be turned on and off via the `daemon` cmd group. The value set via - its `start` and `stop` commands is persisted across sessions. If turned on, the daemon will - automatically start on the next bot start-up. Otherwise, it will wait to be started manually. - - All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can - also be invoked manually, via the following API: - - branding list - - Show all available seasons - - branding set - - Set the cog's internal state to represent `season_name`, if it exists - - If no `season_name` is given, set chronologically current season - - This will not automatically apply the season's branding to the guild, - the cog's state can be detached from the guild - - Seasons can therefore be 'previewed' using this command - - branding info - - View detailed information about resolved assets for current season - - branding refresh - - Refresh internal state, i.e. synchronize with branding repository - - branding apply - - Apply the current internal state to the guild, i.e. upload the assets - - branding cycle - - If there are multiple available icons for current season, randomly pick - and apply the next one - - The daemon calls these methods autonomously as appropriate. The use of this cog - is locked to moderation roles. As it performs media asset uploads, it is prone to - rate-limits - the `apply` command should be used with caution. The `set` command can, - however, be used freely to 'preview' seasonal branding and check whether paths have been - resolved as appropriate. - - While the bot is in debug mode, it will 'mock' asset uploads by logging the passed - download urls and pretending that the upload was successful. Make use of this - to test this cog's behaviour. - """ - - current_season: t.Type[SeasonBase] - - banner: t.Optional[GitHubFile] - - available_icons: t.List[GitHubFile] - remaining_icons: t.List[GitHubFile] - - days_since_cycle: t.Iterator - - daemon: t.Optional[asyncio.Task] - - # Branding configuration - branding_configuration = RedisCache() - - def __init__(self, bot: Bot) -> None: - """ - Assign safe default values on init. - - At this point, we don't have information about currently available branding. - Most of these attributes will be overwritten once the daemon connects, or once - the `refresh` command is used. - """ - self.bot = bot - self.current_season = get_current_season() - - self.banner = None - - self.available_icons = [] - self.remaining_icons = [] - - self.days_since_cycle = itertools.cycle([None]) - - should_run = self.bot.loop.run_until_complete(self.branding_configuration.get("daemon_active")) - - if should_run: - self.daemon = self.bot.loop.create_task(self._daemon_func()) - else: - self.daemon = None - - @property - def _daemon_running(self) -> bool: - """True if the daemon is currently active, False otherwise.""" - return self.daemon is not None and not self.daemon.done() - - async def _daemon_func(self) -> None: - """ - Manage all automated behaviour of the BrandingManager cog. - - Once a day, the daemon will perform the following tasks: - - Update `current_season` - - Poll GitHub API to see if the available branding for `current_season` has changed - - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname) - - Check whether it's time to cycle guild icons - - The internal loop runs once when activated, then periodically at the time - given by `time_until_midnight`. - - All method calls in the internal loop are considered safe, i.e. no errors propagate - to the daemon's loop. The daemon itself does not perform any error handling on its own. - """ - await self.bot.wait_until_guild_available() - - while True: - self.current_season = get_current_season() - branding_changed = await self.refresh() - - if branding_changed: - await self.apply() - - elif next(self.days_since_cycle) == Branding.cycle_frequency: - await self.cycle() - - until_midnight = time_until_midnight() - await asyncio.sleep(until_midnight.total_seconds()) - - async def _info_embed(self) -> discord.Embed: - """Make an informative embed representing current season.""" - info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour) - - # If we're in a non-evergreen season, also show active months - if self.current_season is not SeasonBase: - title = f"{self.current_season.season_name} ({', '.join(str(m) for m in self.current_season.months)})" - else: - title = self.current_season.season_name - - # Use the author field to show the season's name and avatar if available - info_embed.set_author(name=title) - - banner = self.banner.path if self.banner is not None else "Unavailable" - info_embed.add_field(name="Banner", value=banner, inline=False) - - icons = pretty_files(self.available_icons) or "Unavailable" - info_embed.add_field(name="Available icons", value=icons, inline=False) - - # Only display cycle frequency if we're actually cycling - if len(self.available_icons) > 1 and Branding.cycle_frequency: - info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}") - - return info_embed - - async def _reset_remaining_icons(self) -> None: - """Set `remaining_icons` to a shuffled copy of `available_icons`.""" - self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons)) - - async def _reset_days_since_cycle(self) -> None: - """ - Reset the `days_since_cycle` iterator based on configured frequency. - - If the current season only has 1 icon, or if `Branding.cycle_frequency` is falsey, - the iterator will always yield None. This signals that the icon shouldn't be cycled. - - Otherwise, it will yield ints in range [1, `Branding.cycle_frequency`] indefinitely. - When the iterator yields a value equal to `Branding.cycle_frequency`, it is time to cycle. - """ - if len(self.available_icons) > 1 and Branding.cycle_frequency: - sequence = range(1, Branding.cycle_frequency + 1) - else: - sequence = [None] - - self.days_since_cycle = itertools.cycle(sequence) - - async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GitHubFile]: - """ - Get files at `path` in the branding repository. - - If `include_dirs` is False (default), only returns files at `path`. - Otherwise, will return both files and directories. Never returns symlinks. - - Return dict mapping from filename to corresponding `GitHubFile` instance. - This may return an empty dict if the response status is non-200, - or if the target directory is empty. - """ - url = f"{BRANDING_URL}/{path}" - async with self.bot.http_session.get(url, headers=HEADERS, params=PARAMS) as resp: - # Short-circuit if we get non-200 response - if resp.status != STATUS_OK: - log.error(f"GitHub API returned non-200 response: {resp}") - return {} - directory = await resp.json() # Directory at `path` - - allowed_types = {"file", "dir"} if include_dirs else {"file"} - return { - file["name"]: GitHubFile(file["download_url"], file["path"], file["sha"]) - for file in directory - if file["type"] in allowed_types - } - - async def refresh(self) -> bool: - """ - Synchronize available assets with branding repository. - - If the current season is not the evergreen, and lacks at least one asset, - we use the evergreen seasonal dir as fallback for missing assets. - - Finally, if neither the seasonal nor fallback branding directories contain - an asset, it will simply be ignored. - - Return True if the branding has changed. This will be the case when we enter - a new season, or when something changes in the current seasons's directory - in the branding repository. - """ - old_branding = (self.banner, self.available_icons) - seasonal_dir = await self._get_files(self.current_season.branding_path, include_dirs=True) - - # Only make a call to the fallback directory if there is something to be gained - branding_incomplete = any( - asset not in seasonal_dir - for asset in (FILE_BANNER, FILE_AVATAR, SERVER_ICONS) - ) - if branding_incomplete and self.current_season is not SeasonBase: - fallback_dir = await self._get_files(SeasonBase.branding_path, include_dirs=True) - else: - fallback_dir = {} - - # Resolve assets in this directory, None is a safe value - self.banner = seasonal_dir.get(FILE_BANNER) or fallback_dir.get(FILE_BANNER) - - # Now resolve server icons by making a call to the proper sub-directory - if SERVER_ICONS in seasonal_dir: - icons_dir = await self._get_files(f"{self.current_season.branding_path}/{SERVER_ICONS}") - self.available_icons = list(icons_dir.values()) - - elif SERVER_ICONS in fallback_dir: - icons_dir = await self._get_files(f"{SeasonBase.branding_path}/{SERVER_ICONS}") - self.available_icons = list(icons_dir.values()) - - else: - self.available_icons = [] # This should never be the case, but an empty list is a safe value - - # GitHubFile instances carry a `sha` attr so this will pick up if a file changes - branding_changed = old_branding != (self.banner, self.available_icons) - - if branding_changed: - log.info(f"New branding detected (season: {self.current_season.season_name})") - await self._reset_remaining_icons() - await self._reset_days_since_cycle() - - return branding_changed - - async def cycle(self) -> bool: - """ - Apply the next-up server icon. - - Returns True if an icon is available and successfully gets applied, False otherwise. - """ - if not self.available_icons: - log.info("Cannot cycle: no icons for this season") - return False - - if not self.remaining_icons: - log.info("Reset & shuffle remaining icons") - await self._reset_remaining_icons() - - next_up = self.remaining_icons.pop(0) - success = await self.set_icon(next_up.download_url) - - return success - - async def apply(self) -> t.List[str]: - """ - Apply current branding to the guild and bot. - - This delegates to the bot instance to do all the work. We only provide download urls - for available assets. Assets unavailable in the branding repo will be ignored. - - Returns a list of names of all failed assets. An asset is considered failed - if it isn't found in the branding repo, or if something goes wrong while the - bot is trying to apply it. - - An empty list denotes that all assets have been applied successfully. - """ - report = {asset: False for asset in ("banner", "icon")} - - if self.banner is not None: - report["banner"] = await self.set_banner(self.banner.download_url) - - report["icon"] = await self.cycle() - - failed_assets = [asset for asset, succeeded in report.items() if not succeeded] - return failed_assets - - @in_whitelist(roles=MODERATION_ROLES) - @commands.group(name="branding") - async def branding_cmds(self, ctx: commands.Context) -> None: - """Manual branding control.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @branding_cmds.command(name="list", aliases=["ls"]) - async def branding_list(self, ctx: commands.Context) -> None: - """List all available seasons and branding sources.""" - embed = discord.Embed(title="Available seasons", colour=Colours.soft_green) - - for season in get_all_seasons(): - if season is SeasonBase: - active_when = "always" - else: - active_when = f"in {', '.join(str(m) for m in season.months)}" - - description = ( - f"Active {active_when}\n" - f"Branding: {season.branding_path}" - ) - embed.add_field(name=season.season_name, value=description, inline=False) - - await ctx.send(embed=embed) - - @branding_cmds.command(name="set") - async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None: - """ - Manually set season, or reset to current if none given. - - Season search is a case-less comparison against both seasonal class name, - and its `season_name` attr. - - This only pre-loads the cog's internal state to the chosen season, but does not - automatically apply the branding. As that is an expensive operation, the `apply` - command must be called explicitly after this command finishes. - - This means that this command can be used to 'preview' a season gathering info - about its available assets, without applying them to the guild. - - If the daemon is running, it will automatically reset the season to current when - it wakes up. The season set via this command can therefore remain 'detached' from - what it should be - the daemon will make sure that it's set back properly. - """ - if season_name is None: - new_season = get_current_season() - else: - new_season = get_season(season_name) - if new_season is None: - raise BrandingError("No such season exists") - - if self.current_season is new_season: - raise BrandingError(f"Season {self.current_season.season_name} already active") - - self.current_season = new_season - await self.branding_refresh(ctx) - - @branding_cmds.command(name="info", aliases=["status"]) - async def branding_info(self, ctx: commands.Context) -> None: - """ - Show available assets for current season. - - This can be used to confirm that assets have been resolved properly. - When `apply` is used, it attempts to upload exactly the assets listed here. - """ - await ctx.send(embed=await self._info_embed()) - - @branding_cmds.command(name="refresh") - async def branding_refresh(self, ctx: commands.Context) -> None: - """Sync currently available assets with branding repository.""" - async with ctx.typing(): - await self.refresh() - await self.branding_info(ctx) - - @branding_cmds.command(name="apply") - async def branding_apply(self, ctx: commands.Context) -> None: - """ - Apply current season's branding to the guild. - - Use `info` to check which assets will be applied. Shows which assets have - failed to be applied, if any. - """ - async with ctx.typing(): - failed_assets = await self.apply() - if failed_assets: - raise BrandingError(f"Failed to apply following assets: {', '.join(failed_assets)}") - - response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green) - await ctx.send(embed=response) - - @branding_cmds.command(name="cycle") - async def branding_cycle(self, ctx: commands.Context) -> None: - """ - Apply the next-up guild icon, if multiple are available. - - The order is random. - """ - async with ctx.typing(): - success = await self.cycle() - if not success: - raise BrandingError("Failed to cycle icon") - - response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green) - await ctx.send(embed=response) - - @branding_cmds.group(name="daemon", aliases=["d", "task"]) - async def daemon_group(self, ctx: commands.Context) -> None: - """Control the background daemon.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @daemon_group.command(name="status") - async def daemon_status(self, ctx: commands.Context) -> None: - """Check whether daemon is currently active.""" - if self._daemon_running: - remaining_time = (arrow.utcnow() + time_until_midnight()).humanize() - response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green) - response.set_footer(text=f"Next refresh {remaining_time}") - else: - response = discord.Embed(description="Daemon not running", colour=Colours.soft_red) - - await ctx.send(embed=response) - - @daemon_group.command(name="start") - async def daemon_start(self, ctx: commands.Context) -> None: - """If the daemon isn't running, start it.""" - if self._daemon_running: - raise BrandingError("Daemon already running!") - - self.daemon = self.bot.loop.create_task(self._daemon_func()) - await self.branding_configuration.set("daemon_active", True) - - response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green) - await ctx.send(embed=response) - - @daemon_group.command(name="stop") - async def daemon_stop(self, ctx: commands.Context) -> None: - """If the daemon is running, stop it.""" - if not self._daemon_running: - raise BrandingError("Daemon not running!") - - self.daemon.cancel() - await self.branding_configuration.set("daemon_active", False) - - response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green) - await ctx.send(embed=response) - - async def _fetch_image(self, url: str) -> bytes: - """Retrieve and read image from `url`.""" - log.debug(f"Getting image from: {url}") - async with self.bot.http_session.get(url) as resp: - return await resp.read() - - async def _apply_asset(self, target: discord.Guild, asset: AssetType, url: str) -> bool: - """ - Internal method for applying media assets to the guild. - - This shouldn't be called directly. The purpose of this method is mainly generic - error handling to reduce needless code repetition. - - Return True if upload was successful, False otherwise. - """ - log.info(f"Attempting to set {asset.name}: {url}") - - kwargs = {asset.value: await self._fetch_image(url)} - try: - async with async_timeout.timeout(5): - await target.edit(**kwargs) - - except asyncio.TimeoutError: - log.info("Asset upload timed out") - return False - - except discord.HTTPException as discord_error: - log.exception("Asset upload failed", exc_info=discord_error) - return False - - else: - log.info("Asset successfully applied") - return True - - @mock_in_debug(return_value=True) - async def set_banner(self, url: str) -> bool: - """Set the guild's banner to image at `url`.""" - guild = self.bot.get_guild(Guild.id) - if guild is None: - log.info("Failed to get guild instance, aborting asset upload") - return False - - return await self._apply_asset(guild, AssetType.BANNER, url) - - @mock_in_debug(return_value=True) - async def set_icon(self, url: str) -> bool: - """Sets the guild's icon to image at `url`.""" - guild = self.bot.get_guild(Guild.id) - if guild is None: - log.info("Failed to get guild instance, aborting asset upload") - return False - - return await self._apply_asset(guild, AssetType.SERVER_ICON, url) - - -def setup(bot: Bot) -> None: - """Load BrandingManager cog.""" - bot.add_cog(BrandingManager(bot)) diff --git a/bot/exts/backend/branding/__init__.py b/bot/exts/backend/branding/__init__.py new file mode 100644 index 000000000..81ea3bf49 --- /dev/null +++ b/bot/exts/backend/branding/__init__.py @@ -0,0 +1,7 @@ +from bot.bot import Bot +from bot.exts.backend.branding._cog import BrandingManager + + +def setup(bot: Bot) -> None: + """Loads BrandingManager cog.""" + bot.add_cog(BrandingManager(bot)) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py new file mode 100644 index 000000000..d7fa78bb5 --- /dev/null +++ b/bot/exts/backend/branding/_cog.py @@ -0,0 +1,560 @@ +import asyncio +import itertools +import logging +import random +import typing as t +from datetime import datetime, time, timedelta + +import arrow +import async_timeout +import discord +from async_rediscache import RedisCache +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Branding, Colours, Emojis, Guild, MODERATION_ROLES +from bot.decorators import in_whitelist +from bot.exts.backend.branding import _constants, _decorators, _errors, _seasons + +log = logging.getLogger(__name__) + + +class GitHubFile(t.NamedTuple): + """ + Represents a remote file on GitHub. + + The `sha` hash is kept so that we can determine that a file has changed, + despite its filename remaining unchanged. + """ + + download_url: str + path: str + sha: str + + +def pretty_files(files: t.Iterable[GitHubFile]) -> str: + """Provide a human-friendly representation of `files`.""" + return "\n".join(file.path for file in files) + + +def time_until_midnight() -> timedelta: + """ + Determine amount of time until the next-up UTC midnight. + + The exact `midnight` moment is actually delayed to 5 seconds after, in order + to avoid potential problems due to imprecise sleep. + """ + now = datetime.utcnow() + tomorrow = now + timedelta(days=1) + midnight = datetime.combine(tomorrow, time(second=5)) + + return midnight - now + + +class BrandingManager(commands.Cog): + """ + Manages the guild's branding. + + The purpose of this cog is to help automate the synchronization of the branding + repository with the guild. It is capable of discovering assets in the repository + via GitHub's API, resolving download urls for them, and delegating + to the `bot` instance to upload them to the guild. + + BrandingManager is designed to be entirely autonomous. Its `daemon` background task awakens + once a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single + season. The daemon can be turned on and off via the `daemon` cmd group. The value set via + its `start` and `stop` commands is persisted across sessions. If turned on, the daemon will + automatically start on the next bot start-up. Otherwise, it will wait to be started manually. + + All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can + also be invoked manually, via the following API: + + branding list + - Show all available seasons + + branding set + - Set the cog's internal state to represent `season_name`, if it exists + - If no `season_name` is given, set chronologically current season + - This will not automatically apply the season's branding to the guild, + the cog's state can be detached from the guild + - Seasons can therefore be 'previewed' using this command + + branding info + - View detailed information about resolved assets for current season + + branding refresh + - Refresh internal state, i.e. synchronize with branding repository + + branding apply + - Apply the current internal state to the guild, i.e. upload the assets + + branding cycle + - If there are multiple available icons for current season, randomly pick + and apply the next one + + The daemon calls these methods autonomously as appropriate. The use of this cog + is locked to moderation roles. As it performs media asset uploads, it is prone to + rate-limits - the `apply` command should be used with caution. The `set` command can, + however, be used freely to 'preview' seasonal branding and check whether paths have been + resolved as appropriate. + + While the bot is in debug mode, it will 'mock' asset uploads by logging the passed + download urls and pretending that the upload was successful. Make use of this + to test this cog's behaviour. + """ + + current_season: t.Type[_seasons.SeasonBase] + + banner: t.Optional[GitHubFile] + + available_icons: t.List[GitHubFile] + remaining_icons: t.List[GitHubFile] + + days_since_cycle: t.Iterator + + daemon: t.Optional[asyncio.Task] + + # Branding configuration + branding_configuration = RedisCache() + + def __init__(self, bot: Bot) -> None: + """ + Assign safe default values on init. + + At this point, we don't have information about currently available branding. + Most of these attributes will be overwritten once the daemon connects, or once + the `refresh` command is used. + """ + self.bot = bot + self.current_season = _seasons.get_current_season() + + self.banner = None + + self.available_icons = [] + self.remaining_icons = [] + + self.days_since_cycle = itertools.cycle([None]) + + should_run = self.bot.loop.run_until_complete(self.branding_configuration.get("daemon_active")) + + if should_run: + self.daemon = self.bot.loop.create_task(self._daemon_func()) + else: + self.daemon = None + + @property + def _daemon_running(self) -> bool: + """True if the daemon is currently active, False otherwise.""" + return self.daemon is not None and not self.daemon.done() + + async def _daemon_func(self) -> None: + """ + Manage all automated behaviour of the BrandingManager cog. + + Once a day, the daemon will perform the following tasks: + - Update `current_season` + - Poll GitHub API to see if the available branding for `current_season` has changed + - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname) + - Check whether it's time to cycle guild icons + + The internal loop runs once when activated, then periodically at the time + given by `time_until_midnight`. + + All method calls in the internal loop are considered safe, i.e. no errors propagate + to the daemon's loop. The daemon itself does not perform any error handling on its own. + """ + await self.bot.wait_until_guild_available() + + while True: + self.current_season = _seasons.get_current_season() + branding_changed = await self.refresh() + + if branding_changed: + await self.apply() + + elif next(self.days_since_cycle) == Branding.cycle_frequency: + await self.cycle() + + until_midnight = time_until_midnight() + await asyncio.sleep(until_midnight.total_seconds()) + + async def _info_embed(self) -> discord.Embed: + """Make an informative embed representing current season.""" + info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour) + + # If we're in a non-evergreen season, also show active months + if self.current_season is not _seasons.SeasonBase: + title = f"{self.current_season.season_name} ({', '.join(str(m) for m in self.current_season.months)})" + else: + title = self.current_season.season_name + + # Use the author field to show the season's name and avatar if available + info_embed.set_author(name=title) + + banner = self.banner.path if self.banner is not None else "Unavailable" + info_embed.add_field(name="Banner", value=banner, inline=False) + + icons = pretty_files(self.available_icons) or "Unavailable" + info_embed.add_field(name="Available icons", value=icons, inline=False) + + # Only display cycle frequency if we're actually cycling + if len(self.available_icons) > 1 and Branding.cycle_frequency: + info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}") + + return info_embed + + async def _reset_remaining_icons(self) -> None: + """Set `remaining_icons` to a shuffled copy of `available_icons`.""" + self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons)) + + async def _reset_days_since_cycle(self) -> None: + """ + Reset the `days_since_cycle` iterator based on configured frequency. + + If the current season only has 1 icon, or if `Branding.cycle_frequency` is falsey, + the iterator will always yield None. This signals that the icon shouldn't be cycled. + + Otherwise, it will yield ints in range [1, `Branding.cycle_frequency`] indefinitely. + When the iterator yields a value equal to `Branding.cycle_frequency`, it is time to cycle. + """ + if len(self.available_icons) > 1 and Branding.cycle_frequency: + sequence = range(1, Branding.cycle_frequency + 1) + else: + sequence = [None] + + self.days_since_cycle = itertools.cycle(sequence) + + async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GitHubFile]: + """ + Get files at `path` in the branding repository. + + If `include_dirs` is False (default), only returns files at `path`. + Otherwise, will return both files and directories. Never returns symlinks. + + Return dict mapping from filename to corresponding `GitHubFile` instance. + This may return an empty dict if the response status is non-200, + or if the target directory is empty. + """ + url = f"{_constants.BRANDING_URL}/{path}" + async with self.bot.http_session.get( + url, headers=_constants.HEADERS, params=_constants.PARAMS + ) as resp: + # Short-circuit if we get non-200 response + if resp.status != _constants.STATUS_OK: + log.error(f"GitHub API returned non-200 response: {resp}") + return {} + directory = await resp.json() # Directory at `path` + + allowed_types = {"file", "dir"} if include_dirs else {"file"} + return { + file["name"]: GitHubFile(file["download_url"], file["path"], file["sha"]) + for file in directory + if file["type"] in allowed_types + } + + async def refresh(self) -> bool: + """ + Synchronize available assets with branding repository. + + If the current season is not the evergreen, and lacks at least one asset, + we use the evergreen seasonal dir as fallback for missing assets. + + Finally, if neither the seasonal nor fallback branding directories contain + an asset, it will simply be ignored. + + Return True if the branding has changed. This will be the case when we enter + a new season, or when something changes in the current seasons's directory + in the branding repository. + """ + old_branding = (self.banner, self.available_icons) + seasonal_dir = await self._get_files(self.current_season.branding_path, include_dirs=True) + + # Only make a call to the fallback directory if there is something to be gained + branding_incomplete = any( + asset not in seasonal_dir + for asset in (_constants.FILE_BANNER, _constants.FILE_AVATAR, _constants.SERVER_ICONS) + ) + if branding_incomplete and self.current_season is not _seasons.SeasonBase: + fallback_dir = await self._get_files( + _seasons.SeasonBase.branding_path, include_dirs=True + ) + else: + fallback_dir = {} + + # Resolve assets in this directory, None is a safe value + self.banner = ( + seasonal_dir.get(_constants.FILE_BANNER) + or fallback_dir.get(_constants.FILE_BANNER) + ) + + # Now resolve server icons by making a call to the proper sub-directory + if _constants.SERVER_ICONS in seasonal_dir: + icons_dir = await self._get_files( + f"{self.current_season.branding_path}/{_constants.SERVER_ICONS}" + ) + self.available_icons = list(icons_dir.values()) + + elif _constants.SERVER_ICONS in fallback_dir: + icons_dir = await self._get_files( + f"{_seasons.SeasonBase.branding_path}/{_constants.SERVER_ICONS}" + ) + self.available_icons = list(icons_dir.values()) + + else: + self.available_icons = [] # This should never be the case, but an empty list is a safe value + + # GitHubFile instances carry a `sha` attr so this will pick up if a file changes + branding_changed = old_branding != (self.banner, self.available_icons) + + if branding_changed: + log.info(f"New branding detected (season: {self.current_season.season_name})") + await self._reset_remaining_icons() + await self._reset_days_since_cycle() + + return branding_changed + + async def cycle(self) -> bool: + """ + Apply the next-up server icon. + + Returns True if an icon is available and successfully gets applied, False otherwise. + """ + if not self.available_icons: + log.info("Cannot cycle: no icons for this season") + return False + + if not self.remaining_icons: + log.info("Reset & shuffle remaining icons") + await self._reset_remaining_icons() + + next_up = self.remaining_icons.pop(0) + success = await self.set_icon(next_up.download_url) + + return success + + async def apply(self) -> t.List[str]: + """ + Apply current branding to the guild and bot. + + This delegates to the bot instance to do all the work. We only provide download urls + for available assets. Assets unavailable in the branding repo will be ignored. + + Returns a list of names of all failed assets. An asset is considered failed + if it isn't found in the branding repo, or if something goes wrong while the + bot is trying to apply it. + + An empty list denotes that all assets have been applied successfully. + """ + report = {asset: False for asset in ("banner", "icon")} + + if self.banner is not None: + report["banner"] = await self.set_banner(self.banner.download_url) + + report["icon"] = await self.cycle() + + failed_assets = [asset for asset, succeeded in report.items() if not succeeded] + return failed_assets + + @in_whitelist(roles=MODERATION_ROLES) + @commands.group(name="branding") + async def branding_cmds(self, ctx: commands.Context) -> None: + """Manual branding control.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @branding_cmds.command(name="list", aliases=["ls"]) + async def branding_list(self, ctx: commands.Context) -> None: + """List all available seasons and branding sources.""" + embed = discord.Embed(title="Available seasons", colour=Colours.soft_green) + + for season in _seasons.get_all_seasons(): + if season is _seasons.SeasonBase: + active_when = "always" + else: + active_when = f"in {', '.join(str(m) for m in season.months)}" + + description = ( + f"Active {active_when}\n" + f"Branding: {season.branding_path}" + ) + embed.add_field(name=season.season_name, value=description, inline=False) + + await ctx.send(embed=embed) + + @branding_cmds.command(name="set") + async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None: + """ + Manually set season, or reset to current if none given. + + Season search is a case-less comparison against both seasonal class name, + and its `season_name` attr. + + This only pre-loads the cog's internal state to the chosen season, but does not + automatically apply the branding. As that is an expensive operation, the `apply` + command must be called explicitly after this command finishes. + + This means that this command can be used to 'preview' a season gathering info + about its available assets, without applying them to the guild. + + If the daemon is running, it will automatically reset the season to current when + it wakes up. The season set via this command can therefore remain 'detached' from + what it should be - the daemon will make sure that it's set back properly. + """ + if season_name is None: + new_season = _seasons.get_current_season() + else: + new_season = _seasons.get_season(season_name) + if new_season is None: + raise _errors.BrandingError("No such season exists") + + if self.current_season is new_season: + raise _errors.BrandingError(f"Season {self.current_season.season_name} already active") + + self.current_season = new_season + await self.branding_refresh(ctx) + + @branding_cmds.command(name="info", aliases=["status"]) + async def branding_info(self, ctx: commands.Context) -> None: + """ + Show available assets for current season. + + This can be used to confirm that assets have been resolved properly. + When `apply` is used, it attempts to upload exactly the assets listed here. + """ + await ctx.send(embed=await self._info_embed()) + + @branding_cmds.command(name="refresh") + async def branding_refresh(self, ctx: commands.Context) -> None: + """Sync currently available assets with branding repository.""" + async with ctx.typing(): + await self.refresh() + await self.branding_info(ctx) + + @branding_cmds.command(name="apply") + async def branding_apply(self, ctx: commands.Context) -> None: + """ + Apply current season's branding to the guild. + + Use `info` to check which assets will be applied. Shows which assets have + failed to be applied, if any. + """ + async with ctx.typing(): + failed_assets = await self.apply() + if failed_assets: + raise _errors.BrandingError( + f"Failed to apply following assets: {', '.join(failed_assets)}" + ) + + response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @branding_cmds.command(name="cycle") + async def branding_cycle(self, ctx: commands.Context) -> None: + """ + Apply the next-up guild icon, if multiple are available. + + The order is random. + """ + async with ctx.typing(): + success = await self.cycle() + if not success: + raise _errors.BrandingError("Failed to cycle icon") + + response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @branding_cmds.group(name="daemon", aliases=["d", "task"]) + async def daemon_group(self, ctx: commands.Context) -> None: + """Control the background daemon.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @daemon_group.command(name="status") + async def daemon_status(self, ctx: commands.Context) -> None: + """Check whether daemon is currently active.""" + if self._daemon_running: + remaining_time = (arrow.utcnow() + time_until_midnight()).humanize() + response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green) + response.set_footer(text=f"Next refresh {remaining_time}") + else: + response = discord.Embed(description="Daemon not running", colour=Colours.soft_red) + + await ctx.send(embed=response) + + @daemon_group.command(name="start") + async def daemon_start(self, ctx: commands.Context) -> None: + """If the daemon isn't running, start it.""" + if self._daemon_running: + raise _errors.BrandingError("Daemon already running!") + + self.daemon = self.bot.loop.create_task(self._daemon_func()) + await self.branding_configuration.set("daemon_active", True) + + response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @daemon_group.command(name="stop") + async def daemon_stop(self, ctx: commands.Context) -> None: + """If the daemon is running, stop it.""" + if not self._daemon_running: + raise _errors.BrandingError("Daemon not running!") + + self.daemon.cancel() + await self.branding_configuration.set("daemon_active", False) + + response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + async def _fetch_image(self, url: str) -> bytes: + """Retrieve and read image from `url`.""" + log.debug(f"Getting image from: {url}") + async with self.bot.http_session.get(url) as resp: + return await resp.read() + + async def _apply_asset(self, target: discord.Guild, asset: _constants.AssetType, url: str) -> bool: + """ + Internal method for applying media assets to the guild. + + This shouldn't be called directly. The purpose of this method is mainly generic + error handling to reduce needless code repetition. + + Return True if upload was successful, False otherwise. + """ + log.info(f"Attempting to set {asset.name}: {url}") + + kwargs = {asset.value: await self._fetch_image(url)} + try: + async with async_timeout.timeout(5): + await target.edit(**kwargs) + + except asyncio.TimeoutError: + log.info("Asset upload timed out") + return False + + except discord.HTTPException as discord_error: + log.exception("Asset upload failed", exc_info=discord_error) + return False + + else: + log.info("Asset successfully applied") + return True + + @_decorators.mock_in_debug(return_value=True) + async def set_banner(self, url: str) -> bool: + """Set the guild's banner to image at `url`.""" + guild = self.bot.get_guild(Guild.id) + if guild is None: + log.info("Failed to get guild instance, aborting asset upload") + return False + + return await self._apply_asset(guild, _constants.AssetType.BANNER, url) + + @_decorators.mock_in_debug(return_value=True) + async def set_icon(self, url: str) -> bool: + """Sets the guild's icon to image at `url`.""" + guild = self.bot.get_guild(Guild.id) + if guild is None: + log.info("Failed to get guild instance, aborting asset upload") + return False + + return await self._apply_asset(guild, _constants.AssetType.SERVER_ICON, url) diff --git a/bot/exts/backend/branding/_constants.py b/bot/exts/backend/branding/_constants.py new file mode 100644 index 000000000..f4c815fbd --- /dev/null +++ b/bot/exts/backend/branding/_constants.py @@ -0,0 +1,49 @@ +from enum import Enum, IntEnum + +from bot.constants import Keys + + +class Month(IntEnum): + JANUARY = 1 + FEBRUARY = 2 + MARCH = 3 + APRIL = 4 + MAY = 5 + JUNE = 6 + JULY = 7 + AUGUST = 8 + SEPTEMBER = 9 + OCTOBER = 10 + NOVEMBER = 11 + DECEMBER = 12 + + def __str__(self) -> str: + return self.name.title() + + +class AssetType(Enum): + """ + Discord media assets. + + The values match exactly the kwarg keys that can be passed to `Guild.edit`. + """ + + BANNER = "banner" + SERVER_ICON = "icon" + + +STATUS_OK = 200 # HTTP status code + +FILE_BANNER = "banner.png" +FILE_AVATAR = "avatar.png" +SERVER_ICONS = "server_icons" + +BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" + +PARAMS = {"ref": "master"} # Target branch +HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3 + +# A GitHub token is not necessary for the cog to operate, +# unauthorized requests are however limited to 60 per hour +if Keys.github: + HEADERS["Authorization"] = f"token {Keys.github}" diff --git a/bot/exts/backend/branding/_decorators.py b/bot/exts/backend/branding/_decorators.py new file mode 100644 index 000000000..6a1e7e869 --- /dev/null +++ b/bot/exts/backend/branding/_decorators.py @@ -0,0 +1,27 @@ +import functools +import logging +import typing as t + +from bot.constants import DEBUG_MODE + +log = logging.getLogger(__name__) + + +def mock_in_debug(return_value: t.Any) -> t.Callable: + """ + Short-circuit function execution if in debug mode and return `return_value`. + + The original function name, and the incoming args and kwargs are DEBUG level logged + upon each call. This is useful for expensive operations, i.e. media asset uploads + that are prone to rate-limits but need to be tested extensively. + """ + def decorator(func: t.Callable) -> t.Callable: + @functools.wraps(func) + async def wrapped(*args, **kwargs) -> t.Any: + """Short-circuit and log if in debug mode.""" + if DEBUG_MODE: + log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}") + return return_value + return await func(*args, **kwargs) + return wrapped + return decorator diff --git a/bot/exts/backend/branding/_errors.py b/bot/exts/backend/branding/_errors.py new file mode 100644 index 000000000..7cd271af3 --- /dev/null +++ b/bot/exts/backend/branding/_errors.py @@ -0,0 +1,2 @@ +class BrandingError(Exception): + """Exception raised by the BrandingManager cog.""" diff --git a/bot/exts/backend/branding/_seasons.py b/bot/exts/backend/branding/_seasons.py new file mode 100644 index 000000000..2f785bec0 --- /dev/null +++ b/bot/exts/backend/branding/_seasons.py @@ -0,0 +1,175 @@ +import logging +import typing as t +from datetime import datetime + +from ._constants import Month +from ._errors import BrandingError +from bot.constants import Colours + +log = logging.getLogger(__name__) + + +class SeasonBase: + """ + Base for Seasonal classes. + + This serves as the off-season fallback for when no specific + seasons are active. + + Seasons are 'registered' simply by inheriting from `SeasonBase`. + We discover them by calling `__subclasses__`. + """ + + season_name: str = "Evergreen" + + colour: str = Colours.soft_green + description: str = "The default season!" + + branding_path: str = "seasonal/evergreen" + + months: t.Set[Month] = set(Month) + + +class Christmas(SeasonBase): + """Branding for December.""" + + season_name = "Festive season" + + colour = Colours.soft_red + description = ( + "The time is here to get into the festive spirit! No matter who you are, where you are, " + "or what beliefs you may follow, we hope every one of you enjoy this festive season!" + ) + + branding_path = "seasonal/christmas" + + months = {Month.DECEMBER} + + +class Easter(SeasonBase): + """Branding for April.""" + + season_name = "Easter" + + colour = Colours.bright_green + description = ( + "Bunny here, bunny there, bunny everywhere! Here at Python Discord, we celebrate " + "our version of Easter during the entire month of April." + ) + + branding_path = "seasonal/easter" + + months = {Month.APRIL} + + +class Halloween(SeasonBase): + """Branding for October.""" + + season_name = "Halloween" + + colour = Colours.orange + description = "Trick or treat?!" + + branding_path = "seasonal/halloween" + + months = {Month.OCTOBER} + + +class Pride(SeasonBase): + """Branding for June.""" + + season_name = "Pride" + + colour = Colours.pink + description = ( + "The month of June is a special month for us at Python Discord. It is very important to us " + "that everyone feels welcome here, no matter their origin, identity or sexuality. During the " + "month of June, while some of you are participating in Pride festivals across the world, " + "we will be celebrating individuality and commemorating the history and challenges " + "of the LGBTQ+ community with a Pride event of our own!" + ) + + branding_path = "seasonal/pride" + + months = {Month.JUNE} + + +class Valentines(SeasonBase): + """Branding for February.""" + + season_name = "Valentines" + + colour = Colours.pink + description = "Love is in the air!" + + branding_path = "seasonal/valentines" + + months = {Month.FEBRUARY} + + +class Wildcard(SeasonBase): + """Branding for August.""" + + season_name = "Wildcard" + + colour = Colours.purple + description = "A season full of surprises!" + + months = {Month.AUGUST} + + +def get_all_seasons() -> t.List[t.Type[SeasonBase]]: + """Give all available season classes.""" + return [SeasonBase] + SeasonBase.__subclasses__() + + +def get_current_season() -> t.Type[SeasonBase]: + """Give active season, based on current UTC month.""" + current_month = Month(datetime.utcnow().month) + + active_seasons = tuple( + season + for season in SeasonBase.__subclasses__() + if current_month in season.months + ) + + if not active_seasons: + return SeasonBase + + return active_seasons[0] + + +def get_season(name: str) -> t.Optional[t.Type[SeasonBase]]: + """ + Give season such that its class name or its `season_name` attr match `name` (caseless). + + If no such season exists, return None. + """ + name = name.casefold() + + for season in get_all_seasons(): + matches = (season.__name__.casefold(), season.season_name.casefold()) + + if name in matches: + return season + + +def _validate_season_overlap() -> None: + """ + Raise BrandingError if there are any colliding seasons. + + This serves as a local test to ensure that seasons haven't been misconfigured. + """ + month_to_season = {} + + for season in SeasonBase.__subclasses__(): + for month in season.months: + colliding_season = month_to_season.get(month) + + if colliding_season: + raise BrandingError(f"Season {season} collides with {colliding_season} in {month.name}") + else: + month_to_season[month] = season + + +_validate_season_overlap() diff --git a/bot/seasons.py b/bot/seasons.py deleted file mode 100644 index d4a9dfcc5..000000000 --- a/bot/seasons.py +++ /dev/null @@ -1,174 +0,0 @@ -import logging -import typing as t -from datetime import datetime - -from bot.constants import Colours, Month -from bot.errors import BrandingError - -log = logging.getLogger(__name__) - - -class SeasonBase: - """ - Base for Seasonal classes. - - This serves as the off-season fallback for when no specific - seasons are active. - - Seasons are 'registered' simply by inheriting from `SeasonBase`. - We discover them by calling `__subclasses__`. - """ - - season_name: str = "Evergreen" - - colour: str = Colours.soft_green - description: str = "The default season!" - - branding_path: str = "seasonal/evergreen" - - months: t.Set[Month] = set(Month) - - -class Christmas(SeasonBase): - """Branding for December.""" - - season_name = "Festive season" - - colour = Colours.soft_red - description = ( - "The time is here to get into the festive spirit! No matter who you are, where you are, " - "or what beliefs you may follow, we hope every one of you enjoy this festive season!" - ) - - branding_path = "seasonal/christmas" - - months = {Month.DECEMBER} - - -class Easter(SeasonBase): - """Branding for April.""" - - season_name = "Easter" - - colour = Colours.bright_green - description = ( - "Bunny here, bunny there, bunny everywhere! Here at Python Discord, we celebrate " - "our version of Easter during the entire month of April." - ) - - branding_path = "seasonal/easter" - - months = {Month.APRIL} - - -class Halloween(SeasonBase): - """Branding for October.""" - - season_name = "Halloween" - - colour = Colours.orange - description = "Trick or treat?!" - - branding_path = "seasonal/halloween" - - months = {Month.OCTOBER} - - -class Pride(SeasonBase): - """Branding for June.""" - - season_name = "Pride" - - colour = Colours.pink - description = ( - "The month of June is a special month for us at Python Discord. It is very important to us " - "that everyone feels welcome here, no matter their origin, identity or sexuality. During the " - "month of June, while some of you are participating in Pride festivals across the world, " - "we will be celebrating individuality and commemorating the history and challenges " - "of the LGBTQ+ community with a Pride event of our own!" - ) - - branding_path = "seasonal/pride" - - months = {Month.JUNE} - - -class Valentines(SeasonBase): - """Branding for February.""" - - season_name = "Valentines" - - colour = Colours.pink - description = "Love is in the air!" - - branding_path = "seasonal/valentines" - - months = {Month.FEBRUARY} - - -class Wildcard(SeasonBase): - """Branding for August.""" - - season_name = "Wildcard" - - colour = Colours.purple - description = "A season full of surprises!" - - months = {Month.AUGUST} - - -def get_all_seasons() -> t.List[t.Type[SeasonBase]]: - """Give all available season classes.""" - return [SeasonBase] + SeasonBase.__subclasses__() - - -def get_current_season() -> t.Type[SeasonBase]: - """Give active season, based on current UTC month.""" - current_month = Month(datetime.utcnow().month) - - active_seasons = tuple( - season - for season in SeasonBase.__subclasses__() - if current_month in season.months - ) - - if not active_seasons: - return SeasonBase - - return active_seasons[0] - - -def get_season(name: str) -> t.Optional[t.Type[SeasonBase]]: - """ - Give season such that its class name or its `season_name` attr match `name` (caseless). - - If no such season exists, return None. - """ - name = name.casefold() - - for season in get_all_seasons(): - matches = (season.__name__.casefold(), season.season_name.casefold()) - - if name in matches: - return season - - -def _validate_season_overlap() -> None: - """ - Raise BrandingError if there are any colliding seasons. - - This serves as a local test to ensure that seasons haven't been misconfigured. - """ - month_to_season = {} - - for season in SeasonBase.__subclasses__(): - for month in season.months: - colliding_season = month_to_season.get(month) - - if colliding_season: - raise BrandingError(f"Season {season} collides with {colliding_season} in {month.name}") - else: - month_to_season[month] = season - - -_validate_season_overlap() -- cgit v1.2.3 From 6363e571b532a4e4a66dd5ccb9133c72fa8fd7c6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 Jan 2021 09:37:40 +0200 Subject: Add missing docstring to Month enum --- bot/exts/backend/branding/_constants.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/backend/branding/_constants.py b/bot/exts/backend/branding/_constants.py index f4c815fbd..dbc7615f2 100644 --- a/bot/exts/backend/branding/_constants.py +++ b/bot/exts/backend/branding/_constants.py @@ -4,6 +4,8 @@ from bot.constants import Keys class Month(IntEnum): + """All month constants for seasons.""" + JANUARY = 1 FEBRUARY = 2 MARCH = 3 -- cgit v1.2.3 From 29989ae090ae0954d693b86b9d98467c5d34f9fc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 Jan 2021 09:39:47 +0200 Subject: Fix seasons file import order --- bot/exts/backend/branding/_seasons.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/backend/branding/_seasons.py b/bot/exts/backend/branding/_seasons.py index 2f785bec0..5f6256b30 100644 --- a/bot/exts/backend/branding/_seasons.py +++ b/bot/exts/backend/branding/_seasons.py @@ -2,9 +2,9 @@ import logging import typing as t from datetime import datetime -from ._constants import Month -from ._errors import BrandingError from bot.constants import Colours +from bot.exts.backend.branding._constants import Month +from bot.exts.backend.branding._errors import BrandingError log = logging.getLogger(__name__) -- cgit v1.2.3 From 6c20da5555e1c1720131d5c7b37b96be5f1c70bc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 Jan 2021 09:48:28 +0200 Subject: Add startup task that starts daemon to branding cog --- bot/exts/backend/branding/_cog.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index d7fa78bb5..9afacb377 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -135,12 +135,13 @@ class BrandingManager(commands.Cog): self.days_since_cycle = itertools.cycle([None]) - should_run = self.bot.loop.run_until_complete(self.branding_configuration.get("daemon_active")) + self.daemon = None + self._startup_task = self.bot.loop.create_task(self._initial_start_daemon()) - if should_run: + async def _initial_start_daemon(self) -> None: + """Checks is daemon active and when is, start it at cog load.""" + if await self.branding_configuration.get("daemon_active"): self.daemon = self.bot.loop.create_task(self._daemon_func()) - else: - self.daemon = None @property def _daemon_running(self) -> bool: -- cgit v1.2.3 From 00687fa607f9eaadc0aacb260dcda53b2f767827 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 Jan 2021 09:53:46 +0200 Subject: Fix BrandingError import in error handler --- bot/exts/backend/error_handler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index b6c19d504..d60dda3d4 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -11,7 +11,8 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Colours, ERROR_REPLIES from bot.converters import TagNameConverter -from bot.errors import BrandingError, LockedResourceError +from bot.errors import LockedResourceError +from bot.exts.backend.branding._errors import BrandingError from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) -- cgit v1.2.3 From 69ed1cc4742ec17b675d9323e46f30e028248106 Mon Sep 17 00:00:00 2001 From: xithrius Date: Wed, 6 Jan 2021 17:54:51 -0800 Subject: Only helpers and below now get command suggestions --- bot/exts/backend/error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 412c42df5..51fbac99b 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -9,7 +9,7 @@ from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours, Icons, STAFF_ROLES +from bot.constants import Channels, Colours, Icons, MODERATION_ROLES from bot.converters import TagNameConverter from bot.errors import LockedResourceError from bot.utils.checks import InWhitelistCheckFailure @@ -159,7 +159,7 @@ class ErrorHandler(Cog): with contextlib.suppress(ResponseCodeError): await ctx.invoke(tags_get_command, tag_name=tag_name) - if not any(role.id in STAFF_ROLES for role in ctx.author.roles): + if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): tags_cog = self.bot.get_cog("Tags") command_name = ctx.invoked_with sent = await tags_cog.display_tag(ctx, command_name) -- cgit v1.2.3 From 0722f104a0707d3657ff0f63fbdc48aedbd174f9 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 8 Jan 2021 12:13:22 +0200 Subject: Upped duckpond threshold to 5 --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index ca89bb639..e713a59d2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -489,7 +489,7 @@ redirect_output: duck_pond: - threshold: 4 + threshold: 5 channel_blacklist: - *ANNOUNCEMENTS - *PYNEWS_CHANNEL -- cgit v1.2.3 From 26672e5524fb178f3e21f2c18818f3a20c213605 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 10 Jan 2021 17:16:42 +0100 Subject: Make sure that users without the Developers role can use tag. We have a check in place to restrict tag usage to a certain role, but our default is the Developers role, and some users now don't have this code. This commit fixes this by using None as a default and adding a truth test in the check_accessibility method. --- bot/exts/info/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 8f15f932b..da4154316 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -46,7 +46,7 @@ class Tags(Cog): "embed": { "description": file.read_text(encoding="utf8"), }, - "restricted_to": "developers", + "restricted_to": None, "location": f"/bot/{file}" } @@ -63,7 +63,7 @@ class Tags(Cog): @staticmethod def check_accessibility(user: Member, tag: dict) -> bool: """Check if user can access a tag.""" - return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] + return not tag["restricted_to"] or tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] @staticmethod def _fuzzy_search(search: str, target: str) -> float: -- cgit v1.2.3 From 2e51515af5ca51beff6acbc1e48e064c78611dec Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 11 Jan 2021 01:52:08 +0200 Subject: Annihilate all traces of Developer and Unverified roles --- bot/constants.py | 13 - bot/exts/backend/error_handler.py | 10 +- bot/exts/moderation/silence.py | 2 +- bot/exts/moderation/verification.py | 675 +----------------------------------- bot/exts/utils/jams.py | 4 - bot/rules/burst_shared.py | 11 +- config-default.yml | 15 - tests/bot/exts/utils/test_jams.py | 4 +- 8 files changed, 14 insertions(+), 720 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6bfda160b..d813046ab 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -434,7 +434,6 @@ class Channels(metaclass=YAMLGetter): talent_pool: int user_event_announcements: int user_log: int - verification: int voice_chat: int voice_gate: int voice_log: int @@ -471,8 +470,6 @@ class Roles(metaclass=YAMLGetter): python_community: int sprinters: int team_leaders: int - unverified: int - verified: int # This is the Developers role on PyDis, here named verified for readability reasons. voice_verified: int @@ -594,16 +591,6 @@ class PythonNews(metaclass=YAMLGetter): webhook: int -class Verification(metaclass=YAMLGetter): - section = "verification" - - unverified_after: int - kicked_after: int - reminder_frequency: int - bot_message_delete_delay: int - kick_confirmation_threshold: float - - class VoiceGate(metaclass=YAMLGetter): section = "voice_gate" diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index c643d346e..5b5840858 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -8,7 +8,7 @@ from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours +from bot.constants import Colours from bot.converters import TagNameConverter from bot.errors import LockedResourceError from bot.utils.checks import InWhitelistCheckFailure @@ -47,7 +47,6 @@ class ErrorHandler(Cog): * If CommandNotFound is raised when invoking the tag (determined by the presence of the `invoked_from_error_handler` attribute), this error is treated as being unexpected and therefore sends an error message - * Commands in the verification channel are ignored 2. UserInputError: see `handle_user_input_error` 3. CheckFailure: see `handle_check_failure` 4. CommandOnCooldown: send an error message in the invoking context @@ -63,10 +62,9 @@ class ErrorHandler(Cog): if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): if await self.try_silence(ctx): return - if ctx.channel.id != Channels.verification: - # Try to look for a tag with the command's name - await self.try_get_tag(ctx) - return # Exit early to avoid logging. + # Try to look for a tag with the command's name + await self.try_get_tag(ctx) + return # Exit early to avoid logging. elif isinstance(e, errors.UserInputError): await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index a942d5294..2a7ca932e 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -72,7 +72,7 @@ class SilenceNotifier(tasks.Loop): class Silence(commands.Cog): - """Commands for stopping channel messages for `verified` role in a channel.""" + """Commands for stopping channel messages for `everyone` role in a channel.""" # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. # Overwrites are stored as JSON. diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index ce91dcb15..2a24c8ec6 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -1,27 +1,18 @@ -import asyncio import logging import typing as t -from contextlib import suppress -from datetime import datetime, timedelta import discord -from async_rediscache import RedisCache -from discord.ext import tasks -from discord.ext.commands import Cog, Context, command, group, has_any_role -from discord.utils import snowflake_time +from discord.ext.commands import Cog, Context, command, has_any_role from bot import constants -from bot.api import ResponseCodeError from bot.bot import Bot -from bot.decorators import has_no_roles, in_whitelist -from bot.exts.moderation.modlog import ModLog -from bot.utils.checks import InWhitelistCheckFailure, has_no_roles_check -from bot.utils.messages import format_user +from bot.decorators import in_whitelist +from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) # Sent via DMs once user joins the guild -ON_JOIN_MESSAGE = f""" +ON_JOIN_MESSAGE = """ Welcome to Python Discord! To show you what kind of community we are, we've created this video: @@ -29,32 +20,9 @@ https://youtu.be/ZH26PuX3re0 As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. \ In order to see the rest of the channels and to send messages, you first have to accept our rules. - -Please visit <#{constants.Channels.verification}> to get started. Thank you! """ -# Sent via DMs once user verifies VERIFIED_MESSAGE = f""" -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 \ -your information removed here as well. - -Feel free to review them at any point! - -Additionally, if you'd like to receive notifications for the announcements \ -we post in <#{constants.Channels.announcements}> -from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ -to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. - -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{constants.Channels.bot_commands}>. -""" - -ALTERNATE_VERIFIED_MESSAGE = f""" You are now verified! You can find a copy of our rules for reference at . @@ -71,61 +39,6 @@ To introduce you to our community, we've made the following video: https://youtu.be/ZH26PuX3re0 """ -# Sent via DMs to users kicked for failing to verify -KICKED_MESSAGE = f""" -Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ -within `{constants.Verification.kicked_after}` days. If this was an accident, please feel free to join us again! - -{constants.Guild.invite} -""" - -# Sent periodically in the verification channel -REMINDER_MESSAGE = f""" -<@&{constants.Roles.unverified}> - -Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \ -to send messages in the community! - -You will be kicked if you don't verify within `{constants.Verification.kicked_after}` days. -""".strip() - -# An async function taking a Member param -Request = t.Callable[[discord.Member], t.Awaitable] - - -class StopExecution(Exception): - """Signals that a task should halt immediately & alert admins.""" - - def __init__(self, reason: discord.HTTPException) -> None: - super().__init__() - self.reason = reason - - -class Limit(t.NamedTuple): - """Composition over config for throttling requests.""" - - batch_size: int # Amount of requests after which to pause - sleep_secs: int # Sleep this many seconds after each batch - - -def mention_role(role_id: int) -> discord.AllowedMentions: - """Construct an allowed mentions instance that allows pinging `role_id`.""" - return discord.AllowedMentions(roles=[discord.Object(role_id)]) - - -def is_verified(member: discord.Member) -> bool: - """ - Check whether `member` is considered verified. - - Members are considered verified if they have at least 1 role other than - the default role (@everyone) and the @Unverified role. - """ - unverified_roles = { - member.guild.get_role(constants.Roles.unverified), - member.guild.default_role, - } - return len(set(member.roles) - unverified_roles) > 0 - async def safe_dm(coro: t.Coroutine) -> None: """ @@ -150,410 +63,16 @@ class Verification(Cog): """ User verification and role management. - There are two internal tasks in this cog: - - * `update_unverified_members` - * Unverified members are given the @Unverified role after configured `unverified_after` days - * Unverified members are kicked after configured `kicked_after` days - * `ping_unverified` - * Periodically ping the @Unverified role in the verification channel - Statistics are collected in the 'verification.' namespace. - Moderators+ can use the `verification` command group to start or stop both internal - tasks, if necessary. Settings are persisted in Redis across sessions. - - Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands, - and keeps the verification channel clean by deleting messages. + Additionally, this cog offers the !subscribe and !unsubscribe commands, """ - # Persist task settings & last sent `REMINDER_MESSAGE` id - # RedisCache[ - # "tasks_running": int (0 or 1), - # "last_reminder": int (discord.Message.id), - # ] - task_cache = RedisCache() - def __init__(self, bot: Bot) -> None: """Start internal tasks.""" self.bot = bot - self.bot.loop.create_task(self._maybe_start_tasks()) - self.pending_members = set() - def cog_unload(self) -> None: - """ - Cancel internal tasks. - - This is necessary, as tasks are not automatically cancelled on cog unload. - """ - self._stop_tasks(gracefully=False) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def _maybe_start_tasks(self) -> None: - """ - Poll Redis to check whether internal tasks should start. - - Redis must be interfaced with from an async function. - """ - log.trace("Checking whether background tasks should begin") - setting: t.Optional[int] = await self.task_cache.get("tasks_running") # This can be None if never set - - if setting: - log.trace("Background tasks will be started") - self.update_unverified_members.start() - self.ping_unverified.start() - - def _stop_tasks(self, *, gracefully: bool) -> None: - """ - Stop the update users & ping @Unverified tasks. - - If `gracefully` is True, the tasks will be able to finish their current iteration. - Otherwise, they are cancelled immediately. - """ - log.info(f"Stopping internal tasks ({gracefully=})") - if gracefully: - self.update_unverified_members.stop() - self.ping_unverified.stop() - else: - self.update_unverified_members.cancel() - self.ping_unverified.cancel() - - # region: automatically update unverified users - - async def _verify_kick(self, n_members: int) -> bool: - """ - Determine whether `n_members` is a reasonable amount of members to kick. - - First, `n_members` is checked against the size of the PyDis guild. If `n_members` are - more than the configured `kick_confirmation_threshold` of the guild, the operation - must be confirmed by staff in #core-dev. Otherwise, the operation is seen as safe. - """ - log.debug(f"Checking whether {n_members} members are safe to kick") - - await self.bot.wait_until_guild_available() # Ensure cache is populated before we grab the guild - pydis = self.bot.get_guild(constants.Guild.id) - - percentage = n_members / len(pydis.members) - if percentage < constants.Verification.kick_confirmation_threshold: - log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe") - return True - - # Since `n_members` is a suspiciously large number, we will ask for confirmation - log.debug("Amount of users is too large, requesting staff confirmation") - - core_dev_channel = pydis.get_channel(constants.Channels.dev_core) - core_dev_ping = f"<@&{constants.Roles.core_developers}>" - - confirmation_msg = await core_dev_channel.send( - f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " - f"verified in `{constants.Verification.kicked_after}` days. This is `{percentage:.2%}` of the guild's " - f"population. Proceed?", - allowed_mentions=mention_role(constants.Roles.core_developers), - ) - - options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) - for option in options: - await confirmation_msg.add_reaction(option) - - core_dev_ids = [member.id for member in pydis.get_role(constants.Roles.core_developers).members] - - def check(reaction: discord.Reaction, user: discord.User) -> bool: - """Check whether `reaction` is a valid reaction to `confirmation_msg`.""" - return ( - reaction.message.id == confirmation_msg.id # Reacted to `confirmation_msg` - and str(reaction.emoji) in options # With one of `options` - and user.id in core_dev_ids # By a core developer - ) - - timeout = 60 * 5 # Seconds, i.e. 5 minutes - try: - choice, _ = await self.bot.wait_for("reaction_add", check=check, timeout=timeout) - except asyncio.TimeoutError: - log.debug("Staff prompt not answered, aborting operation") - return False - finally: - with suppress(discord.HTTPException): - await confirmation_msg.clear_reactions() - - result = str(choice) == constants.Emojis.incident_actioned - log.debug(f"Received answer: {choice}, result: {result}") - - # Edit the prompt message to reflect the final choice - if result is True: - result_msg = f":ok_hand: {core_dev_ping} Request to kick `{n_members}` members was authorized!" - else: - result_msg = f":warning: {core_dev_ping} Request to kick `{n_members}` members was denied!" - - with suppress(discord.HTTPException): - await confirmation_msg.edit(content=result_msg) - - return result - - async def _alert_admins(self, exception: discord.HTTPException) -> None: - """ - Ping @Admins with information about `exception`. - - This is used when a critical `exception` caused a verification task to abort. - """ - await self.bot.wait_until_guild_available() - log.info(f"Sending admin alert regarding exception: {exception}") - - admins_channel = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.admins) - ping = f"<@&{constants.Roles.admins}>" - - await admins_channel.send( - f"{ping} Aborted updating unverified users due to the following exception:\n" - f"```{exception}```\n" - f"Internal tasks will be stopped.", - allowed_mentions=mention_role(constants.Roles.admins), - ) - - async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: - """ - Pass `members` one by one to `request` handling Discord exceptions. - - This coroutine serves as a generic `request` executor for kicking members and adding - roles, as it allows us to define the error handling logic in one place only. - - Any `request` has the ability to completely abort the execution by raising `StopExecution`. - In such a case, the @Admins will be alerted of the reason attribute. - - To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds - to sleep between batches. - - Returns the amount of successful requests. Failed requests are logged at info level. - """ - log.trace(f"Sending {len(members)} requests") - n_success, bad_statuses = 0, set() - - for progress, member in enumerate(members, start=1): - if is_verified(member): # Member could have verified in the meantime - continue - try: - await request(member) - except StopExecution as stop_execution: - await self._alert_admins(stop_execution.reason) - await self.task_cache.set("tasks_running", 0) - self._stop_tasks(gracefully=True) # Gracefully finish current iteration, then stop - break - except discord.HTTPException as http_exc: - bad_statuses.add(http_exc.status) - else: - n_success += 1 - - if progress % limit.batch_size == 0: - log.trace(f"Processed {progress} requests, pausing for {limit.sleep_secs} seconds") - await asyncio.sleep(limit.sleep_secs) - - if bad_statuses: - log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}") - - return n_success - - async def _add_kick_note(self, member: discord.Member) -> None: - """ - Post a note regarding `member` being kicked to site. - - Allows keeping track of kicked members for auditing purposes. - """ - payload = { - "active": False, - "actor": self.bot.user.id, # Bot actions this autonomously - "expires_at": None, - "hidden": True, - "reason": "Verification kick", - "type": "note", - "user": member.id, - } - - log.trace(f"Posting kick note for member {member} ({member.id})") - try: - await self.bot.api_client.post("bot/infractions", json=payload) - except ResponseCodeError as api_exc: - log.warning("Failed to post kick note", exc_info=api_exc) - - async def _kick_members(self, members: t.Collection[discord.Member]) -> int: - """ - Kick `members` from the PyDis guild. - - Due to strict ratelimits on sending messages (120 requests / 60 secs), we sleep for a second - after each 2 requests to allow breathing room for other features. - - Note that this is a potentially destructive operation. Returns the amount of successful requests. - """ - log.info(f"Kicking {len(members)} members (not verified after {constants.Verification.kicked_after} days)") - - async def kick_request(member: discord.Member) -> None: - """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" - try: - await safe_dm(member.send(KICKED_MESSAGE)) # Suppress disabled DMs - except discord.HTTPException as suspicious_exception: - raise StopExecution(reason=suspicious_exception) - await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") - await self._add_kick_note(member) - - n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) - self.bot.stats.incr("verification.kicked", count=n_kicked) - - return n_kicked - - async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int: - """ - Give `role` to all `members`. - - We pause for a second after batches of 25 requests to ensure ratelimits aren't exceeded. - - Returns the amount of successful requests. - """ - log.info( - f"Assigning {role} role to {len(members)} members (not verified " - f"after {constants.Verification.unverified_after} days)" - ) - - async def role_request(member: discord.Member) -> None: - """Add `role` to `member`.""" - await member.add_roles(role, reason=f"Not verified after {constants.Verification.unverified_after} days") - - return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1)) - - async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: - """ - Check in on the verification status of PyDis members. - - This coroutine finds two sets of users: - * Not verified after configured `unverified_after` days, should be given the @Unverified role - * Not verified after configured `kicked_after` days, should be kicked from the guild - - These sets are always disjoint, i.e. share no common members. - """ - await self.bot.wait_until_guild_available() # Ensure cache is ready - pydis = self.bot.get_guild(constants.Guild.id) - - unverified = pydis.get_role(constants.Roles.unverified) - current_dt = datetime.utcnow() # Discord timestamps are UTC - - # Users to be given the @Unverified role, and those to be kicked, these should be entirely disjoint - for_role, for_kick = set(), set() - - log.debug("Checking verification status of guild members") - for member in pydis.members: - - # Skip verified members, bots, and members for which we do not know their join date, - # this should be extremely rare but docs mention that it can happen - if is_verified(member) or member.bot or member.joined_at is None: - continue - - # At this point, we know that `member` is an unverified user, and we will decide what - # to do with them based on time passed since their join date - since_join = current_dt - member.joined_at - - if since_join > timedelta(days=constants.Verification.kicked_after): - for_kick.add(member) # User should be removed from the guild - - elif ( - since_join > timedelta(days=constants.Verification.unverified_after) - and unverified not in member.roles - ): - for_role.add(member) # User should be given the @Unverified role - - log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") - return for_role, for_kick - - @tasks.loop(minutes=30) - async def update_unverified_members(self) -> None: - """ - Periodically call `_check_members` and update unverified members accordingly. - - After each run, a summary will be sent to the modlog channel. If a suspiciously high - amount of members to be kicked is found, the operation is guarded by `_verify_kick`. - """ - log.info("Updating unverified guild members") - - await self.bot.wait_until_guild_available() - unverified = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.unverified) - - for_role, for_kick = await self._check_members() - - if not for_role: - role_report = f"Found no users to be assigned the {unverified.mention} role." - else: - n_roles = await self._give_role(for_role, unverified) - role_report = f"Assigned {unverified.mention} role to `{n_roles}`/`{len(for_role)}` members." - - if not for_kick: - kick_report = "Found no users to be kicked." - elif not await self._verify_kick(len(for_kick)): - kick_report = f"Not authorized to kick `{len(for_kick)}` members." - else: - n_kicks = await self._kick_members(for_kick) - kick_report = f"Kicked `{n_kicks}`/`{len(for_kick)}` members from the guild." - - await self.mod_log.send_log_message( - icon_url=self.bot.user.avatar_url, - colour=discord.Colour.blurple(), - title="Verification system", - text=f"{kick_report}\n{role_report}", - ) - - # endregion - # region: periodically ping @Unverified - - @tasks.loop(hours=constants.Verification.reminder_frequency) - async def ping_unverified(self) -> None: - """ - Delete latest `REMINDER_MESSAGE` and send it again. - - This utilizes RedisCache to persist the latest reminder message id. - """ - await self.bot.wait_until_guild_available() - verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification) - - last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") - - if last_reminder is not None: - log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}") - - with suppress(discord.HTTPException): # If something goes wrong, just ignore it - await self.bot.http.delete_message(verification.id, last_reminder) - - log.trace("Sending verification reminder") - new_reminder = await verification.send( - REMINDER_MESSAGE, allowed_mentions=mention_role(constants.Roles.unverified), - ) - - await self.task_cache.set("last_reminder", new_reminder.id) - - @ping_unverified.before_loop - async def _before_first_ping(self) -> None: - """ - Sleep until `REMINDER_MESSAGE` should be sent again. - - If latest reminder is not cached, exit instantly. Otherwise, wait wait until the - configured `reminder_frequency` has passed. - """ - last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") - - if last_reminder is None: - log.trace("Latest verification reminder message not cached, task will not wait") - return - - # Convert cached message id into a timestamp - time_since = datetime.utcnow() - snowflake_time(last_reminder) - log.trace(f"Time since latest verification reminder: {time_since}") - - to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since - log.trace(f"Time to sleep until next ping: {to_sleep}") - - # Delta can be negative if `reminder_frequency` has already passed - secs = max(to_sleep.total_seconds(), 0) - await asyncio.sleep(secs) - - # endregion # region: listeners @Cog.listener() @@ -586,183 +105,12 @@ class Verification(Cog): # and has gone through the alternate gating system we should send # our alternate welcome DM which includes info such as our welcome # video. - await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) + await safe_dm(after.send(VERIFIED_MESSAGE)) except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") - @Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Check new message event for messages to the checkpoint channel & process.""" - if message.channel.id != constants.Channels.verification: - return # Only listen for #checkpoint messages - - if message.content == REMINDER_MESSAGE: - return # Ignore bots own verification reminder - - if message.author.bot: - # They're a bot, delete their message after the delay. - await message.delete(delay=constants.Verification.bot_message_delete_delay) - return - - # if a user mentions a role or guild member - # alert the mods in mod-alerts channel - if message.mentions or message.role_mentions: - log.debug( - f"{message.author} mentioned one or more users " - f"and/or roles in {message.channel.name}" - ) - - embed_text = ( - f"{format_user(message.author)} sent a message in " - f"{message.channel.mention} that contained user and/or role mentions." - f"\n\n**Original message:**\n>>> {message.content}" - ) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=constants.Icons.filtering, - colour=discord.Colour(constants.Colours.soft_red), - title=f"User/Role mentioned in {message.channel.name}", - text=embed_text, - thumbnail=message.author.avatar_url_as(static_format="png"), - channel_id=constants.Channels.mod_alerts, - ) - - ctx: Context = await self.bot.get_context(message) - if ctx.command is not None and ctx.command.name == "accept": - return - - if any(r.id == constants.Roles.verified for r in ctx.author.roles): - log.info( - f"{ctx.author} posted '{ctx.message.content}' " - "in the verification channel, but is already verified." - ) - return - - log.debug( - f"{ctx.author} posted '{ctx.message.content}' in the verification " - "channel. We are providing instructions how to verify." - ) - await ctx.send( - f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " - f"and gain access to the rest of the server.", - delete_after=20 - ) - - log.trace(f"Deleting the message posted by {ctx.author}") - with suppress(discord.NotFound): - await ctx.message.delete() - # endregion - # region: task management commands - - @has_any_role(*constants.MODERATION_ROLES) - @group(name="verification") - async def verification_group(self, ctx: Context) -> None: - """Manage internal verification tasks.""" - if ctx.invoked_subcommand is None: - await ctx.send_help(ctx.command) - - @verification_group.command(name="status") - async def status_cmd(self, ctx: Context) -> None: - """Check whether verification tasks are running.""" - log.trace("Checking status of verification tasks") - - if self.update_unverified_members.is_running(): - update_status = f"{constants.Emojis.incident_actioned} Member update task is running." - else: - update_status = f"{constants.Emojis.incident_unactioned} Member update task is **not** running." - - mention = f"<@&{constants.Roles.unverified}>" - if self.ping_unverified.is_running(): - ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} task is running." - else: - ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} task is **not** running." - - embed = discord.Embed( - title="Verification system", - description=f"{update_status}\n{ping_status}", - colour=discord.Colour.blurple(), - ) - await ctx.send(embed=embed) - - @verification_group.command(name="start") - async def start_cmd(self, ctx: Context) -> None: - """Start verification tasks if they are not already running.""" - log.info("Starting verification tasks") - - if not self.update_unverified_members.is_running(): - self.update_unverified_members.start() - - if not self.ping_unverified.is_running(): - self.ping_unverified.start() - - await self.task_cache.set("tasks_running", 1) - - colour = discord.Colour.blurple() - await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) - - @verification_group.command(name="stop", aliases=["kill"]) - async def stop_cmd(self, ctx: Context) -> None: - """Stop verification tasks.""" - log.info("Stopping verification tasks") - - self._stop_tasks(gracefully=False) - await self.task_cache.set("tasks_running", 0) - - colour = discord.Colour.blurple() - await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) - - # endregion - # region: accept and subscribe commands - - def _bump_verified_stats(self, verified_member: discord.Member) -> None: - """ - Increment verification stats for `verified_member`. - - Each member falls into one of the three categories: - * Verified within 24 hours after joining - * Does not have @Unverified role yet - * Does have @Unverified role - - Stats for member kicking are handled separately. - """ - if verified_member.joined_at is None: # Docs mention this can happen - return - - if (datetime.utcnow() - verified_member.joined_at) < timedelta(hours=24): - category = "accepted_on_day_one" - elif constants.Roles.unverified not in [role.id for role in verified_member.roles]: - category = "accepted_before_unverified" - else: - category = "accepted_after_unverified" - - log.trace(f"Bumping verification stats in category: {category}") - self.bot.stats.incr(f"verification.{category}") - - @command(name='accept', aliases=('verified', 'accepted'), hidden=True) - @has_no_roles(constants.Roles.verified) - @in_whitelist(channels=(constants.Channels.verification,)) - 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(discord.Object(constants.Roles.verified), reason="Accepted the rules") - - self._bump_verified_stats(ctx.author) # This checks for @Unverified so make sure it's not yet removed - - if constants.Roles.unverified in [role.id for role in ctx.author.roles]: - log.debug(f"Removing Unverified role from: {ctx.author}") - await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) - - try: - await safe_dm(ctx.author.send(VERIFIED_MESSAGE)) - except discord.HTTPException: - log.exception(f"Sending welcome message failed for {ctx.author}.") - finally: - log.trace(f"Deleting accept message by {ctx.author}.") - with suppress(discord.NotFound): - self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) - await ctx.message.delete() + # region: subscribe commands @command(name='subscribe') @in_whitelist(channels=(constants.Channels.bot_commands,)) @@ -823,15 +171,6 @@ class Verification(Cog): if isinstance(error, InWhitelistCheckFailure): error.handled = True - @staticmethod - async def bot_check(ctx: Context) -> bool: - """Block any command within the verification channel that is not !accept.""" - is_verification = ctx.channel.id == constants.Channels.verification - if is_verification and await has_no_roles_check(ctx, *constants.MODERATION_ROLES): - return ctx.command.name == "accept" - else: - return True - @command(name='verify') @has_any_role(*constants.MODERATION_ROLES) async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None: diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 1c0988343..98fbcb303 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -93,10 +93,6 @@ class CodeJams(commands.Cog): connect=True ), guild.default_role: PermissionOverwrite(read_messages=False, connect=False), - guild.get_role(Roles.verified): PermissionOverwrite( - read_messages=False, - connect=False - ) } # Rest of members should just have read_messages diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py index 0e66df69c..bbe9271b3 100644 --- a/bot/rules/burst_shared.py +++ b/bot/rules/burst_shared.py @@ -2,20 +2,11 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message -from bot.constants import Channels - async def apply( last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """ - Detects repeated messages sent by multiple users. - - This filter never triggers in the verification channel. - """ - if last_message.channel.id == Channels.verification: - return - + """Detects repeated messages sent by multiple users.""" total_recent = len(recent_messages) if total_recent > config['max']: diff --git a/config-default.yml b/config-default.yml index e713a59d2..175460a31 100644 --- a/config-default.yml +++ b/config-default.yml @@ -173,7 +173,6 @@ guild: # Special bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 - verification: 352442727016693763 voice_gate: 764802555427029012 # Staff @@ -244,8 +243,6 @@ guild: python_community: &PY_COMMUNITY_ROLE 458226413825294336 sprinters: &SPRINTERS 758422482289426471 - unverified: 739794855945044069 - verified: 352427296948486144 # @Developers on PyDis voice_verified: 764802720779337729 # Staff @@ -514,18 +511,6 @@ python_news: webhook: *PYNEWS_WEBHOOK -verification: - unverified_after: 3 # Days after which non-Developers receive the @Unverified role - kicked_after: 30 # Days after which non-Developers get kicked from the guild - reminder_frequency: 28 # Hours between @Unverified pings - bot_message_delete_delay: 10 # Seconds before deleting bots response in #verification - - # Number in range [0, 1] determining the percentage of unverified users that are safe - # to be kicked from the guild in one batch, any larger amount will require staff confirmation, - # set this to 0 to require explicit approval for batches of any size - kick_confirmation_threshold: 0.01 # 1% - - voice_gate: minimum_days_member: 3 # How many days the user must have been a member for minimum_messages: 50 # How many messages a user must have to be eligible for voice diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py index 45e7b5b51..85d6a1173 100644 --- a/tests/bot/exts/utils/test_jams.py +++ b/tests/bot/exts/utils/test_jams.py @@ -118,11 +118,9 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(overwrites[member].read_messages) self.assertTrue(overwrites[member].connect) - # Everyone and verified role overwrite + # Everyone role overwrite self.assertFalse(overwrites[self.guild.default_role].read_messages) self.assertFalse(overwrites[self.guild.default_role].connect) - self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) - self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect) async def test_team_channels_creation(self): """Should create new voice and text channel for team.""" -- cgit v1.2.3 From 8b1276a0c70a020e26037019a8b277e572f88390 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 Jan 2021 18:49:23 -0800 Subject: Update discord.py to the 1.6 release --- Pipfile | 2 +- Pipfile.lock | 42 ++++++++++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/Pipfile b/Pipfile index 1a9c271b4..0868daf43 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -"discord.py" = {git = "https://github.com/Rapptz/discord.py.git", ref = "94f76e63947b102e5de6dae9a2cd687b308033"} +"discord.py" = "~=1.6.0" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 17d2f81ba..fbae5b3db 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8cc7415371be66ebc4dbfc3f3f27f19f743f4f1a9952ca30abf385a06047439b" + "sha256": "f9f28d3d98e12f92c179e6d88444d1a9ad57557683b7116a91f0b1650d399848" }, "pipfile-spec": 6, "requires": { @@ -211,6 +211,7 @@ "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" ], + "index": "pypi", "markers": "sys_platform == 'win32'", "version": "==0.4.4" }, @@ -230,13 +231,13 @@ "index": "pypi", "version": "==4.3.2" }, - "discord-py": { - "git": "https://github.com/Rapptz/discord.py.git", - "ref": "94f76e63947b102e5de6dae9a2cd687b308033dd" - }, "discord.py": { - "git": "https://github.com/Rapptz/discord.py.git", - "ref": "94f76e63947b102e5de6dae9a2cd687b308033" + "hashes": [ + "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12", + "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f" + ], + "index": "pypi", + "version": "==1.6.0" }, "docutils": { "hashes": [ @@ -582,6 +583,15 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, + "pyreadline": { + "hashes": [ + "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1", + "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e", + "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b" + ], + "markers": "sys_platform == 'win32'", + "version": "==2.1" + }, "python-dateutil": { "hashes": [ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", @@ -925,11 +935,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1", - "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df" + "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055", + "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e" ], "index": "pypi", - "version": "==2.4.1" + "version": "==2.5.0" }, "flake8-bugbear": { "hashes": [ @@ -987,11 +997,11 @@ }, "identify": { "hashes": [ - "sha256:7aef7a5104d6254c162990e54a203cdc0fd202046b6c415bd5d636472f6565c4", - "sha256:b2c71bf9f5c482c389cef816f3a15f1c9d7429ad70f497d4a2e522442d80c6de" + "sha256:18994e850ba50c37bcaed4832be8b354d6a06c8fb31f54e0e7ece76d32f69bc8", + "sha256:892473bf12e655884132a3a32aca737a3cbefaa34a850ff52d501773a45837bc" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.11" + "version": "==1.5.12" }, "idna": { "hashes": [ @@ -1115,11 +1125,11 @@ }, "virtualenv": { "hashes": [ - "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c", - "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b" + "sha256:205a7577275dd0d9223c730dd498e21a8910600085c3dee97412b041fc4b853b", + "sha256:7992b8de87e544a4ab55afc2240bf8388c4e3b5765d03784dad384bfdf9097ee" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.2.2" + "version": "==20.3.0" } } } -- cgit v1.2.3 From 6e0d810c9e5c8eac0dfabd75155edf744333fb02 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 15 Jan 2021 14:55:14 +0800 Subject: Remove unverified member information. We do not have the Developers role anymore, so there's no such thing as verified/unverified members. --- bot/exts/info/information.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index b9d46b9c0..73a377bf0 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -68,7 +68,6 @@ class Information(Cog): def get_extended_server_info(self, guild: Guild) -> str: """Return additional server info only visible in moderation channels.""" - unverified_count = guild.member_count - len(guild.get_role(constants.Roles.verified).members) talentpool_count = len(self.bot.get_cog("Talentpool").watched_users) bb_count = len(self.bot.get_cog("Big Brother").watched_users) @@ -78,10 +77,8 @@ class Information(Cog): python_general = self.bot.get_channel(constants.Channels.python_discussion) return textwrap.dedent(f""" - Unverified: {unverified_count:,} Nominated: {talentpool_count} BB-watched: {bb_count} - Defcon status: {defcon_status} Defcon days: {defcon_days} {python_general.mention} cooldown: {python_general.slowmode_delay}s @@ -207,7 +204,7 @@ class Information(Cog): # Additional info if ran in moderation channels if ctx.channel.id in constants.MODERATION_CHANNELS: embed.add_field( - name="Moderation Information:", value=self.get_extended_server_info(ctx.guild) + name="Moderation:", value=self.get_extended_server_info(ctx.guild) ) await ctx.send(embed=embed) -- cgit v1.2.3 From dd0d14c000a099c80f9a79e7bd9b2240edd240db Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 16 Jan 2021 17:18:48 +0100 Subject: Remove unwatch reason from the !nominate output As the watch reason can contain private information, we shouldn't share it with the whole staff. --- bot/exts/moderation/watchchannels/talentpool.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index df2ce586e..dd3349c3a 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -122,8 +122,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): if history: total = f"({len(history)} previous nominations in total)" start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}" - end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}" - msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}```" await ctx.send(msg) -- cgit v1.2.3 From 5e93396e1655715187d176d1cfdd32aa54616a1a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 16 Jan 2021 18:03:38 +0100 Subject: Add an allow_moderation_roles argument to the wait_for_deletion() util The `allow_moderation_roles` bool can be specified to allow anyone with a role in `MODERATION_ROLES` to delete the message. --- bot/utils/messages.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 42bde358d..b0b6cbf82 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -11,7 +11,7 @@ from discord.errors import HTTPException from discord.ext.commands import Context import bot -from bot.constants import Emojis, NEGATIVE_REPLIES +from bot.constants import Emojis, NEGATIVE_REPLIES, MODERATION_ROLES log = logging.getLogger(__name__) @@ -22,12 +22,15 @@ async def wait_for_deletion( deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, + allow_moderation_roles: bool = True ) -> None: """ 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`. + An `allow_moderation_roles` bool may also be specified to allow anyone with a role in `MODERATION_ROLES` to delete + the message. """ if message.guild is None: raise ValueError("Message must be sent on a guild") @@ -46,6 +49,7 @@ async def wait_for_deletion( reaction.message.id == message.id and str(reaction.emoji) in deletion_emojis and user.id in user_ids + or allow_moderation_roles and any(role.id in MODERATION_ROLES for role in user.roles) ) with contextlib.suppress(asyncio.TimeoutError): -- cgit v1.2.3 From 91b205593d9efe82038f8a35c69a089eb4fa68cf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 16 Jan 2021 20:19:34 +0200 Subject: Add startup and daemon tasks canceling on cog unload --- bot/exts/backend/branding/_cog.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 9afacb377..887eaf120 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -559,3 +559,9 @@ class BrandingManager(commands.Cog): return False return await self._apply_asset(guild, _constants.AssetType.SERVER_ICON, url) + + def cog_unload(self) -> None: + """Cancels startup and daemon task.""" + self._startup_task.cancel() + if self.daemon is not None: + self.daemon.cancel() -- cgit v1.2.3 From 72a3ef768ea3ef6472b92a94552f0859f776bf89 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Sat, 16 Jan 2021 18:24:24 +0000 Subject: Relock Pipfile --- Pipfile.lock | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 58492fe0d..8fd7f0964 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4b0241e9e78d021533671efa48a4f9972842c88dde14dbe89fb480eeb188efee" + "sha256": "26c8089f17d6d6bac11dbed366b1b46818b4546f243af756a106a32af5d9d8f6" }, "pipfile-spec": 6, "requires": { @@ -219,7 +219,6 @@ "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" ], - "index": "pypi", "markers": "sys_platform == 'win32'", "version": "==0.4.4" }, @@ -577,11 +576,11 @@ }, "pygments": { "hashes": [ - "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716", - "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08" + "sha256:bc9591213a8f0e0ca1a5e68a479b4887fdc3e75d0774e5c71c31920c427de435", + "sha256:df49d09b498e83c1a73128295860250b0b7edd4c723a32e9bc0d295c7c2ec337" ], "markers": "python_version >= '3.5'", - "version": "==2.7.3" + "version": "==2.7.4" }, "pyparsing": { "hashes": [ @@ -591,15 +590,6 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, - "pyreadline": { - "hashes": [ - "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1", - "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e", - "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b" - ], - "markers": "sys_platform == 'win32'", - "version": "==2.1" - }, "python-dateutil": { "hashes": [ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", @@ -1133,11 +1123,11 @@ }, "virtualenv": { "hashes": [ - "sha256:205a7577275dd0d9223c730dd498e21a8910600085c3dee97412b041fc4b853b", - "sha256:7992b8de87e544a4ab55afc2240bf8388c4e3b5765d03784dad384bfdf9097ee" + "sha256:0c111a2236b191422b37fe8c28b8c828ced39aab4bf5627fa5c331aeffb570d9", + "sha256:14b34341e742bdca219e10708198e704e8a7064dd32f474fc16aca68ac53a306" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.0" + "version": "==20.3.1" } } } -- cgit v1.2.3 From 491e0b93895d03a1985c2606d066c8ebd0ebd02d Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 16 Jan 2021 16:36:10 -0800 Subject: Removed 'Channels' import, unused. --- bot/exts/backend/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 2402fa175..14147398b 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -9,7 +9,7 @@ from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours, Icons, MODERATION_ROLES +from bot.constants import Colours, Icons, MODERATION_ROLES from bot.converters import TagNameConverter from bot.errors import LockedResourceError from bot.utils.checks import InWhitelistCheckFailure -- cgit v1.2.3 From 9e407f10c3766475d10a94120a5905c430a44bac Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 17 Jan 2021 00:55:06 +0000 Subject: Update to use Member.pending instead of bot.http.get_member --- bot/exts/moderation/verification.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 2a24c8ec6..bfe9b74b4 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -81,13 +81,11 @@ class Verification(Cog): if member.guild.id != constants.Guild.id: return # Only listen for PyDis events - raw_member = await self.bot.http.get_member(member.guild.id, member.id) - # If the user has the pending flag set, they will be using the alternate # gate and will not need a welcome DM with verification instructions. # We will send them an alternate DM once they verify with the welcome # video when they pass the gate. - if raw_member.get("pending"): + if member.pending: return log.trace(f"Sending on join message to new member: {member.id}") -- cgit v1.2.3 From 0ef3f765e4adbec8258585d51457c7cf48c49f87 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 17 Jan 2021 09:09:11 +0300 Subject: Adds Return On Tag Matches --- bot/exts/backend/error_handler.py | 3 ++- bot/exts/info/tags.py | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 14147398b..e42c8c6e6 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -155,7 +155,8 @@ class ErrorHandler(Cog): ) else: with contextlib.suppress(ResponseCodeError): - await ctx.invoke(tags_get_command, tag_name=tag_name) + if await ctx.invoke(tags_get_command, tag_name=tag_name): + return if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): tags_cog = self.bot.get_cog("Tags") diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 639286d90..00b4d1a78 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -281,9 +281,13 @@ class Tags(Cog): return False @tags_group.command(name='get', aliases=('show', 'g')) - 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.""" - await self.display_tag(ctx, tag_name) + async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> bool: + """ + Get a specified tag, or a list of all tags if no tag is specified. + + Returns False if a tag is on cooldown, or if no matches are found. + """ + return await self.display_tag(ctx, tag_name) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 9aa481e274931a13769478325234b49e83e85980 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 17 Jan 2021 09:14:52 +0300 Subject: Checks If Similar Command Is None --- bot/exts/backend/error_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index e42c8c6e6..da264ce2f 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -181,6 +181,9 @@ class ErrorHandler(Cog): similar_command_name = similar_command_data[0] similar_command = self.bot.get_command(similar_command_name) + if not similar_command: + return + log_msg = "Cancelling attempt to suggest a command due to failed checks." try: if not await similar_command.can_run(ctx): -- cgit v1.2.3 From eb78b9c261ecbcea4b2ca5bb0d423f7b3bfb9a0f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 16 Jan 2021 18:55:26 +0100 Subject: Restrict paginator usage to the author and moderators --- bot/pagination.py | 13 ++++++++++--- bot/utils/messages.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index 182b2fa76..09dbad7b5 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -8,6 +8,7 @@ from discord.abc import User from discord.ext.commands import Context, Paginator from bot import constants +from bot.constants import MODERATION_ROLES FIRST_EMOJI = "\u23EE" # [:track_previous:] LEFT_EMOJI = "\u2B05" # [:arrow_left:] @@ -210,6 +211,9 @@ class LinePaginator(Paginator): Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). + The interaction will be limited to `restrict_to_user` (ctx.author by default) or + to any user with a moderation role. + Example: >>> embed = discord.Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) @@ -218,10 +222,10 @@ class LinePaginator(Paginator): def event_check(reaction_: discord.Reaction, user_: discord.Member) -> bool: """Make sure that this reaction is what we want to operate on.""" no_restrictions = ( - # Pagination is not restricted - not restrict_to_user # The reaction was by a whitelisted user - or user_.id == restrict_to_user.id + user_.id == restrict_to_user.id + # The reaction was by a moderator + or any(role.id in MODERATION_ROLES for role in user_.roles) ) return ( @@ -242,6 +246,9 @@ class LinePaginator(Paginator): scale_to_size=scale_to_size) current_page = 0 + if not restrict_to_user: + restrict_to_user = ctx.author + if not lines: if exception_on_empty_embed: log.exception("Pagination asked for empty lines iterable") diff --git a/bot/utils/messages.py b/bot/utils/messages.py index b0b6cbf82..832ad4d55 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -11,7 +11,7 @@ from discord.errors import HTTPException from discord.ext.commands import Context import bot -from bot.constants import Emojis, NEGATIVE_REPLIES, MODERATION_ROLES +from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES log = logging.getLogger(__name__) -- cgit v1.2.3 From 5539bd03300f032bbb7554887eda814f31dd3592 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 17 Jan 2021 22:04:40 +0300 Subject: Stop Tag Matching On Suggestion --- bot/exts/backend/error_handler.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index da264ce2f..2f02fdee3 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -159,12 +159,7 @@ class ErrorHandler(Cog): return if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): - tags_cog = self.bot.get_cog("Tags") - command_name = ctx.invoked_with - sent = await tags_cog.display_tag(ctx, command_name) - - if not sent: - await self.send_command_suggestion(ctx, command_name) + await self.send_command_suggestion(ctx, ctx.invoked_with) # Return to not raise the exception return -- cgit v1.2.3 From 1fc0789aae6777f943f41b973539173fe24a24ae Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Mon, 18 Jan 2021 09:05:29 +0200 Subject: Update config-default.yml Whitelisted mod_meta and mod_tools as mod channels --- config-default.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config-default.yml b/config-default.yml index 175460a31..7845afbe8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -185,6 +185,8 @@ guild: mods: &MODS 305126844661760000 mod_alerts: 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 + mod_tools: &MOD_TOOLS 775413915391098921 + mod_meta: &MOD_META 775412552795947058 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 duck_pond: &DUCK_POND 637820308341915648 @@ -218,6 +220,8 @@ guild: moderation_channels: - *ADMINS - *ADMIN_SPAM + - *MOD_META + - *MOD_TOOLS - *MODS - *MOD_SPAM -- cgit v1.2.3 From f444864473931463199916bfa587216b75caf578 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 18 Jan 2021 16:12:03 -0800 Subject: Sync: chunk user requests The site can't handle huge syncs. Even a bulk patch of 10k users will crash the service. Chunk the requests into groups of 1000 users and await them sequentially. Testing showed that concurrent requests are not scalable and would also crash the service. --- bot/exts/backend/sync/_syncers.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 2eb9f9971..c9f2d2da8 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -5,12 +5,15 @@ from collections import namedtuple from discord import Guild from discord.ext.commands import Context +from more_itertools import chunked import bot from bot.api import ResponseCodeError log = logging.getLogger(__name__) +CHUNK_SIZE = 1000 + # 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', 'position')) @@ -207,10 +210,13 @@ class UserSyncer(Syncer): @staticmethod async def _sync(diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" + # Using asyncio.gather would still consume too many resources on the site. log.trace("Syncing created users...") if diff.created: - await bot.instance.api_client.post("bot/users", json=diff.created) + for chunk in chunked(diff.created, CHUNK_SIZE): + await bot.instance.api_client.post("bot/users", json=chunk) log.trace("Syncing updated users...") if diff.updated: - await bot.instance.api_client.patch("bot/users/bulk_patch", json=diff.updated) + for chunk in chunked(diff.updated, CHUNK_SIZE): + await bot.instance.api_client.patch("bot/users/bulk_patch", json=chunk) -- cgit v1.2.3 From a1c9e0030b63ccf1ed8dd540cb59881d41cebb71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 18 Jan 2021 16:48:55 -0800 Subject: Sync: test chunking of user requests --- tests/bot/exts/backend/sync/test_users.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 61673e1bb..27932be95 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -188,30 +188,37 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for the API requests that sync users.""" def setUp(self): - patcher = mock.patch("bot.instance", new=helpers.MockBot()) - self.bot = patcher.start() - self.addCleanup(patcher.stop) + bot_patcher = mock.patch("bot.instance", new=helpers.MockBot()) + self.bot = bot_patcher.start() + self.addCleanup(bot_patcher.stop) + + chunk_patcher = mock.patch("bot.exts.backend.sync._syncers.CHUNK_SIZE", 2) + self.chunk_size = chunk_patcher.start() + self.addCleanup(chunk_patcher.stop) + + self.chunk_count = 2 + self.users = [fake_user(id=i) for i in range(self.chunk_size * self.chunk_count)] async def test_sync_created_users(self): """Only POST requests should be made with the correct payload.""" - users = [fake_user(id=111), fake_user(id=222)] - - diff = _Diff(users, [], None) + diff = _Diff(self.users, [], None) await UserSyncer._sync(diff) - self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created) + self.bot.api_client.post.assert_any_call("bot/users", json=diff.created[:self.chunk_size]) + self.bot.api_client.post.assert_any_call("bot/users", json=diff.created[self.chunk_size:]) + self.assertEqual(self.bot.api_client.post.call_count, self.chunk_count) self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() async def test_sync_updated_users(self): """Only PUT requests should be made with the correct payload.""" - users = [fake_user(id=111), fake_user(id=222)] - - diff = _Diff([], users, None) + diff = _Diff([], self.users, None) await UserSyncer._sync(diff) - self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated) + self.bot.api_client.patch.assert_any_call("bot/users/bulk_patch", json=diff.updated[:self.chunk_size]) + self.bot.api_client.patch.assert_any_call("bot/users/bulk_patch", json=diff.updated[self.chunk_size:]) + self.assertEqual(self.bot.api_client.patch.call_count, self.chunk_count) self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() -- cgit v1.2.3 From 9e1dd9d6ddcf1bbf4762cac34745a5748787e887 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 Jan 2021 07:47:51 +0200 Subject: Replace in_whitelist check with commands.has_any_role check in_whitelist allow normal users also run commands in bot commands, but branding commands should be mod+ only, so we need to use has_any_role instead from discord.py. --- bot/exts/backend/branding/_cog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 887eaf120..20df83a89 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -13,7 +13,6 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import Branding, Colours, Emojis, Guild, MODERATION_ROLES -from bot.decorators import in_whitelist from bot.exts.backend.branding import _constants, _decorators, _errors, _seasons log = logging.getLogger(__name__) @@ -356,7 +355,7 @@ class BrandingManager(commands.Cog): failed_assets = [asset for asset, succeeded in report.items() if not succeeded] return failed_assets - @in_whitelist(roles=MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) @commands.group(name="branding") async def branding_cmds(self, ctx: commands.Context) -> None: """Manual branding control.""" -- cgit v1.2.3 From 3294224fce85381320ae77f4b04efcfd7a7d74e0 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 19 Jan 2021 16:40:59 +0200 Subject: Remove additional embed message --- bot/exts/filters/filtering.py | 7 +------ bot/exts/moderation/modlog.py | 3 --- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 208fc9e1f..3527bf8bb 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -48,7 +48,6 @@ class Stats(NamedTuple): message_content: str additional_embeds: Optional[List[discord.Embed]] - additional_embeds_msg: Optional[str] class Filtering(Cog): @@ -358,7 +357,6 @@ class Filtering(Cog): channel_id=Channels.mod_alerts, ping_everyone=ping_everyone, additional_embeds=stats.additional_embeds, - additional_embeds_msg=stats.additional_embeds_msg ) def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats: @@ -375,7 +373,6 @@ class Filtering(Cog): message_content = content additional_embeds = None - additional_embeds_msg = None self.bot.stats.incr(f"filters.{name}") @@ -392,13 +389,11 @@ class Filtering(Cog): embed.set_thumbnail(url=data["icon"]) embed.set_footer(text=f"Guild ID: {data['id']}") additional_embeds.append(embed) - additional_embeds_msg = "For the following guild(s):" elif name == "watch_rich_embeds": additional_embeds = match - additional_embeds_msg = "With the following embed(s):" - return Stats(message_content, additional_embeds, additional_embeds_msg) + return Stats(message_content, additional_embeds) @staticmethod def _check_filter(msg: Message) -> bool: diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index b01de0ee3..e4b119f41 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -92,7 +92,6 @@ class ModLog(Cog, name="ModLog"): files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, additional_embeds: t.Optional[t.List[discord.Embed]] = None, - additional_embeds_msg: t.Optional[str] = None, timestamp_override: t.Optional[datetime] = None, footer: t.Optional[str] = None, ) -> Context: @@ -133,8 +132,6 @@ class ModLog(Cog, name="ModLog"): ) if additional_embeds: - if additional_embeds_msg: - await channel.send(additional_embeds_msg) for additional_embed in additional_embeds: await channel.send(embed=additional_embed) -- cgit v1.2.3 From 7f920229e0d8245b2778833f5089997d3d46432a Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 19 Jan 2021 20:31:01 +0000 Subject: grammar and spelling fixes --- bot/resources/tags/enviroments.md | 18 ------------------ bot/resources/tags/environments.md | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 18 deletions(-) delete mode 100644 bot/resources/tags/enviroments.md create mode 100644 bot/resources/tags/environments.md diff --git a/bot/resources/tags/enviroments.md b/bot/resources/tags/enviroments.md deleted file mode 100644 index 5b2914cd9..000000000 --- a/bot/resources/tags/enviroments.md +++ /dev/null @@ -1,18 +0,0 @@ -**Python Enviroments** - -The main purpose of Python [virtual environments](https://docs.python.org/3/library/venv.html#venv-def) is to create an isolated environment for Python projects. This means that each project can have its own dependencies, such as third party packages installed using `pip`, regardless of what dependencies every other project has. - -To see the current enviroment in use by python you can run: -```py ->>> import sys ->>> print(sys.executable) -/usr/bin/python3 -``` - -To see the enviroment in use by `pip` you can do `pip debug`, or `pip3 debug` for linux/macOS. The 3rd line of the output will contain the path in use. I.E. `sys.executable: /usr/bin/python3` - -If the python's `sys.executable` doesn't match pip's then they are currently using different enviroments! This may cause python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different enviroment. - -Further reading: -• [Real Python's primer on Python Virtual Environments](https://realpython.com/python-virtual-environments-a-primer) -• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) diff --git a/bot/resources/tags/environments.md b/bot/resources/tags/environments.md new file mode 100644 index 000000000..f8825f0dd --- /dev/null +++ b/bot/resources/tags/environments.md @@ -0,0 +1,18 @@ +**Python Environments** + +The main purpose of Python [virtual environments](https://docs.Python.org/3/library/venv.html#venv-def) is to create an isolated environment for Python projects. This means that each project can have its own dependencies, such as third party packages installed using pip, regardless of what dependencies every other project has. + +To see the current environment in use by Python, you can run: +```py +>>> import sys +>>> print(sys.executable) +/usr/bin/Python3 +``` + +To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use. e.g. `sys.executable: /usr/bin/Python3`. + +If Python's `sys.executable` doesn't match pip's then they are currently using different environments! This may cause Python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different environment. + +Further reading: +• [Python Virtual Environments: A Primer](https://realPython.com/Python-virtual-environments-a-primer) +• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) -- cgit v1.2.3 From ab3e40356602a0a2a4404db61949e7570d029464 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 19 Jan 2021 21:25:37 +0000 Subject: Fix url --- bot/resources/tags/environments.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/environments.md b/bot/resources/tags/environments.md index f8825f0dd..f97873770 100644 --- a/bot/resources/tags/environments.md +++ b/bot/resources/tags/environments.md @@ -14,5 +14,5 @@ To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for L If Python's `sys.executable` doesn't match pip's then they are currently using different environments! This may cause Python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different environment. Further reading: -• [Python Virtual Environments: A Primer](https://realPython.com/Python-virtual-environments-a-primer) +• [Python Virtual Environments: A Primer](https://realpython.com/python-virtual-environments-a-primer) • [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) -- cgit v1.2.3 From 84a5afe4c28f037e070722e1f48f9ae80493f0fa Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 19 Jan 2021 21:35:13 +0000 Subject: Add a section on why to use a virtual env --- bot/resources/tags/environments.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/resources/tags/environments.md b/bot/resources/tags/environments.md index f97873770..df04b2770 100644 --- a/bot/resources/tags/environments.md +++ b/bot/resources/tags/environments.md @@ -6,13 +6,22 @@ To see the current environment in use by Python, you can run: ```py >>> import sys >>> print(sys.executable) -/usr/bin/Python3 +/usr/bin/python3 ``` -To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use. e.g. `sys.executable: /usr/bin/Python3`. +To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use. e.g. `sys.executable: /usr/bin/python3`. If Python's `sys.executable` doesn't match pip's then they are currently using different environments! This may cause Python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different environment. -Further reading: +**Why use a virtual environment?** + +• Resolve dependency issues by allowing the use of different versions of a package for different projects. For example, you could use Package A v2.7 for Project X and Package A v1.3 for Project Y. +• Make your project self-contained and reproducible by capturing all package dependencies in a requirements file. Try running `pip freeze` to see what you currently have installed! +• Install packages on a host on which you do not have admin privileges. +• Keep your global `site-packages/` directory tidy by removing the need to install packages system-wide which you might only need for one project. + + +**Further reading:** + • [Python Virtual Environments: A Primer](https://realpython.com/python-virtual-environments-a-primer) • [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) -- cgit v1.2.3 From fddde4c27ab0a75d861a42535b52986aae7ca429 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Wed, 20 Jan 2021 10:40:11 +0000 Subject: Apply suggestions from code review Grammar Co-authored-by: Mark --- bot/resources/tags/environments.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/environments.md b/bot/resources/tags/environments.md index df04b2770..51d1b4a20 100644 --- a/bot/resources/tags/environments.md +++ b/bot/resources/tags/environments.md @@ -9,9 +9,9 @@ To see the current environment in use by Python, you can run: /usr/bin/python3 ``` -To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use. e.g. `sys.executable: /usr/bin/python3`. +To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use e.g. `sys.executable: /usr/bin/python3`. -If Python's `sys.executable` doesn't match pip's then they are currently using different environments! This may cause Python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different environment. +If Python's `sys.executable` doesn't match pip's, then they are currently using different environments! This may cause Python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different environment. **Why use a virtual environment?** -- cgit v1.2.3 From 04e64c523df212815eb4b29d766f83916a3dd099 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 20 Jan 2021 10:41:20 +0000 Subject: Remove incorrect point --- bot/resources/tags/environments.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/environments.md b/bot/resources/tags/environments.md index 51d1b4a20..7bc69bde4 100644 --- a/bot/resources/tags/environments.md +++ b/bot/resources/tags/environments.md @@ -17,7 +17,6 @@ If Python's `sys.executable` doesn't match pip's, then they are currently using • Resolve dependency issues by allowing the use of different versions of a package for different projects. For example, you could use Package A v2.7 for Project X and Package A v1.3 for Project Y. • Make your project self-contained and reproducible by capturing all package dependencies in a requirements file. Try running `pip freeze` to see what you currently have installed! -• Install packages on a host on which you do not have admin privileges. • Keep your global `site-packages/` directory tidy by removing the need to install packages system-wide which you might only need for one project. -- cgit v1.2.3 From eccbebf8f29998dcee72b690ed405221363a984f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 20 Jan 2021 16:38:58 +0000 Subject: Prevent bot from sending DMs to itself Fixes BOT-KX --- bot/exts/moderation/dm_relay.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 4d5142b55..6d081741c 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -52,6 +52,10 @@ class DMRelay(Cog): await ctx.message.add_reaction("❌") return + if member.id == self.bot.user.id: + log.debug("Not sending message to bot user") + return await ctx.send("🚫 I can't send messages to myself!") + try: await member.send(message) except discord.errors.Forbidden: -- cgit v1.2.3 From 37bfd7569cdeb0900ee131c22f67aed3f78692ba Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Wed, 20 Jan 2021 20:19:47 +0300 Subject: Cleans Up Tests --- tests/bot/exts/moderation/infraction/test_infractions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 4ba5a4feb..13efee054 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -3,8 +3,9 @@ import unittest from unittest.mock import AsyncMock, MagicMock, Mock, patch from bot.constants import Event +from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction.infractions import Infractions -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec class TruncationTests(unittest.IsolatedAsyncioTestCase): @@ -164,9 +165,8 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): ) self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") - @patch("bot.exts.moderation.infraction._utils.get_active_infraction", return_value=None) - @patch("bot.exts.moderation.infraction._utils.post_infraction") - @patch("bot.exts.moderation.infraction.infractions.Infractions.apply_infraction") + @autospec(_utils, "post_infraction", "get_active_infraction", return_value=None) + @autospec(Infractions, "apply_infraction") async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): """Should voice ban user that left the guild without throwing an error.""" infraction = {"foo": "bar"} @@ -175,7 +175,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): user = MockUser() await self.cog.voiceban(self.cog, self.ctx, user, reason=None) post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True) - apply_infraction_mock.assert_called_once_with(self.ctx, infraction, user, None) + apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, None) async def test_voice_unban_user_not_found(self): """Should include info to return dict when user was not found from guild.""" -- cgit v1.2.3 From c2fcb1fe0255c532e07b777df0ca1ce70e8a1040 Mon Sep 17 00:00:00 2001 From: Anand Krishna Date: Wed, 20 Jan 2021 23:17:09 +0400 Subject: Duck Pond: Add `is_helper_viewable` check --- bot/exts/fun/duck_pond.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 48aa2749c..43be0dd83 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -3,7 +3,7 @@ import logging from typing import Union import discord -from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors +from discord import Color, Embed, Member, Message, RawReactionActionEvent, TextChannel, User, errors from discord.ext.commands import Cog, Context, command from bot import constants @@ -44,6 +44,17 @@ class DuckPond(Cog): return True return False + @staticmethod + def is_helper_viewable(channel: TextChannel) -> bool: + """Check if helpers can view a specific channel.""" + guild = channel.guild + helper_role = guild.get_role(constants.Roles.helpers) + # check channel overwrites for both the Helper role and @everyone and + # return True for channels that they have explicit permissions to view. + helper_overwrites = channel.overwrites_for(helper_role) + default_overwrites = channel.overwrites_for(guild.default_role) + return default_overwrites.view_channel or helper_overwrites.view_channel + async def has_green_checkmark(self, message: Message) -> bool: """Check if the message has a green checkmark reaction.""" for reaction in message.reactions: @@ -165,6 +176,10 @@ class DuckPond(Cog): message = await channel.fetch_message(payload.message_id) member = discord.utils.get(message.guild.members, id=payload.user_id) + # Was the message sent in a channel Helpers can see? + if not self.is_helper_viewable(channel): + return + # Was the message sent by a human staff member? if not self.is_staff(message.author) or message.author.bot: return -- cgit v1.2.3 From 73e89b8a7c8f12ae781aa0228e9b67c13232a34c Mon Sep 17 00:00:00 2001 From: Anand Krishna Date: Wed, 20 Jan 2021 23:18:24 +0400 Subject: Make type hints uniform across file --- bot/exts/fun/duck_pond.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 43be0dd83..c97f4b0cc 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -118,7 +118,7 @@ class DuckPond(Cog): except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") - async def locked_relay(self, message: discord.Message) -> bool: + async def locked_relay(self, message: Message) -> bool: """Relay a message after obtaining the relay lock.""" if self.relay_lock is None: # Lazily load the lock to ensure it's created within the @@ -216,7 +216,7 @@ class DuckPond(Cog): @command(name="duckify", aliases=("duckpond", "pondify")) @has_any_role(constants.Roles.admins) - async def duckify(self, ctx: Context, message: discord.Message) -> None: + async def duckify(self, ctx: Context, message: Message) -> None: """Relay a message to the duckpond, no ducks required!""" if await self.locked_relay(message): await ctx.message.add_reaction("🦆") -- cgit v1.2.3 From b75cb590b7ad7006b973e0c275a316057c5ff09c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 21 Jan 2021 01:03:40 +0200 Subject: Slowmode reset now uses slowmode set --- bot/exts/moderation/slowmode.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index efd862aa5..80eec34a0 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -75,16 +75,7 @@ class Slowmode(Cog): @slowmode_group.command(name='reset', aliases=['r']) async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: """Reset the slowmode delay for a text channel to 0 seconds.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') - - await channel.edit(slowmode_delay=0) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' - ) + await self.set_slowmode(ctx, channel, relativedelta(seconds=0)) async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" -- cgit v1.2.3 From 06c5eba63f735237b44f2b1da9944bc7cddd2177 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 21 Jan 2021 01:26:41 +0300 Subject: Restructures Voice Ban Action Updates the voice ban action so the infraction pardoning is always run, and so all operations are handled in the _scheduler. Updates tests. --- bot/exts/moderation/infraction/infractions.py | 11 ++++--- .../exts/moderation/infraction/test_infractions.py | 37 +++++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 78c326c47..be4327bb0 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -355,14 +355,15 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - action = None + async def action() -> None: + # Skip members that left the server + if not isinstance(user, Member): + return - # Skip members that left the server - if isinstance(user, Member): await user.move_to(None, reason="Disconnected from voice to apply voiceban.") - action = user.remove_roles(self._voice_verified_role, reason=reason) + await user.remove_roles(self._voice_verified_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action) + await self.apply_infraction(ctx, infraction, user, action()) # endregion # region: Base pardon functions diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 13efee054..86c2617ea 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,6 +1,7 @@ +import inspect import textwrap import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from bot.constants import Event from bot.exts.moderation.infraction import _utils @@ -133,20 +134,29 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + async def action_tester(self, action, reason: str) -> None: + """Helper method to test voice ban action.""" + self.assertTrue(inspect.iscoroutine(action)) + await action + + self.user.move_to.assert_called_once_with(None, reason=ANY) + self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason=reason) + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): """Should ignore Voice Verified role removing.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() - self.user.remove_roles = MagicMock(return_value="my_return_value") get_active_infraction.return_value = None post_infraction_mock.return_value = {"foo": "bar"} - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") - self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + reason = "foobar" + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason)) + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) + + await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") @@ -154,16 +164,16 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should truncate reason for voice ban.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() - self.user.remove_roles = MagicMock(return_value="my_return_value") get_active_infraction.return_value = None post_infraction_mock.return_value = {"foo": "bar"} self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) - self.user.remove_roles.assert_called_once_with( - self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") - ) - self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) + + # Test action + action = self.cog.apply_infraction.call_args[0][-1] + await self.action_tester(action, textwrap.shorten("foobar" * 3000, 512, placeholder="...")) @autospec(_utils, "post_infraction", "get_active_infraction", return_value=None) @autospec(Infractions, "apply_infraction") @@ -175,7 +185,12 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): user = MockUser() await self.cog.voiceban(self.cog, self.ctx, user, reason=None) post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True) - apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, None) + apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY) + + # Test action + action = self.cog.apply_infraction.call_args[0][-1] + self.assertTrue(inspect.iscoroutine(action)) + await action async def test_voice_unban_user_not_found(self): """Should include info to return dict when user was not found from guild.""" -- cgit v1.2.3 From a41b932436b8b5c07c0f5ea3bd7ee886fa01e88e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 21 Jan 2021 02:25:46 +0300 Subject: Updates Apply Infraction Docstring --- bot/exts/moderation/infraction/_scheduler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 242b2d30f..a73f2e8da 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -102,6 +102,7 @@ class InfractionScheduler: """ Apply an infraction to the user, log the infraction, and optionally notify the user. + `action_coro`, if not provided, will result in the infraction not getting scheduled for deletion. `user_reason`, if provided, will be sent to the user in place of the infraction reason. `additional_info` will be attached to the text field in the mod-log embed. -- cgit v1.2.3 From 1478da27d899d23de72c9236a359a92a57708f1c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 21 Jan 2021 02:51:34 +0200 Subject: Added slowmode stat for python-general. --- bot/constants.py | 2 +- bot/exts/moderation/slowmode.py | 6 +++++- config-default.yml | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index be8d303f6..f2cc64702 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -429,7 +429,7 @@ class Channels(metaclass=YAMLGetter): off_topic_1: int off_topic_2: int organisation: int - python_discussion: int + python_general: int python_events: int python_news: int reddit: int diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index 80eec34a0..b72985d73 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -7,7 +7,7 @@ from discord import TextChannel from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES +from bot.constants import Channels, Emojis, MODERATION_ROLES from bot.converters import DurationDelta from bot.utils import time @@ -58,6 +58,10 @@ class Slowmode(Cog): log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') await channel.edit(slowmode_delay=slowmode_delay) + if channel.id == Channels.python_general: + log.info(f'Recording slowmode change in stats for {channel.name}.') + self.bot.stats.gauge(f"slowmode.{channel.name}", slowmode_delay) + await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' ) diff --git a/config-default.yml b/config-default.yml index f8368c5d2..530feffe5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -157,7 +157,7 @@ guild: # Discussion meta: 429409067623251969 - python_discussion: &PY_DISCUSSION 267624335836053506 + python_general: &PY_GENERAL 267624335836053506 # Python Help: Available cooldown: 720603994149486673 @@ -430,7 +430,7 @@ code_block: # The channels which will be affected by a cooldown. These channels are also whitelisted. cooldown_channels: - - *PY_DISCUSSION + - *PY_GENERAL # Sending instructions triggers a cooldown on a per-channel basis. # More instruction messages will not be sent in the same channel until the cooldown has elapsed. -- cgit v1.2.3 From 83f9755a7a03d10cff62bcae0556a981e4b679c0 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 21 Jan 2021 04:05:23 +0200 Subject: Changed slowmode reset tests --- tests/bot/exts/moderation/test_slowmode.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py index dad751e0d..5483b7a64 100644 --- a/tests/bot/exts/moderation/test_slowmode.py +++ b/tests/bot/exts/moderation/test_slowmode.py @@ -85,22 +85,14 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): self.ctx.reset_mock() - async def test_reset_slowmode_no_channel(self) -> None: - """Reset slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) - - await self.cog.reset_slowmode(self.cog, self.ctx, None) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' - ) - - async def test_reset_slowmode_with_channel(self) -> None: + async def test_reset_slowmode_sets_delay_to_zero(self) -> None: """Reset slowmode with a given channel.""" text_channel = MockTextChannel(name='meta', slowmode_delay=1) + self.cog.set_slowmode = mock.AsyncMock() await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' + self.cog.set_slowmode.assert_awaited_once_with( + self.ctx, text_channel, relativedelta(seconds=0) ) @mock.patch("bot.exts.moderation.slowmode.has_any_role") -- cgit v1.2.3 From 5c45e7f9923a435732e6b593b335ae9750f00fd2 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 21 Jan 2021 14:36:57 +0200 Subject: Added slowmode tracking to dpy and ot0 --- bot/constants.py | 1 + bot/exts/moderation/slowmode.py | 10 ++++++++-- config-default.yml | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f2cc64702..2f5cf0e8a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -408,6 +408,7 @@ class Channels(metaclass=YAMLGetter): code_help_voice_2: int cooldown: int defcon: int + discord_py: int dev_contrib: int dev_core: int dev_log: int diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index b72985d73..c449752e1 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -15,6 +15,12 @@ log = logging.getLogger(__name__) SLOWMODE_MAX_DELAY = 21600 # seconds +COMMONLY_SLOWMODED_CHANNELS = { + Channels.python_general: "python_general", + Channels.discord_py: "discordpy", + Channels.off_topic_0: "ot0", +} + class Slowmode(Cog): """Commands for getting and setting slowmode delays of text channels.""" @@ -58,9 +64,9 @@ class Slowmode(Cog): log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') await channel.edit(slowmode_delay=slowmode_delay) - if channel.id == Channels.python_general: + if channel.id in COMMONLY_SLOWMODED_CHANNELS: log.info(f'Recording slowmode change in stats for {channel.name}.') - self.bot.stats.gauge(f"slowmode.{channel.name}", slowmode_delay) + self.bot.stats.gauge(f"slowmode.{COMMONLY_SLOWMODED_CHANNELS[channel.id]}", slowmode_delay) await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' diff --git a/config-default.yml b/config-default.yml index 530feffe5..6695cffed 100644 --- a/config-default.yml +++ b/config-default.yml @@ -162,6 +162,9 @@ guild: # Python Help: Available cooldown: 720603994149486673 + # Topical + discord_py: 343944376055103488 + # Logs attachment_log: &ATTACH_LOG 649243850006855680 message_log: &MESSAGE_LOG 467752170159079424 -- cgit v1.2.3 From a0c2869bcaecd4eeccb6f57b1d46925525364cc1 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 21 Jan 2021 19:03:46 +0000 Subject: Fix aliases of shadow tempban --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 18e937e87..b3d069b34 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -198,7 +198,7 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Temporary shadow infractions - @command(hidden=True, aliases=["shadowtempban, stempban"]) + @command(hidden=True, aliases=["shadowtempban", "stempban"]) async def shadow_tempban( self, ctx: Context, -- cgit v1.2.3 From 8ebc08351e84b49edb7aeb95e5430b74c2369ff9 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 22 Jan 2021 10:09:46 +0100 Subject: Update badges on the README file --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c813997e7..da2d0bf03 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ [![Build][3]][4] [![Deploy][5]][6] [![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot) -[![License](https://img.shields.io/github/license/python-discord/bot)](LICENSE) -[![Website](https://img.shields.io/badge/website-visit-brightgreen)](https://pythondiscord.com) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities and other tools to help keep the server running like a well-oiled machine. @@ -19,5 +18,5 @@ Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) [4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster [5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master [6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster -[7]: https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white -[8]: https://discord.gg/2B963hn +[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.png +[8]: https://discord.gg/python -- cgit v1.2.3 From 65e2640d8d13eac4cf6afdbee8ab9a68e7c0ecba Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 22 Jan 2021 10:44:49 +0100 Subject: Use the SVG badge on the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da2d0bf03..ac45e6340 100644 --- a/README.md +++ b/README.md @@ -18,5 +18,5 @@ Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) [4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster [5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master [6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster -[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.png +[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.svg [8]: https://discord.gg/python -- cgit v1.2.3 From c4399916db810a82adce1111fb6367e2228f8ef1 Mon Sep 17 00:00:00 2001 From: Anand Krishna Date: Fri, 22 Jan 2021 19:23:38 +0400 Subject: Do `is_helper_viewable` check before fetching message --- bot/exts/fun/duck_pond.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index c97f4b0cc..3eed25781 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -173,13 +173,13 @@ class DuckPond(Cog): if channel is None: return - message = await channel.fetch_message(payload.message_id) - member = discord.utils.get(message.guild.members, id=payload.user_id) - # Was the message sent in a channel Helpers can see? if not self.is_helper_viewable(channel): return + message = await channel.fetch_message(payload.message_id) + member = discord.utils.get(message.guild.members, id=payload.user_id) + # Was the message sent by a human staff member? if not self.is_staff(message.author) or message.author.bot: return -- cgit v1.2.3 From 576146af6a01b387c6b0abec1309169aa77a5bf2 Mon Sep 17 00:00:00 2001 From: PH-KDX <50588793+PH-KDX@users.noreply.github.com> Date: Fri, 22 Jan 2021 16:23:39 +0100 Subject: Create voice.md This tag would provide info for users which are not voice-verified, so that they can easily be directed toward the appropriate channel. --- bot/resources/tags/voice.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 bot/resources/tags/voice.md diff --git a/bot/resources/tags/voice.md b/bot/resources/tags/voice.md new file mode 100644 index 000000000..3d88b0c71 --- /dev/null +++ b/bot/resources/tags/voice.md @@ -0,0 +1,3 @@ +**Voice verification** + +Can’t talk in voice chat? Check out <#764802555427029012> to get access. The criteria for verifying are specified there. -- cgit v1.2.3 From ca38700f7fc77e0b25f647fc1e9821ec7c8c81cd Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 23 Jan 2021 17:16:12 +0800 Subject: Extract `is_staff_channel` to a utility function. --- bot/exts/info/information.py | 21 +++------------------ bot/utils/channel.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 73a377bf0..c20dfc6f0 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -5,8 +5,7 @@ import textwrap from collections import defaultdict from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Message, Role, utils -from discord.abc import GuildChannel +from discord import Colour, Embed, Guild, Message, Role, utils from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from bot import constants @@ -15,7 +14,7 @@ from bot.bot import Bot from bot.converters import FetchedMember from bot.decorators import in_whitelist from bot.pagination import LinePaginator -from bot.utils.channel import is_mod_channel +from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.time import time_since @@ -28,27 +27,13 @@ class Information(Cog): def __init__(self, bot: Bot): self.bot = bot - @staticmethod - def is_staff_channel(guild: Guild, channel: GuildChannel) -> bool: - """Determines if a given channel is staff-only.""" - if channel.type is ChannelType.category: - return False - - # Channel is staff-only if staff have explicit read allow perms - # and @everyone has explicit read deny perms - return any( - channel.overwrites_for(guild.get_role(staff_role)).read_messages is True - and channel.overwrites_for(guild.default_role).read_messages is False - for staff_role in constants.STAFF_ROLES - ) - @staticmethod def get_channel_type_counts(guild: Guild) -> DefaultDict[str, int]: """Return the total amounts of the various types of channels in `guild`.""" channel_counter = defaultdict(int) for channel in guild.channels: - if Information.is_staff_channel(guild, channel): + if is_staff_channel(channel): channel_counter["staff"] += 1 else: channel_counter[str(channel.type)] += 1 diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 0c072184c..72603c521 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -32,6 +32,22 @@ def is_mod_channel(channel: discord.TextChannel) -> bool: return False +def is_staff_channel(channel: discord.TextChannel) -> bool: + """True if `channel` is considered a staff channel.""" + guild = bot.instance.get_guild(constants.Guild.id) + + if channel.type is discord.ChannelType.category: + return False + + # Channel is staff-only if staff have explicit read allow perms + # and @everyone has explicit read deny perms + return any( + channel.overwrites_for(guild.get_role(staff_role)).read_messages is True + and channel.overwrites_for(guild.default_role).read_messages is False + for staff_role in constants.STAFF_ROLES + ) + + def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" return getattr(channel, "category_id", None) == category_id -- cgit v1.2.3 From a7c6d574c91dd5dfb256303d378f1ab1a8fcb737 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 23 Jan 2021 17:22:32 +0800 Subject: Use helper function to determine mod channel. --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c20dfc6f0..e475d0b75 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -187,7 +187,7 @@ class Information(Cog): embed.add_field(name=f"Channels: {total_channels}", value=channel_info) # Additional info if ran in moderation channels - if ctx.channel.id in constants.MODERATION_CHANNELS: + if is_mod_channel(ctx.channel): embed.add_field( name="Moderation:", value=self.get_extended_server_info(ctx.guild) ) -- cgit v1.2.3 From 8cdee214cca54ce1fdbfc98b38ce95c1ed5a3a0c Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 23 Jan 2021 17:25:06 +0800 Subject: Reduce unnecessary line splits and parameters. --- bot/exts/info/information.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index e475d0b75..343942109 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -51,7 +51,7 @@ class Information(Cog): ) return {role.name.title(): len(role.members) for role in roles} - def get_extended_server_info(self, guild: Guild) -> str: + def get_extended_server_info(self) -> str: """Return additional server info only visible in moderation channels.""" talentpool_count = len(self.bot.get_cog("Talentpool").watched_users) bb_count = len(self.bot.get_cog("Big Brother").watched_users) @@ -136,10 +136,7 @@ class Information(Cog): @command(name="server", aliases=["server_info", "guild", "guild_info"]) async def server_info(self, ctx: Context) -> None: """Returns an embed full of server information.""" - embed = Embed( - colour=Colour.blurple(), - title="Server Information", - ) + embed = Embed(colour=Colour.blurple(), title="Server Information") created = time_since(ctx.guild.created_at, precision="days") region = ctx.guild.region @@ -164,7 +161,8 @@ class Information(Cog): embed.description = textwrap.dedent(f""" Created: {created} - Voice region: {region}{features} + Voice region: {region}\ + {features} Roles: {num_roles} Member status: {member_status} """) @@ -173,9 +171,7 @@ class Information(Cog): # Members total_members = ctx.guild.member_count member_counts = self.get_member_counts(ctx.guild) - member_info = "\n".join( - f"{role}: {count}" for role, count in member_counts.items() - ) + member_info = "\n".join(f"{role}: {count}" for role, count in member_counts.items()) embed.add_field(name=f"Members: {total_members}", value=member_info) # Channels @@ -188,9 +184,7 @@ class Information(Cog): # Additional info if ran in moderation channels if is_mod_channel(ctx.channel): - embed.add_field( - name="Moderation:", value=self.get_extended_server_info(ctx.guild) - ) + embed.add_field(name="Moderation:", value=self.get_extended_server_info()) await ctx.send(embed=embed) -- cgit v1.2.3 From f612a2630b7b324b3c73a0e9188b08449b8f1401 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 23 Jan 2021 17:44:56 +0800 Subject: Handle unloaded cogs when retrieving server info. --- bot/exts/info/information.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 343942109..1fa045416 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -53,19 +53,26 @@ class Information(Cog): def get_extended_server_info(self) -> str: """Return additional server info only visible in moderation channels.""" - talentpool_count = len(self.bot.get_cog("Talentpool").watched_users) - bb_count = len(self.bot.get_cog("Big Brother").watched_users) + talentpool_info = "" + if (cog := self.bot.get_cog("Talentpool")): + talentpool_info = f"Nominated: {len(cog.watched_users)}\n" + + bb_info = "" + if (cog := self.bot.get_cog("Big Brother")): + bb_info = f"BB-watched: {len(cog.watched_users)}\n" + + defcon_info = "" + if (cog := self.bot.get_cog("Defcon")): + defcon_status = "Enabled" if cog.enabled else "Disabled" + defcon_days = cog.days.days if cog.enabled else "-" + defcon_info = f"Defcon status: {defcon_status}\nDefcon days: {defcon_days}\n" - defcon_cog = self.bot.get_cog("Defcon") - defcon_status = "Enabled" if defcon_cog.enabled else "Disabled" - defcon_days = defcon_cog.days.days if defcon_cog.enabled else "-" python_general = self.bot.get_channel(constants.Channels.python_discussion) return textwrap.dedent(f""" - Nominated: {talentpool_count} - BB-watched: {bb_count} - Defcon status: {defcon_status} - Defcon days: {defcon_days} + {talentpool_info}\ + {bb_info}\ + {defcon_info}\ {python_general.mention} cooldown: {python_general.slowmode_delay}s """) -- cgit v1.2.3 From eb45e9895a0bf1b294bbdca9b154ee433942989a Mon Sep 17 00:00:00 2001 From: PH-KDX <50588793+PH-KDX@users.noreply.github.com> Date: Sat, 23 Jan 2021 19:46:09 +0100 Subject: Rename voice.md to voice-verification.md --- bot/resources/tags/voice-verification.md | 3 +++ bot/resources/tags/voice.md | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 bot/resources/tags/voice-verification.md delete mode 100644 bot/resources/tags/voice.md diff --git a/bot/resources/tags/voice-verification.md b/bot/resources/tags/voice-verification.md new file mode 100644 index 000000000..3d88b0c71 --- /dev/null +++ b/bot/resources/tags/voice-verification.md @@ -0,0 +1,3 @@ +**Voice verification** + +Can’t talk in voice chat? Check out <#764802555427029012> to get access. The criteria for verifying are specified there. diff --git a/bot/resources/tags/voice.md b/bot/resources/tags/voice.md deleted file mode 100644 index 3d88b0c71..000000000 --- a/bot/resources/tags/voice.md +++ /dev/null @@ -1,3 +0,0 @@ -**Voice verification** - -Can’t talk in voice chat? Check out <#764802555427029012> to get access. The criteria for verifying are specified there. -- cgit v1.2.3 From 43cdae4e495b03fabf41805ca4e9e93f60f3ec7a Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 24 Jan 2021 13:16:30 +0800 Subject: Remove redundant parenthesis. --- bot/exts/info/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 917465538..619204e5d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -55,15 +55,15 @@ class Information(Cog): def get_extended_server_info(self) -> str: """Return additional server info only visible in moderation channels.""" talentpool_info = "" - if (cog := self.bot.get_cog("Talentpool")): + if cog := self.bot.get_cog("Talentpool"): talentpool_info = f"Nominated: {len(cog.watched_users)}\n" bb_info = "" - if (cog := self.bot.get_cog("Big Brother")): + if cog := self.bot.get_cog("Big Brother"): bb_info = f"BB-watched: {len(cog.watched_users)}\n" defcon_info = "" - if (cog := self.bot.get_cog("Defcon")): + if cog := self.bot.get_cog("Defcon"): defcon_status = "Enabled" if cog.enabled else "Disabled" defcon_days = cog.days.days if cog.enabled else "-" defcon_info = f"Defcon status: {defcon_status}\nDefcon days: {defcon_days}\n" -- cgit v1.2.3 From 8931464423fb7723f1a4d7aa859a1fd85cb0c406 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 24 Jan 2021 18:47:28 +0300 Subject: Reorganizes Config --- config-default.yml | 182 ++++++++++++++++++++++++++--------------------------- 1 file changed, 90 insertions(+), 92 deletions(-) diff --git a/config-default.yml b/config-default.yml index f8368c5d2..26c365d5e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,74 +1,75 @@ bot: prefix: "!" - token: !ENV "BOT_TOKEN" sentry_dsn: !ENV "BOT_SENTRY_DSN" + token: !ENV "BOT_TOKEN" + + clean: + # Maximum number of messages to traverse for clean commands + message_limit: 10000 + + cooldowns: + # Per channel, per tag. + tags: 60 redis: host: "redis.default.svc.cluster.local" - port: 6379 password: !ENV "REDIS_PASSWORD" + port: 6379 use_fakeredis: false stats: - statsd_host: "graphite.default.svc.cluster.local" presence_update_timeout: 300 - - cooldowns: - # Per channel, per tag. - tags: 60 - - clean: - # Maximum number of messages to traverse for clean commands - message_limit: 10000 + statsd_host: "graphite.default.svc.cluster.local" style: colours: - soft_red: 0xcd6d6d + bright_green: 0x01d277 soft_green: 0x68c290 soft_orange: 0xf9cb54 - bright_green: 0x01d277 + soft_red: 0xcd6d6d orange: 0xe67e22 pink: 0xcf84e0 purple: 0xb734eb emojis: - defcon_disabled: "<:defcondisabled:470326273952972810>" - defcon_enabled: "<:defconenabled:470326274213150730>" - defcon_updated: "<:defconsettingsupdated:470326274082996224>" - - status_online: "<:status_online:470326272351010816>" - status_idle: "<:status_idle:470326266625785866>" - status_dnd: "<:status_dnd:470326272082313216>" - status_offline: "<:status_offline:470326266537705472>" - - badge_staff: "<:discord_staff:743882896498098226>" - badge_partner: "<:partner:748666453242413136>" - badge_hypesquad: "<:hypesquad_events:743882896892362873>" badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" + badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" + badge_early_supporter: "<:early_supporter:743882896909140058>" + badge_hypesquad: "<:hypesquad_events:743882896892362873>" + badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" - badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" - badge_early_supporter: "<:early_supporter:743882896909140058>" - badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" + badge_partner: "<:partner:748666453242413136>" + badge_staff: "<:discord_staff:743882896498098226>" badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" - incident_actioned: "<:incident_actioned:719645530128646266>" - incident_unactioned: "<:incident_unactioned:719645583245180960>" - incident_investigating: "<:incident_investigating:719645658671480924>" + defcon_disabled: "<:defcondisabled:470326273952972810>" + defcon_enabled: "<:defconenabled:470326274213150730>" + defcon_updated: "<:defconsettingsupdated:470326274082996224>" failmail: "<:failmail:633660039931887616>" + + incident_actioned: "<:incident_actioned:719645530128646266>" + incident_investigating: "<:incident_investigating:719645658671480924>" + incident_unactioned: "<:incident_unactioned:719645583245180960>" + + status_dnd: "<:status_dnd:470326272082313216>" + status_idle: "<:status_idle:470326266625785866>" + status_offline: "<:status_offline:470326266537705472>" + status_online: "<:status_online:470326272351010816>" + trashcan: "<:trashcan:637136429717389331>" bullet: "\u2022" - pencil: "\u270F" - new: "\U0001F195" - cross_mark: "\u274C" check_mark: "\u2705" + cross_mark: "\u274C" + new: "\U0001F195" + pencil: "\u270F" # emotes used for #reddit - upvotes: "<:reddit_upvotes:755845219890757644>" comments: "<:reddit_comments:755845255001014384>" + upvotes: "<:reddit_upvotes:755845219890757644>" user: "<:reddit_users:755845303822974997>" ok_hand: ":ok_hand:" @@ -85,6 +86,7 @@ style: filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png" + green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-checkmark-dist.png" guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png" hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png" @@ -95,38 +97,34 @@ style: message_delete: "https://cdn.discordapp.com/emojis/472472641320648704.png" message_edit: "https://cdn.discordapp.com/emojis/472472638976163870.png" + pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" + + questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.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" + sign_in: "https://cdn.discordapp.com/emojis/469952898181234698.png" sign_out: "https://cdn.discordapp.com/emojis/469952898089091082.png" + superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png" + unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.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_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png" user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" + user_update: "https://cdn.discordapp.com/emojis/469952898684551168.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" - - superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png" - unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png" - voice_state_blue: "https://cdn.discordapp.com/emojis/656899769662439456.png" voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png" voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" - green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-checkmark-dist.png" - guild: id: 267624335836053506 @@ -134,19 +132,19 @@ guild: categories: help_available: 691405807388196926 - help_in_use: 696958401460043776 help_dormant: 691405908919451718 - modmail: &MODMAIL 714494672835444826 + help_in_use: 696958401460043776 logs: &LOGS 468520609152892958 + modmail: &MODMAIL 714494672835444826 voice: 356013253765234688 channels: # Public announcement and news channels - change_log: &CHANGE_LOG 748238795236704388 announcements: &ANNOUNCEMENTS 354619224620138496 - python_news: &PYNEWS_CHANNEL 704372456592506880 - python_events: &PYEVENTS_CHANNEL 729674110270963822 + change_log: &CHANGE_LOG 748238795236704388 mailing_lists: &MAILING_LISTS 704372456592506880 + python_events: &PYEVENTS_CHANNEL 729674110270963822 + python_news: &PYNEWS_CHANNEL 704372456592506880 reddit: &REDDIT_CHANNEL 458224812528238616 user_event_announcements: &USER_EVENT_A 592000283102674944 @@ -164,11 +162,11 @@ guild: # Logs attachment_log: &ATTACH_LOG 649243850006855680 + dm_log: 653713721625018428 message_log: &MESSAGE_LOG 467752170159079424 mod_log: &MOD_LOG 282638479504965634 user_log: 528976905546760203 voice_log: 640292421988646961 - dm_log: 653713721625018428 # Off-topic off_topic_0: 291284109232308226 @@ -184,22 +182,22 @@ guild: admins: &ADMINS 365960823622991872 admin_spam: &ADMIN_SPAM 563594791770914816 defcon: &DEFCON 464469101889454091 + duck_pond: &DUCK_POND 637820308341915648 helpers: &HELPERS 385474242440986624 incidents: 714214212200562749 incidents_archive: 720668923636351037 mods: &MODS 305126844661760000 mod_alerts: 473092532147060736 + mod_meta: &MOD_META 775412552795947058 mod_spam: &MOD_SPAM 620607373828030464 mod_tools: &MOD_TOOLS 775413915391098921 - mod_meta: &MOD_META 775412552795947058 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 - duck_pond: &DUCK_POND 637820308341915648 # Staff announcement channels - staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042 - mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225 admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 + mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225 + staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042 # Voice Channels admins_voice: &ADMINS_VOICE 500734494840717332 @@ -251,7 +249,6 @@ guild: partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 sprinters: &SPRINTERS 758422482289426471 - voice_verified: 764802720779337729 # Staff @@ -266,15 +263,15 @@ guild: team_leaders: 737250302834638889 moderation_roles: - - *OWNERS_ROLE - *ADMINS_ROLE - *MODS_ROLE + - *OWNERS_ROLE staff_roles: - - *OWNERS_ROLE - *ADMINS_ROLE - - *MODS_ROLE - *HELPERS_ROLE + - *MODS_ROLE + - *OWNERS_ROLE webhooks: big_brother: 569133704568373283 @@ -289,47 +286,47 @@ guild: filter: # What do we filter? - filter_zalgo: false - filter_invites: true filter_domains: true filter_everyone_ping: true + filter_invites: true watch_regex: true watch_rich_embeds: true + filter_zalgo: false # Notify user on filter? # Notifications are not expected for "watchlist" type filters - notify_user_zalgo: false - notify_user_invites: true notify_user_domains: false notify_user_everyone_ping: true + notify_user_invites: true + notify_user_zalgo: false # Filter configuration - ping_everyone: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? + ping_everyone: true # Censor doesn't apply to these channel_whitelist: - *ADMINS - - *MOD_LOG - - *MESSAGE_LOG - - *DEV_LOG - *BB_LOGS + - *DEV_LOG + - *MESSAGE_LOG + - *MOD_LOG - *STAFF_LOUNGE - *TALENT_POOL - *USER_EVENT_A role_whitelist: - *ADMINS_ROLE + - *HELPERS_ROLE - *MODS_ROLE - *OWNERS_ROLE - - *HELPERS_ROLE - *PY_COMMUNITY_ROLE - *SPRINTERS keys: - site_api: !ENV "BOT_API_KEY" github: !ENV "GITHUB_API_KEY" + site_api: !ENV "BOT_API_KEY" urls: @@ -337,11 +334,11 @@ 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_staff: &STAFF !JOIN ["staff.", *DOMAIN] - site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] + site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] # Snekbox snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval" @@ -361,8 +358,8 @@ anti_spam: ping_everyone: true punishment: - role_id: *MUTED_ROLE remove_after: 600 + role_id: *MUTED_ROLE rules: attachments: @@ -385,14 +382,14 @@ anti_spam: interval: 5 max: 3_000 - duplicates: - interval: 10 - max: 3 - discord_emojis: interval: 10 max: 20 + duplicates: + interval: 10 + max: 3 + links: interval: 10 max: 10 @@ -412,15 +409,15 @@ anti_spam: reddit: + client_id: !ENV "REDDIT_CLIENT_ID" + secret: !ENV "REDDIT_SECRET" subreddits: - 'r/Python' - client_id: !ENV "REDDIT_CLIENT_ID" - secret: !ENV "REDDIT_SECRET" big_brother: - log_delay: 15 header_message_limit: 15 + log_delay: 15 code_block: @@ -444,8 +441,8 @@ free: # Seconds to elapse for a channel # to be considered inactive. activity_timeout: 600 - cooldown_rate: 1 cooldown_per: 60.0 + cooldown_rate: 1 help_channels: @@ -490,8 +487,8 @@ help_channels: redirect_output: - delete_invocation: true delete_delay: 15 + delete_invocation: true duck_pond: @@ -511,20 +508,21 @@ duck_pond: python_news: + channel: *PYNEWS_CHANNEL + webhook: *PYNEWS_WEBHOOK + mail_lists: - 'python-ideas' - 'python-announce-list' - 'pypi-announce' - 'python-dev' - channel: *PYNEWS_CHANNEL - webhook: *PYNEWS_WEBHOOK voice_gate: - minimum_days_member: 3 # How many days the user must have been a member for - minimum_messages: 50 # How many messages a user must have to be eligible for voice bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate minimum_activity_blocks: 3 # Number of 10 minute blocks during which a user must have been active + minimum_days_member: 3 # How many days the user must have been a member for + minimum_messages: 50 # How many messages a user must have to be eligible for voice voice_ping_delete_delay: 60 # Seconds before deleting the bot's ping to user in Voice Gate -- cgit v1.2.3 From 46ff6949ac583fb705c52431297e6c7a47ad231b Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 24 Jan 2021 17:39:36 +0100 Subject: Make sure that the paginator doesn't choke on DMs --- bot/pagination.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index 09dbad7b5..3b16cc9ff 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -4,6 +4,7 @@ import typing as t from contextlib import suppress import discord +from discord import Member from discord.abc import User from discord.ext.commands import Context, Paginator @@ -225,7 +226,7 @@ class LinePaginator(Paginator): # The reaction was by a whitelisted user user_.id == restrict_to_user.id # The reaction was by a moderator - or any(role.id in MODERATION_ROLES for role in user_.roles) + or isinstance(user_, Member) and any(role.id in MODERATION_ROLES for role in user_.roles) ) return ( -- cgit v1.2.3 From 05ab7f4e5dcd778ad80230cf16dbd0a71a220770 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 24 Jan 2021 20:00:36 +0300 Subject: Reorganizes constants.py Mirrors the changes from config-default.yml to constants.py. --- bot/constants.py | 207 ++++++++++++++++++++++++++++------------------------- config-default.yml | 2 +- 2 files changed, 111 insertions(+), 98 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index be8d303f6..1905c8dfb 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -13,7 +13,7 @@ their default values from `config-default.yml`. import logging import os from collections.abc import Mapping -from enum import Enum, IntEnum +from enum import Enum from pathlib import Path from typing import Dict, List, Optional @@ -197,8 +197,8 @@ class Bot(metaclass=YAMLGetter): section = "bot" prefix: str - token: str sentry_dsn: Optional[str] + token: str class Redis(metaclass=YAMLGetter): @@ -206,29 +206,30 @@ class Redis(metaclass=YAMLGetter): subsection = "redis" host: str - port: int password: Optional[str] + port: int use_fakeredis: bool # If this is True, Bot will use fakeredis.aioredis class Filter(metaclass=YAMLGetter): section = "filter" - filter_zalgo: bool - filter_invites: bool filter_domains: bool filter_everyone_ping: bool + filter_invites: bool + filter_zalgo: bool watch_regex: bool watch_rich_embeds: bool # Notifications are not expected for "watchlist" type filters - notify_user_zalgo: bool - notify_user_invites: bool + notify_user_domains: bool notify_user_everyone_ping: bool + notify_user_invites: bool + notify_user_zalgo: bool - ping_everyone: bool offensive_msg_delete_days: int + ping_everyone: bool channel_whitelist: List[int] role_whitelist: List[int] @@ -245,10 +246,10 @@ class Colours(metaclass=YAMLGetter): section = "style" subsection = "colours" - soft_red: int + bright_green: int soft_green: int soft_orange: int - bright_green: int + soft_red: int orange: int pink: int purple: int @@ -265,41 +266,42 @@ class Emojis(metaclass=YAMLGetter): section = "style" subsection = "emojis" - defcon_disabled: str # noqa: E704 - defcon_enabled: str # noqa: E704 - defcon_updated: str # noqa: E704 - - status_online: str - status_offline: str - status_idle: str - status_dnd: str - - badge_staff: str - badge_partner: str - badge_hypesquad: str badge_bug_hunter: str + badge_bug_hunter_level_2: str + badge_early_supporter: str + badge_hypesquad: str + badge_hypesquad_balance: str badge_hypesquad_bravery: str badge_hypesquad_brilliance: str - badge_hypesquad_balance: str - badge_early_supporter: str - badge_bug_hunter_level_2: str + badge_partner: str + badge_staff: str badge_verified_bot_developer: str + defcon_disabled: str # noqa: E704 + defcon_enabled: str # noqa: E704 + defcon_updated: str # noqa: E704 + + failmail: str + incident_actioned: str - incident_unactioned: str incident_investigating: str + incident_unactioned: str + + status_dnd: str + status_idle: str + status_offline: str + status_online: str - failmail: str trashcan: str bullet: str + check_mark: str + cross_mark: str new: str pencil: str - cross_mark: str - check_mark: str - upvotes: str comments: str + upvotes: str user: str ok_hand: str @@ -320,6 +322,7 @@ class Icons(metaclass=YAMLGetter): filtering: str + green_checkmark: str guild_update: str hash_blurple: str @@ -330,38 +333,34 @@ class Icons(metaclass=YAMLGetter): message_delete: str message_edit: str + pencil: str + + questionmark: str + + remind_blurple: str + remind_green: str + remind_red: str + sign_in: str sign_out: str + superstarify: str + unsuperstarify: str + token_removed: str user_ban: str - user_unban: str - user_update: str - user_mute: str + user_unban: str user_unmute: str + user_update: str user_verified: str - user_warn: str - pencil: str - - remind_blurple: str - remind_green: str - remind_red: str - - questionmark: str - - superstarify: str - unsuperstarify: str - voice_state_blue: str voice_state_green: str voice_state_red: str - green_checkmark: str - class CleanMessages(metaclass=YAMLGetter): section = "bot" @@ -383,8 +382,8 @@ class Categories(metaclass=YAMLGetter): subsection = "categories" help_available: int - help_in_use: int help_dormant: int + help_in_use: int modmail: int voice: int @@ -393,55 +392,66 @@ class Channels(metaclass=YAMLGetter): section = "guild" subsection = "channels" - admin_announcements: int - admin_spam: int - admins: int - admins_voice: int announcements: int - attachment_log: int - big_brother_logs: int - bot_commands: int change_log: int - code_help_chat_1: int - code_help_chat_2: int - code_help_voice_1: int - code_help_voice_2: int - cooldown: int - defcon: int + mailing_lists: int + python_events: int + python_news: int + reddit: int + user_event_announcements: int + dev_contrib: int dev_core: int dev_log: int + + meta: int + python_discussion: int + + cooldown: int + + attachment_log: int dm_log: int + message_log: int + mod_log: int + user_log: int + voice_log: int + + off_topic_0: int + off_topic_1: int + off_topic_2: int + + bot_commands: int esoteric: int - general_voice: int + voice_gate: int + + admins: int + admin_spam: int + defcon: int helpers: int incidents: int incidents_archive: int - mailing_lists: int - message_log: int - meta: int + mods: int mod_alerts: int - mod_announcements: int - mod_log: int mod_spam: int - mods: int - off_topic_0: int - off_topic_1: int - off_topic_2: int organisation: int - python_discussion: int - python_events: int - python_news: int - reddit: int + + admin_announcements: int + mod_announcements: int staff_announcements: int + + admins_voice: int + code_help_voice_1: int + code_help_voice_2: int + general_voice: int staff_voice: int + + code_help_chat_1: int + code_help_chat_2: int staff_voice_chat: int - talent_pool: int - user_event_announcements: int - user_log: int voice_chat: int - voice_gate: int - voice_log: int + + big_brother_logs: int + talent_pool: int class Webhooks(metaclass=YAMLGetter): @@ -461,41 +471,44 @@ class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" - admins: int announcements: int contributors: int - core_developers: int help_cooldown: int - helpers: int - jammers: int - moderators: int muted: int - owners: int partners: int python_community: int sprinters: int - team_leaders: int voice_verified: int + admins: int + core_developers: int + helpers: int + moderators: int + owners: int + + jammers: int + team_leaders: int + class Guild(metaclass=YAMLGetter): section = "guild" id: int invite: str # Discord invite, gets embedded in chat - moderation_channels: List[int] + moderation_categories: List[int] - moderation_roles: List[int] + moderation_channels: List[int] modlog_blacklist: List[int] reminder_whitelist: List[int] + moderation_roles: List[int] staff_roles: List[int] class Keys(metaclass=YAMLGetter): section = "keys" - site_api: Optional[str] github: Optional[str] + site_api: Optional[str] class URLs(metaclass=YAMLGetter): @@ -525,9 +538,9 @@ class URLs(metaclass=YAMLGetter): class Reddit(metaclass=YAMLGetter): section = "reddit" - subreddits: list client_id: Optional[str] secret: Optional[str] + subreddits: list class AntiSpam(metaclass=YAMLGetter): @@ -543,8 +556,8 @@ class AntiSpam(metaclass=YAMLGetter): class BigBrother(metaclass=YAMLGetter): section = 'big_brother' - log_delay: int header_message_limit: int + log_delay: int class CodeBlock(metaclass=YAMLGetter): @@ -560,8 +573,8 @@ class Free(metaclass=YAMLGetter): section = 'free' activity_timeout: int - cooldown_rate: int cooldown_per: float + cooldown_rate: int class HelpChannels(metaclass=YAMLGetter): @@ -584,25 +597,25 @@ class HelpChannels(metaclass=YAMLGetter): class RedirectOutput(metaclass=YAMLGetter): section = 'redirect_output' - delete_invocation: bool delete_delay: int + delete_invocation: bool class PythonNews(metaclass=YAMLGetter): section = 'python_news' - mail_lists: List[str] channel: int webhook: int + mail_lists: List[str] class VoiceGate(metaclass=YAMLGetter): section = "voice_gate" - minimum_days_member: int - minimum_messages: int bot_message_delete_delay: int minimum_activity_blocks: int + minimum_days_member: int + minimum_messages: int voice_ping_delete_delay: int diff --git a/config-default.yml b/config-default.yml index 26c365d5e..9ad9725e6 100644 --- a/config-default.yml +++ b/config-default.yml @@ -289,9 +289,9 @@ filter: filter_domains: true filter_everyone_ping: true filter_invites: true + filter_zalgo: false watch_regex: true watch_rich_embeds: true - filter_zalgo: false # Notify user on filter? # Notifications are not expected for "watchlist" type filters -- cgit v1.2.3 From ef4a5926537e7304fa68c729247dc632851a274f Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sun, 24 Jan 2021 19:55:37 +0200 Subject: Fixed python_general const in server command --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 619204e5d..4499e4c25 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -68,7 +68,7 @@ class Information(Cog): defcon_days = cog.days.days if cog.enabled else "-" defcon_info = f"Defcon status: {defcon_status}\nDefcon days: {defcon_days}\n" - python_general = self.bot.get_channel(constants.Channels.python_discussion) + python_general = self.bot.get_channel(constants.Channels.python_general) return textwrap.dedent(f""" {talentpool_info}\ -- cgit v1.2.3 From a123417cb9fe2d9be86bab41c6a166e29833f6a3 Mon Sep 17 00:00:00 2001 From: Inheritance Date: Mon, 25 Jan 2021 10:46:04 +0545 Subject: Removed self.interpreter from internal cog, and deleted the interpreter helper file --- bot/exts/utils/internal.py | 1 - bot/interpreter.py | 51 ---------------------------------------------- 2 files changed, 52 deletions(-) delete mode 100644 bot/interpreter.py diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 3521c8fd4..cc0c8a574 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -30,7 +30,6 @@ class Internal(Cog): self.ln = 0 self.stdout = StringIO() - self.interpreter = Interpreter() self.socket_since = datetime.utcnow() self.socket_event_total = 0 diff --git a/bot/interpreter.py b/bot/interpreter.py deleted file mode 100644 index b58f7a6b0..000000000 --- a/bot/interpreter.py +++ /dev/null @@ -1,51 +0,0 @@ -from code import InteractiveInterpreter -from io import StringIO -from typing import Any - -from discord.ext.commands import Context - -import bot - -CODE_TEMPLATE = """ -async def _func(): -{0} -""" - - -class Interpreter(InteractiveInterpreter): - """ - Subclass InteractiveInterpreter to specify custom run functionality. - - Helper class for internal eval. - """ - - write_callable = None - - def __init__(self): - locals_ = {"bot": bot.instance} - super().__init__(locals_) - - 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") - - code_io = StringIO() - - for line in code.split("\n"): - code_io.write(f" {line}\n") - - code = CODE_TEMPLATE.format(code_io.getvalue()) - del code_io - - self.runsource(code, *args, **kwargs) - self.runsource("_rvalue = _func()", *args, **kwargs) - - rvalue = await self.locals["_rvalue"] - - del self.locals["_rvalue"] - del self.locals["ctx"] - del self.locals["print"] - - return rvalue -- cgit v1.2.3 From ce6b86e5aad03388ce0f4244f36438da1fa7c188 Mon Sep 17 00:00:00 2001 From: Inheritance Date: Mon, 25 Jan 2021 10:56:43 +0545 Subject: Removed the import of helper interpreter --- bot/exts/utils/internal.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index cc0c8a574..09e12697a 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -15,7 +15,6 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Roles -from bot.interpreter import Interpreter from bot.utils import find_nth_occurrence, send_to_paste_service log = logging.getLogger(__name__) -- cgit v1.2.3 From 64aa6c059faa554ff0fa39b4b34a3d6715042f2b Mon Sep 17 00:00:00 2001 From: Inheritance Date: Mon, 25 Jan 2021 10:59:22 +0545 Subject: Removed the blank lines from __init__ function --- bot/exts/utils/internal.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 09e12697a..a7ab43f37 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -29,7 +29,6 @@ class Internal(Cog): self.ln = 0 self.stdout = StringIO() - self.socket_since = datetime.utcnow() self.socket_event_total = 0 self.socket_events = Counter() -- cgit v1.2.3 From 3e338ca62e20f73f4563e194d5aab031b0df96fe Mon Sep 17 00:00:00 2001 From: Anand Krishna Date: Mon, 25 Jan 2021 19:32:25 +0400 Subject: Update `is_helper_viewable` check --- bot/exts/fun/duck_pond.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 3eed25781..ee440dec2 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -50,10 +50,10 @@ class DuckPond(Cog): guild = channel.guild helper_role = guild.get_role(constants.Roles.helpers) # check channel overwrites for both the Helper role and @everyone and - # return True for channels that they have explicit permissions to view. + # return True for channels that they have permissions to view. helper_overwrites = channel.overwrites_for(helper_role) default_overwrites = channel.overwrites_for(guild.default_role) - return default_overwrites.view_channel or helper_overwrites.view_channel + return default_overwrites.view_channel is None or helper_overwrites.view_channel is True async def has_green_checkmark(self, message: Message) -> bool: """Check if the message has a green checkmark reaction.""" -- cgit v1.2.3 From 9dea9e6fa57682b94b93f7ff6567d58862ada7ed Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 28 Jan 2021 21:57:54 +0000 Subject: catch the response error and deal with it --- bot/converters.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index d0a9731d6..880b1fe38 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -575,7 +575,10 @@ class Infraction(Converter): return infractions[0] else: - return await ctx.bot.api_client.get(f"bot/infractions/{arg}") + try: + return await ctx.bot.api_client.get(f"bot/infractions/{arg}") + except ResponseCodeError: + raise BadArgument("The provided infraction could not be found.") Expiry = t.Union[Duration, ISODateTime] -- cgit v1.2.3 From 3f0565b04fa08f7d799bc5b05af6f0926800aaa1 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 29 Jan 2021 22:16:51 +0000 Subject: handle within the error handler --- bot/converters.py | 5 +---- bot/exts/backend/error_handler.py | 6 ++++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 880b1fe38..d0a9731d6 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -575,10 +575,7 @@ class Infraction(Converter): return infractions[0] else: - try: - return await ctx.bot.api_client.get(f"bot/infractions/{arg}") - except ResponseCodeError: - raise BadArgument("The provided infraction could not be found.") + return await ctx.bot.api_client.get(f"bot/infractions/{arg}") Expiry = t.Union[Duration, ISODateTime] diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index b8bb3757f..8923a6b3d 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -85,6 +85,12 @@ class ErrorHandler(Cog): else: await self.handle_unexpected_error(ctx, e.original) return # Exit early to avoid logging. + elif isinstance(e, errors.ConversionError): + if isinstance(e.original, ResponseCodeError): + await self.handle_api_error(ctx, e.original) + else: + await self.handle_unexpected_error(ctx, e.original) + return # Exit early to avoid logging. elif not isinstance(e, errors.DisabledCommand): # ConversionError, MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) -- cgit v1.2.3 From acc8bcfd2371035b315538d525a7b9231664fd34 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 29 Jan 2021 22:30:01 +0000 Subject: Remove ConversionError from comment, as its now handled above. --- bot/exts/backend/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 8923a6b3d..ed7962b06 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -92,7 +92,7 @@ class ErrorHandler(Cog): await self.handle_unexpected_error(ctx, e.original) return # Exit early to avoid logging. elif not isinstance(e, errors.DisabledCommand): - # ConversionError, MaxConcurrencyReached, ExtensionError + # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) return # Exit early to avoid logging. -- cgit v1.2.3 From ced0af2b2936b1fab4f5601bd7b1c00bbbd9130a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 30 Jan 2021 15:19:17 +0100 Subject: Make sure that restrictions also applies to moderators Without this, if a moderator add a reaction to any message, all the messages currently listening for reaction will pass the check since the user has a moderation role. --- bot/utils/messages.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 832ad4d55..077dd9569 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -48,8 +48,10 @@ async def wait_for_deletion( return ( reaction.message.id == message.id and str(reaction.emoji) in deletion_emojis - and user.id in user_ids - or allow_moderation_roles and any(role.id in MODERATION_ROLES for role in user.roles) + and ( + user.id in user_ids + or allow_moderation_roles and any(role.id in MODERATION_ROLES for role in user.roles) + ) ) with contextlib.suppress(asyncio.TimeoutError): -- cgit v1.2.3 From 8130b34fe0d9c59b75294844b9f05942a4e6b5ad Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 30 Jan 2021 22:24:36 +0000 Subject: Protect against overflows caused by large expirations --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index d0a9731d6..0d9a519df 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -350,7 +350,7 @@ class Duration(DurationDelta): try: return now + delta - except ValueError: + except (ValueError, OverflowError): raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") -- cgit v1.2.3 From 01ad92b2d3c4a3679f86ac8889736fa873e00ae4 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Sun, 31 Jan 2021 17:35:30 +0100 Subject: Re created file from last point --- bot/resources/tags/empty-json.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 bot/resources/tags/empty-json.md diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md new file mode 100644 index 000000000..a54d3d71a --- /dev/null +++ b/bot/resources/tags/empty-json.md @@ -0,0 +1,27 @@ +When creating a new JSON file you might run into the following error. + +`JSONDecodeError: Expecting value: line 1 column 1 (char 0)` + +In short this error means your JSON is invalid in it's current state. +A JSON may never be completely empty and must always at least have one of the following items. + +``` +object +array +string +number +"true" +"false" +"null" +``` + +To resolve this issue, you create one of the above values in your JSON. It is very common to use `{}` to make an object. Adding the following to your JSON should resolve this issue. + +```json +{ + + +} +``` + +Make sure to put all your data between the `{}`, just like you would when making a dictionary. \ No newline at end of file -- cgit v1.2.3 From 5a6f77fde58f024ea151adfdc6a5745eeb0046cd Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Sun, 31 Jan 2021 17:41:06 +0100 Subject: name fix, added suggestions from previous PR --- bot/resources/tags/empty-json.md | 27 --------------------------- bot/resources/tags/empty_json.md | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 27 deletions(-) delete mode 100644 bot/resources/tags/empty-json.md create mode 100644 bot/resources/tags/empty_json.md diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md deleted file mode 100644 index a54d3d71a..000000000 --- a/bot/resources/tags/empty-json.md +++ /dev/null @@ -1,27 +0,0 @@ -When creating a new JSON file you might run into the following error. - -`JSONDecodeError: Expecting value: line 1 column 1 (char 0)` - -In short this error means your JSON is invalid in it's current state. -A JSON may never be completely empty and must always at least have one of the following items. - -``` -object -array -string -number -"true" -"false" -"null" -``` - -To resolve this issue, you create one of the above values in your JSON. It is very common to use `{}` to make an object. Adding the following to your JSON should resolve this issue. - -```json -{ - - -} -``` - -Make sure to put all your data between the `{}`, just like you would when making a dictionary. \ No newline at end of file diff --git a/bot/resources/tags/empty_json.md b/bot/resources/tags/empty_json.md new file mode 100644 index 000000000..36511abb6 --- /dev/null +++ b/bot/resources/tags/empty_json.md @@ -0,0 +1,24 @@ +When creating a new JSON file you might run into the following error. + +`JSONDecodeError: Expecting value: line 1 column 1 (char 0)` + +In short, this means that your JSON is invalid in its current state. This could very well happen because the file is just new and empty. +A JSON may never be completely empty. It is recommended to have at least one of the following in your json: + +``` +object +array +``` + +To resolve this issue, you create one of the above values in your JSON. It is very common to use `{}` to make an object, which is similar to a dictionary in python. +When this is added to your JSON, it will look like this: + +```json +{ + + +} +``` + +The error is resolved now. +Make sure to put all your data between the `{}`, just like you would when making a dictionary. \ No newline at end of file -- cgit v1.2.3 From 4704344807bf56a544ddeb8cdc592bcb69675cf6 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Sun, 31 Jan 2021 17:48:09 +0100 Subject: Fixed EOF --- bot/resources/tags/empty_json.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/empty_json.md b/bot/resources/tags/empty_json.md index 36511abb6..8fb7c4e23 100644 --- a/bot/resources/tags/empty_json.md +++ b/bot/resources/tags/empty_json.md @@ -21,4 +21,4 @@ When this is added to your JSON, it will look like this: ``` The error is resolved now. -Make sure to put all your data between the `{}`, just like you would when making a dictionary. \ No newline at end of file +Make sure to put all your data between the `{}`, just like you would when making a dictionary. -- cgit v1.2.3 From 9931ebc20ff3dcda11cd2bca338c3a798e6f6b17 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Mon, 1 Feb 2021 18:29:48 +0100 Subject: Removed extra whitespace line in last example --- bot/resources/tags/empty_json.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/empty_json.md b/bot/resources/tags/empty_json.md index 8fb7c4e23..d5e0f843f 100644 --- a/bot/resources/tags/empty_json.md +++ b/bot/resources/tags/empty_json.md @@ -16,7 +16,6 @@ When this is added to your JSON, it will look like this: ```json { - } ``` -- cgit v1.2.3 From dc8a5d4084e124f9a7d6e0d31658b6eb0637bccc Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Mon, 1 Feb 2021 18:41:09 +0100 Subject: Changed some wording --- bot/resources/tags/empty_json.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/resources/tags/empty_json.md b/bot/resources/tags/empty_json.md index d5e0f843f..45df3fd54 100644 --- a/bot/resources/tags/empty_json.md +++ b/bot/resources/tags/empty_json.md @@ -2,16 +2,16 @@ When creating a new JSON file you might run into the following error. `JSONDecodeError: Expecting value: line 1 column 1 (char 0)` -In short, this means that your JSON is invalid in its current state. This could very well happen because the file is just new and empty. -A JSON may never be completely empty. It is recommended to have at least one of the following in your json: +In short, this means that your JSON is invalid in its current state. This could very well happen because the file is just new and completely empty. +Whilst the JSON data may be empty, the .json file must not. It is recommended to have at least one of the following data types in your .json file: ``` object array ``` -To resolve this issue, you create one of the above values in your JSON. It is very common to use `{}` to make an object, which is similar to a dictionary in python. -When this is added to your JSON, it will look like this: +To resolve this issue, you create one of the above data types in your .json file. It is very common to use `{}` to make an object, which works similar to a dictionary in python. +When this is added to your .json file, it will look like this: ```json { -- cgit v1.2.3 From 5040129e8b32ace05d8b391c1753a96769555bab Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Mon, 1 Feb 2021 18:42:46 +0100 Subject: added some more clarification --- bot/resources/tags/empty_json.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/empty_json.md b/bot/resources/tags/empty_json.md index 45df3fd54..eaeafeb18 100644 --- a/bot/resources/tags/empty_json.md +++ b/bot/resources/tags/empty_json.md @@ -3,7 +3,7 @@ When creating a new JSON file you might run into the following error. `JSONDecodeError: Expecting value: line 1 column 1 (char 0)` In short, this means that your JSON is invalid in its current state. This could very well happen because the file is just new and completely empty. -Whilst the JSON data may be empty, the .json file must not. It is recommended to have at least one of the following data types in your .json file: +Whilst the JSON data, the data you wish to store, may be empty, the .json file must not. It is recommended to have at least one of the following data types in your .json file: ``` object -- cgit v1.2.3 From 7b4833ed11f96d1e8bc26ec3997ee42956dca230 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Mon, 1 Feb 2021 18:45:15 +0100 Subject: suggestion for: more friendly/less personal suggestion. --- bot/resources/tags/empty_json.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/empty_json.md b/bot/resources/tags/empty_json.md index eaeafeb18..9e5c5fd4f 100644 --- a/bot/resources/tags/empty_json.md +++ b/bot/resources/tags/empty_json.md @@ -3,14 +3,14 @@ When creating a new JSON file you might run into the following error. `JSONDecodeError: Expecting value: line 1 column 1 (char 0)` In short, this means that your JSON is invalid in its current state. This could very well happen because the file is just new and completely empty. -Whilst the JSON data, the data you wish to store, may be empty, the .json file must not. It is recommended to have at least one of the following data types in your .json file: +Whilst the JSON data, the data you wish to store, may be empty, the .json file must not. You most likely want to use one of the following data types in your .json file: ``` object array ``` -To resolve this issue, you create one of the above data types in your .json file. It is very common to use `{}` to make an object, which works similar to a dictionary in python. +To resolve this issue, create one of the above data types in your .json file. It is very common to use `{}` to make an object, which works similar to a dictionary in python. When this is added to your .json file, it will look like this: ```json -- cgit v1.2.3 From 7432f580300c58321d3a37695779026fe5e51f8c Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 2 Feb 2021 14:16:17 +0000 Subject: Add tag on float imprecision Adds a tag on float imprecision --- bot/resources/tags/floats.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bot/resources/tags/floats.md diff --git a/bot/resources/tags/floats.md b/bot/resources/tags/floats.md new file mode 100644 index 000000000..4b0651930 --- /dev/null +++ b/bot/resources/tags/floats.md @@ -0,0 +1,19 @@ +**Floating Point Arithmetic** +You may have noticed that when doing arithmetic with floats in Python you sometimes get strange results, like this: +```python +>>> 0.1 + 0.2 +0.30000000000000004 +``` +**Why this happens** +Internally your computer stores floats as as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used. + +**How you can avoid this** +If you require an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here is an example using the decimal module: +```python +>>> from decimal import Decimal +>>> Decimal('0.1') + Decimal('0.2') +Decimal('0.3') +``` +Note that we enter in the number we want as a string so we don't pass on the imprecision from the float. + +For more details on why this happens check out this [page in the python docs](https://docs.python.org/3/tutorial/floatingpoint.html) or this [Computerphile video](https://www.youtube.com/watch/PZRI1IfStY0). -- cgit v1.2.3 From 9c1e5fd22e71f919bcbb7e2215430a0b4d518310 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Wed, 3 Feb 2021 10:00:42 +0000 Subject: Mention math.isclose and add an example --- bot/resources/tags/floats.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/resources/tags/floats.md b/bot/resources/tags/floats.md index 4b0651930..7129b91bb 100644 --- a/bot/resources/tags/floats.md +++ b/bot/resources/tags/floats.md @@ -8,12 +8,13 @@ You may have noticed that when doing arithmetic with floats in Python you someti Internally your computer stores floats as as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used. **How you can avoid this** -If you require an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here is an example using the decimal module: + You can use [math.isclose](https://docs.python.org/3/library/math.html#math.isclose) to check if two floats are close, or to get an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here are some examples: ```python ->>> from decimal import Decimal ->>> Decimal('0.1') + Decimal('0.2') +>>> math.isclose(0.1 + 0.2, 0.3) +True +>>> decimal.Decimal('0.1') + decimal.Decimal('0.2') Decimal('0.3') ``` -Note that we enter in the number we want as a string so we don't pass on the imprecision from the float. +Note that with `decimal.Decimal` we enter the number we want as a string so we don't pass on the imprecision from the float. For more details on why this happens check out this [page in the python docs](https://docs.python.org/3/tutorial/floatingpoint.html) or this [Computerphile video](https://www.youtube.com/watch/PZRI1IfStY0). -- cgit v1.2.3 From 47c4321b1fbd49896a6935c58e1bec9dbaf6918f Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 4 Feb 2021 16:02:52 -0800 Subject: Added how_to_get_help constant. --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 95e22513f..6b86d33a3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -406,6 +406,7 @@ class Channels(metaclass=YAMLGetter): meta: int python_general: int + how_to_get_help: int cooldown: int diff --git a/config-default.yml b/config-default.yml index d3b267159..913d5ca09 100644 --- a/config-default.yml +++ b/config-default.yml @@ -158,6 +158,7 @@ guild: python_general: &PY_GENERAL 267624335836053506 # Python Help: Available + how_to_get_help: 704250143020417084 cooldown: 720603994149486673 # Topical -- cgit v1.2.3 From 0f46bf58bea83da9434b53ddfda3ce8331829588 Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 4 Feb 2021 16:07:37 -0800 Subject: Added dynamic available help channels message --- bot/exts/help_channels/_cog.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 0995c8a79..c4ec820b5 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,6 +11,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.constants import Channels, Categories from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling @@ -20,6 +21,9 @@ NAMESPACE = "help" HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ +AVAILABLE_HELP_CHANNELS = """ +Currently available help channel(s): {available} +""" class HelpChannels(commands.Cog): @@ -72,6 +76,11 @@ class HelpChannels(commands.Cog): self.last_notification: t.Optional[datetime] = None + # Acquiring and modifying the channel to dynamically update the available help channels message. + self.how_to_get_help: discord.TextChannel = None + self.available_help_channels: t.Set[int] = set() + self.dynamic_message: discord.Message = None + # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] self.init_task = self.bot.loop.create_task(self.init_cog()) @@ -114,6 +123,9 @@ class HelpChannels(commands.Cog): await _caches.unanswered.set(message.channel.id, True) + self.available_help_channels.remove(message.channel.id) + await self.update_available_help_channels() + # Not awaited because it may indefinitely hold the lock while waiting for a channel. scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") @@ -275,6 +287,15 @@ class HelpChannels(commands.Cog): # This may confuse users. So would potentially long delays for the cog to become ready. self.close_command.enabled = True + # Getting channels that need to be included in the dynamic message. + task = asyncio.create_task(self.update_available_help_channels()) + self.queue_tasks.append(task) + + await task + + log.trace(f"Dynamic available help message updated.") + self.queue_tasks.remove(task) + await self.init_available() _stats.report_counts() @@ -332,6 +353,10 @@ class HelpChannels(commands.Cog): category_id=constants.Categories.help_available, ) + # Adding the help channel to the dynamic message, and editing/sending that message. + self.available_help_channels.add(channel.id) + await self.update_available_help_channels() + _stats.report_counts() async def move_to_dormant(self, channel: discord.TextChannel) -> None: @@ -461,3 +486,26 @@ class HelpChannels(commands.Cog): self.queue_tasks.remove(task) return channel + + async def update_available_help_channels(self) -> None: + """Updates the dynamic message within #how-to-get-help for available help channels.""" + if not self.available_help_channels: + available_channels_category = await channel_utils.try_get_channel(Categories.help_available) + self.available_help_channels = set( + c.id for c in available_channels_category.channels if 'help-' in c.name + ) + + if self.how_to_get_help is None: + self.how_to_get_help = await channel_utils.try_get_channel(Channels.how_to_get_help) + + if self.dynamic_message is None: + self.dynamic_message = await self.how_to_get_help.history(limit=1).next() + + available_channels = AVAILABLE_HELP_CHANNELS.format( + available=', '.join(f"<#{c}>" for c in self.available_help_channels) + ) + + try: + await self.dynamic_message.edit(content=available_channels) + except discord.Forbidden: + self.dynamic_message = await self.how_to_get_help.send(available_channels) -- cgit v1.2.3 From 5f17d4d19d0952c91ead096a52b206eea86851fe Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 4 Feb 2021 16:18:20 -0800 Subject: Fixed up linting errors. --- bot/exts/help_channels/_cog.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index c4ec820b5..943b63a42 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,7 +11,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.constants import Channels, Categories +from bot.constants import Categories, Channels from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling @@ -293,7 +293,7 @@ class HelpChannels(commands.Cog): await task - log.trace(f"Dynamic available help message updated.") + log.trace("Dynamic available help message updated.") self.queue_tasks.remove(task) await self.init_available() @@ -499,7 +499,8 @@ class HelpChannels(commands.Cog): self.how_to_get_help = await channel_utils.try_get_channel(Channels.how_to_get_help) if self.dynamic_message is None: - self.dynamic_message = await self.how_to_get_help.history(limit=1).next() + last_message = await self.how_to_get_help.history(limit=1) + self.dynamic_message = next(last_message) available_channels = AVAILABLE_HELP_CHANNELS.format( available=', '.join(f"<#{c}>" for c in self.available_help_channels) -- cgit v1.2.3 From 219ac90d494793a99d77ef5a4e912151e936b1d8 Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 4 Feb 2021 17:12:25 -0800 Subject: Fixed logic in case dynamic message doesn't exist. --- bot/exts/help_channels/_cog.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 943b63a42..730635f08 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -495,18 +495,16 @@ class HelpChannels(commands.Cog): c.id for c in available_channels_category.channels if 'help-' in c.name ) - if self.how_to_get_help is None: - self.how_to_get_help = await channel_utils.try_get_channel(Channels.how_to_get_help) - - if self.dynamic_message is None: - last_message = await self.how_to_get_help.history(limit=1) - self.dynamic_message = next(last_message) - available_channels = AVAILABLE_HELP_CHANNELS.format( available=', '.join(f"<#{c}>" for c in self.available_help_channels) ) - try: - await self.dynamic_message.edit(content=available_channels) - except discord.Forbidden: - self.dynamic_message = await self.how_to_get_help.send(available_channels) + if self.how_to_get_help is None: + self.how_to_get_help = await channel_utils.try_get_channel(Channels.how_to_get_help) + + if self.dynamic_message is None: + try: + self.dynamic_message = await self.how_to_get_help.fetch_message(self.how_to_get_help.last_message_id) + await self.dynamic_message.edit(content=available_channels) + except discord.NotFound: + self.dynamic_message = await self.how_to_get_help.send(available_channels) -- cgit v1.2.3 From 12d8670de79011dc1095293ddc7b256f033fcead Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 4 Feb 2021 18:22:21 -0800 Subject: 'None' is now shown if no channels are available. --- bot/exts/help_channels/_cog.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 730635f08..2f14146ab 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -123,12 +123,13 @@ class HelpChannels(commands.Cog): await _caches.unanswered.set(message.channel.id, True) - self.available_help_channels.remove(message.channel.id) - await self.update_available_help_channels() - # Not awaited because it may indefinitely hold the lock while waiting for a channel. scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") + # Removing the help channel from the dynamic message, and editing/sending that message. + self.available_help_channels.remove(message.channel.id) + await self.update_available_help_channels() + def create_channel_queue(self) -> asyncio.Queue: """ Return a queue of dormant channels to use for getting the next available channel. @@ -496,15 +497,15 @@ class HelpChannels(commands.Cog): ) available_channels = AVAILABLE_HELP_CHANNELS.format( - available=', '.join(f"<#{c}>" for c in self.available_help_channels) + available=', '.join(f"<#{c}>" for c in self.available_help_channels) or None ) if self.how_to_get_help is None: self.how_to_get_help = await channel_utils.try_get_channel(Channels.how_to_get_help) - if self.dynamic_message is None: - try: + try: + if self.dynamic_message is None: self.dynamic_message = await self.how_to_get_help.fetch_message(self.how_to_get_help.last_message_id) - await self.dynamic_message.edit(content=available_channels) - except discord.NotFound: - self.dynamic_message = await self.how_to_get_help.send(available_channels) + await self.dynamic_message.edit(content=available_channels) + except discord.NotFound: + self.dynamic_message = await self.how_to_get_help.send(available_channels) -- cgit v1.2.3 From d2a10cd1b758625ea165e599c0271d9e43f0ab8a Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 4 Feb 2021 18:35:11 -0800 Subject: Alphabetized config-default.yml. --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 913d5ca09..fc1f3b3a8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -158,8 +158,8 @@ guild: python_general: &PY_GENERAL 267624335836053506 # Python Help: Available - how_to_get_help: 704250143020417084 cooldown: 720603994149486673 + how_to_get_help: 704250143020417084 # Topical discord_py: 343944376055103488 -- cgit v1.2.3 From 3a4d38bc27dc8214af91ee8ab598a5b60897815f Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 4 Feb 2021 18:53:54 -0800 Subject: Removed unnecessary update method call. --- bot/exts/help_channels/_cog.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 2f14146ab..d50197339 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -123,12 +123,11 @@ class HelpChannels(commands.Cog): await _caches.unanswered.set(message.channel.id, True) - # Not awaited because it may indefinitely hold the lock while waiting for a channel. - scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") - # Removing the help channel from the dynamic message, and editing/sending that message. self.available_help_channels.remove(message.channel.id) - await self.update_available_help_channels() + + # Not awaited because it may indefinitely hold the lock while waiting for a channel. + scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") def create_channel_queue(self) -> asyncio.Queue: """ -- cgit v1.2.3 From 88b88ba5c42f7db2d253493fa9b3749287d31ffb Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 4 Feb 2021 19:14:02 -0800 Subject: Replaced fetching available category for old one. --- bot/exts/help_channels/_cog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index d50197339..4520b408d 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,7 +11,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.constants import Categories, Channels +from bot.constants import Channels from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling @@ -490,9 +490,8 @@ class HelpChannels(commands.Cog): async def update_available_help_channels(self) -> None: """Updates the dynamic message within #how-to-get-help for available help channels.""" if not self.available_help_channels: - available_channels_category = await channel_utils.try_get_channel(Categories.help_available) self.available_help_channels = set( - c.id for c in available_channels_category.channels if 'help-' in c.name + c.id for c in self.available_category.channels if 'help-' in c.name ) available_channels = AVAILABLE_HELP_CHANNELS.format( -- cgit v1.2.3 From 2940ba31bb5118f7c9668d14c3d9ffa7611b3890 Mon Sep 17 00:00:00 2001 From: xithrius Date: Fri, 5 Feb 2021 02:55:50 -0800 Subject: Modified the dynamic to be bold to catch eyes. --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 4520b408d..e9333b9a6 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -22,7 +22,7 @@ HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ AVAILABLE_HELP_CHANNELS = """ -Currently available help channel(s): {available} +**Currently available help channel(s):** {available} """ -- cgit v1.2.3 From 555c1a8a4543f051b950c72a4b89805db988ca76 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 5 Feb 2021 12:29:27 +0100 Subject: Add jumplink to deleted messages --- bot/exts/moderation/modlog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index e4b119f41..2dae9d268 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -546,6 +546,7 @@ class ModLog(Cog, name="ModLog"): f"**Author:** {format_user(author)}\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" + f"[Jump to message]({message.jump_url})\n" "\n" ) else: @@ -553,6 +554,7 @@ class ModLog(Cog, name="ModLog"): f"**Author:** {format_user(author)}\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" + f"[Jump to message]({message.jump_url})\n" "\n" ) -- cgit v1.2.3 From 2fdb0e5fce6d246cfc67962e235b4d53622f03d7 Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Fri, 5 Feb 2021 19:13:40 +0400 Subject: Make `KeyError` tag --- bot/resources/tags/keyerror.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 bot/resources/tags/keyerror.md diff --git a/bot/resources/tags/keyerror.md b/bot/resources/tags/keyerror.md new file mode 100644 index 000000000..d0c069004 --- /dev/null +++ b/bot/resources/tags/keyerror.md @@ -0,0 +1,17 @@ +Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary. \ +While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them. +## __The `dict.get` method__ +The [dict.get](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, or None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. +```py +>>> my_dict = {"foo": 1, "bar": 2} +>>> print(my_dict.get("foo")) +1 +>>> print(my_dict.get("foobar")) +None +>>> print(my_dict.get("foobar", 3)) # here 3 is the default value to be returned, in case the key doesn't exist +3 +>>> print(my_dict) +{'foo': 1, 'bar': 2} # note that the new key was NOT added to the dictionary +``` +\ +Some other methods that can be used for handling KeyErrors gracefully are the [dict.setdefault](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method, or by using [collections.defaultdict](https://docs.python.org/3/library/collections.html#collections.defaultdict). -- cgit v1.2.3 From 2c4d7f41432bb620d83d3403fe4ab9317bc1129f Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Fri, 5 Feb 2021 19:39:36 +0400 Subject: Update and rename keyerror.md to dict-get.md --- bot/resources/tags/dict-get.md | 13 +++++++++++++ bot/resources/tags/keyerror.md | 17 ----------------- 2 files changed, 13 insertions(+), 17 deletions(-) create mode 100644 bot/resources/tags/dict-get.md delete mode 100644 bot/resources/tags/keyerror.md diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md new file mode 100644 index 000000000..b22db7af5 --- /dev/null +++ b/bot/resources/tags/dict-get.md @@ -0,0 +1,13 @@ +Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary.\ +While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them. +__**The `dict.get` method**__ +The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, or None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. +```py +>>> my_dict = {"foo": 1, "bar": 2} +>>> print(my_dict.get("foobar")) +None +>>> print(my_dict.get("foobar", 3)) # here 3 is the default value to be returned, in case the key doesn't exist +3 +``` + +Some other methods that can be used for handling KeyErrors gracefully are the [`dict.setdefault`](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method, or by using [`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict). diff --git a/bot/resources/tags/keyerror.md b/bot/resources/tags/keyerror.md deleted file mode 100644 index d0c069004..000000000 --- a/bot/resources/tags/keyerror.md +++ /dev/null @@ -1,17 +0,0 @@ -Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary. \ -While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them. -## __The `dict.get` method__ -The [dict.get](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, or None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. -```py ->>> my_dict = {"foo": 1, "bar": 2} ->>> print(my_dict.get("foo")) -1 ->>> print(my_dict.get("foobar")) -None ->>> print(my_dict.get("foobar", 3)) # here 3 is the default value to be returned, in case the key doesn't exist -3 ->>> print(my_dict) -{'foo': 1, 'bar': 2} # note that the new key was NOT added to the dictionary -``` -\ -Some other methods that can be used for handling KeyErrors gracefully are the [dict.setdefault](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method, or by using [collections.defaultdict](https://docs.python.org/3/library/collections.html#collections.defaultdict). -- cgit v1.2.3 From 721472352f05c561625932a88bcf3c10c98c130d Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Fri, 5 Feb 2021 19:43:29 +0400 Subject: Fix faulty heading --- bot/resources/tags/dict-get.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md index b22db7af5..d9cc6a691 100644 --- a/bot/resources/tags/dict-get.md +++ b/bot/resources/tags/dict-get.md @@ -1,6 +1,8 @@ Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary.\ -While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them. +While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them.\ + __**The `dict.get` method**__ + The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, or None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. ```py >>> my_dict = {"foo": 1, "bar": 2} -- cgit v1.2.3 From 3a08d74e6ecc5a038ccfc97e973dac161171c6c2 Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Fri, 5 Feb 2021 19:50:45 +0400 Subject: Make `defaultdict` tag --- bot/resources/tags/defaultdict.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 bot/resources/tags/defaultdict.md diff --git a/bot/resources/tags/defaultdict.md b/bot/resources/tags/defaultdict.md new file mode 100644 index 000000000..a15ebff2a --- /dev/null +++ b/bot/resources/tags/defaultdict.md @@ -0,0 +1,20 @@ +**[`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict)** + +The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically create the key and generate a default value for it. +While instantiating a `defaultdict`, we pass in a function that tells it how to create a default value for missing keys. + +```py +>>> from collections import defaultdict +>>> my_dict = defaultdict(int, {"foo": 1, "bar": 2}) +>>> print(my_dict) +defaultdict(, {'foo': 1, 'bar': 2}) +``` + +In this example, we've used the `int` function - this means that if we try to access a non-existent key, it provides the default value of 0. + +```py +>>> print(my_dict["foobar"]) +0 +>>> print(my_dict) +defaultdict(, {'foo': 1, 'bar': 2, 'foobar': 0}) +``` -- cgit v1.2.3 From a6d65fd04e932a8cc860f5e8ab07f05a4a2f51d1 Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Fri, 5 Feb 2021 19:53:49 +0400 Subject: Refer to `defaultdict` tag in `dict-get` --- bot/resources/tags/dict-get.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md index d9cc6a691..6f8299dc7 100644 --- a/bot/resources/tags/dict-get.md +++ b/bot/resources/tags/dict-get.md @@ -12,4 +12,4 @@ None 3 ``` -Some other methods that can be used for handling KeyErrors gracefully are the [`dict.setdefault`](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method, or by using [`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict). +Some other methods that can be used for handling KeyErrors gracefully are the [`dict.setdefault`](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method, or by using [`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) (check out the `!defaultdict` tag). -- cgit v1.2.3 From c346c2becd2967058a73bc900800afbfb8dbe6d5 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 5 Feb 2021 19:12:14 +0100 Subject: Fix #1371 error via adding errors. --- bot/errors.py | 17 ++++++++++++++++- bot/exts/backend/error_handler.py | 4 +++- bot/exts/moderation/infraction/_utils.py | 5 +++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 65d715203..016d9bd17 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,4 +1,5 @@ -from typing import Hashable +from typing import Hashable, Union +from discord import Member, User class LockedResourceError(RuntimeError): @@ -18,3 +19,17 @@ class LockedResourceError(RuntimeError): f"Cannot operate on {self.type.lower()} `{self.id}`; " "it is currently locked and in use by another operation." ) + + +class InvalidInfractedUser(Exception): + """ + Exception raised upon attempt of infracting an invalid user." + + Attributes: + `user` -- User or Member which is invalid + """ + + def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."): + self.user = user + + super().__init__(reason) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index ed7962b06..d2cce5558 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -12,7 +12,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, ERROR_REPLIES, Icons, MODERATION_ROLES from bot.converters import TagNameConverter -from bot.errors import LockedResourceError +from bot.errors import InvalidInfractedUser, LockedResourceError from bot.exts.backend.branding._errors import BrandingError from bot.utils.checks import InWhitelistCheckFailure @@ -82,6 +82,8 @@ class ErrorHandler(Cog): elif isinstance(e.original, BrandingError): await ctx.send(embed=self._get_error_embed(random.choice(ERROR_REPLIES), str(e.original))) return + elif isinstance(e.original, InvalidInfractedUser): + await ctx.send(f"Cannot infract that user. {e.original.reason}") else: await self.handle_unexpected_error(ctx, e.original) return # Exit early to avoid logging. diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index d0dc3f0a1..e766c1e5c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -7,6 +7,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons +from bot.errors import InvalidInfractedUser log = logging.getLogger(__name__) @@ -79,6 +80,10 @@ async def post_infraction( active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" + if isinstance(user, (discord.Member, discord.User)) and user.bot: + log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") + raise InvalidInfractedUser(user) + log.trace(f"Posting {infr_type} infraction for {user} to the API.") payload = { -- cgit v1.2.3 From 4f1f45652ab980502098b98e5534d64f810b4b53 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 5 Feb 2021 19:19:02 +0100 Subject: Linting fix. --- bot/errors.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/errors.py b/bot/errors.py index 016d9bd17..a6fc33312 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,4 +1,5 @@ from typing import Hashable, Union + from discord import Member, User @@ -23,7 +24,7 @@ class LockedResourceError(RuntimeError): class InvalidInfractedUser(Exception): """ - Exception raised upon attempt of infracting an invalid user." + Exception raised upon attempt of infracting an invalid user. Attributes: `user` -- User or Member which is invalid -- cgit v1.2.3 From 78ded411d8e57d399a00d9132d4caa94ba59f410 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Fri, 5 Feb 2021 19:31:38 +0100 Subject: replaced underscore with dash --- bot/resources/tags/empty-json.md | 23 +++++++++++++++++++++++ bot/resources/tags/empty_json.md | 23 ----------------------- 2 files changed, 23 insertions(+), 23 deletions(-) create mode 100644 bot/resources/tags/empty-json.md delete mode 100644 bot/resources/tags/empty_json.md diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md new file mode 100644 index 000000000..9e5c5fd4f --- /dev/null +++ b/bot/resources/tags/empty-json.md @@ -0,0 +1,23 @@ +When creating a new JSON file you might run into the following error. + +`JSONDecodeError: Expecting value: line 1 column 1 (char 0)` + +In short, this means that your JSON is invalid in its current state. This could very well happen because the file is just new and completely empty. +Whilst the JSON data, the data you wish to store, may be empty, the .json file must not. You most likely want to use one of the following data types in your .json file: + +``` +object +array +``` + +To resolve this issue, create one of the above data types in your .json file. It is very common to use `{}` to make an object, which works similar to a dictionary in python. +When this is added to your .json file, it will look like this: + +```json +{ + +} +``` + +The error is resolved now. +Make sure to put all your data between the `{}`, just like you would when making a dictionary. diff --git a/bot/resources/tags/empty_json.md b/bot/resources/tags/empty_json.md deleted file mode 100644 index 9e5c5fd4f..000000000 --- a/bot/resources/tags/empty_json.md +++ /dev/null @@ -1,23 +0,0 @@ -When creating a new JSON file you might run into the following error. - -`JSONDecodeError: Expecting value: line 1 column 1 (char 0)` - -In short, this means that your JSON is invalid in its current state. This could very well happen because the file is just new and completely empty. -Whilst the JSON data, the data you wish to store, may be empty, the .json file must not. You most likely want to use one of the following data types in your .json file: - -``` -object -array -``` - -To resolve this issue, create one of the above data types in your .json file. It is very common to use `{}` to make an object, which works similar to a dictionary in python. -When this is added to your .json file, it will look like this: - -```json -{ - -} -``` - -The error is resolved now. -Make sure to put all your data between the `{}`, just like you would when making a dictionary. -- cgit v1.2.3 From f0afae3c7792d3c6b9899a915a05adb95de3b45d Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Fri, 5 Feb 2021 19:49:06 +0100 Subject: Rewrite to make it more compact and to the point --- bot/resources/tags/empty-json.md | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index 9e5c5fd4f..0246d346f 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -1,23 +1,15 @@ -When creating a new JSON file you might run into the following error. -`JSONDecodeError: Expecting value: line 1 column 1 (char 0)` - -In short, this means that your JSON is invalid in its current state. This could very well happen because the file is just new and completely empty. -Whilst the JSON data, the data you wish to store, may be empty, the .json file must not. You most likely want to use one of the following data types in your .json file: +When using JSON you might run into the following error: +``` +JSONDecodeError: Expecting value: line 1 column 1 (char 0) +``` +This error could have appeared because you just created the JSON file and there is nothing in it at the moment. +Whilst having the data empty is no problem, the file itself may never be completely empty. You most likely want one of the following in your json ``` object array ``` +This issue can be resolved by creating one of these data types. An object is the most common of the 2, and is created by editing your file to read `{}`. -To resolve this issue, create one of the above data types in your .json file. It is very common to use `{}` to make an object, which works similar to a dictionary in python. -When this is added to your .json file, it will look like this: - -```json -{ - -} -``` - -The error is resolved now. -Make sure to put all your data between the `{}`, just like you would when making a dictionary. +Different data types are also supported. If you wish to read more on these, please reffer to the following article: https://www.tutorialspoint.com/json/json_data_types.htm -- cgit v1.2.3 From 68d53cbe1955b486903a7c962f8d6602766375ce Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Fri, 5 Feb 2021 19:53:16 +0100 Subject: Removed an excess line --- bot/resources/tags/empty-json.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index 0246d346f..a5de2380f 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -10,6 +10,6 @@ Whilst having the data empty is no problem, the file itself may never be complet object array ``` -This issue can be resolved by creating one of these data types. An object is the most common of the 2, and is created by editing your file to read `{}`. +An object is the most common of the 2, and is created by editing your file to read `{}`. Different data types are also supported. If you wish to read more on these, please reffer to the following article: https://www.tutorialspoint.com/json/json_data_types.htm -- cgit v1.2.3 From 04b99031d9895afda5a00080c894b07958919ae9 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Fri, 5 Feb 2021 19:56:37 +0100 Subject: Fixed random newline at start --- bot/resources/tags/empty-json.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index a5de2380f..98bfe5fa7 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -1,4 +1,3 @@ - When using JSON you might run into the following error: ``` JSONDecodeError: Expecting value: line 1 column 1 (char 0) -- cgit v1.2.3 From 970b49aec1cfee6cdffe56b3d675224fecde382f Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Fri, 5 Feb 2021 20:47:31 +0100 Subject: Simplified language --- bot/resources/tags/empty-json.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index 98bfe5fa7..3851dc142 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -4,11 +4,8 @@ JSONDecodeError: Expecting value: line 1 column 1 (char 0) ``` This error could have appeared because you just created the JSON file and there is nothing in it at the moment. -Whilst having the data empty is no problem, the file itself may never be completely empty. You most likely want one of the following in your json -``` -object -array -``` -An object is the most common of the 2, and is created by editing your file to read `{}`. +Whilst having the data empty is no problem, the file itself may never be completely empty. + +You most likely wanted to structure your JSON as a dictionary. For this change your JSON file to read `{}`. Different data types are also supported. If you wish to read more on these, please reffer to the following article: https://www.tutorialspoint.com/json/json_data_types.htm -- cgit v1.2.3 From 6d9e17634ac10ce911e68d544a52aaa928298929 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 6 Feb 2021 01:25:44 -0800 Subject: Reformatted string constant for available help channels. --- bot/exts/help_channels/_cog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e9333b9a6..fbfc585a4 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -21,9 +21,7 @@ NAMESPACE = "help" HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ -AVAILABLE_HELP_CHANNELS = """ -**Currently available help channel(s):** {available} -""" +AVAILABLE_HELP_CHANNELS = """**Currently available help channel(s):** {available}""" class HelpChannels(commands.Cog): -- cgit v1.2.3 From bbce0a8cb17d0771a4823e67675cf4dc26f72b2a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 11:37:34 +0200 Subject: Create local-file tag about sending local files to Discord --- bot/resources/tags/local-file.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 bot/resources/tags/local-file.md diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md new file mode 100644 index 000000000..309ca4820 --- /dev/null +++ b/bot/resources/tags/local-file.md @@ -0,0 +1,24 @@ +Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of `discord.File` class: +```py +# When you know the file exact path, you can pass it. +file = discord.File("/this/is/path/to/my/file.png", filename="file.png") + +# When you have the file-like object, then you can pass this instead path. +with open("/this/is/path/to/my/file.png", "rb") as f: + file = discord.File(f) +``` +When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing filename to it is not necessary. +Please note that `filename` can't contain underscores. This is Discord limitation. + +`discord.Embed` instance has method `set_image` what can be used to set attachment as image: +```py +embed = discord.Embed() +# Set other fields +embed.set_image("attachment://file.png") # Filename here must be exactly same as attachment filename. +``` +After this, you can send embed and attachment to Discord: +```py +await channel.send(file=file, embed=embed) +``` +This example uses `discord.Channel` for sending, but any `discord.Messageable` can be used for sending. + -- cgit v1.2.3 From dc72923a4c74207fc405764a8e8afc1b4b239b37 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 6 Feb 2021 01:52:42 -0800 Subject: Available channels are no longer stored as IDs. --- bot/exts/help_channels/_cog.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index fbfc585a4..dae9b5730 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -76,7 +76,7 @@ class HelpChannels(commands.Cog): # Acquiring and modifying the channel to dynamically update the available help channels message. self.how_to_get_help: discord.TextChannel = None - self.available_help_channels: t.Set[int] = set() + self.available_help_channels: t.Set[discord.TextChannel] = set() self.dynamic_message: discord.Message = None # Asyncio stuff @@ -122,7 +122,7 @@ class HelpChannels(commands.Cog): await _caches.unanswered.set(message.channel.id, True) # Removing the help channel from the dynamic message, and editing/sending that message. - self.available_help_channels.remove(message.channel.id) + self.available_help_channels.remove(message.channel) # Not awaited because it may indefinitely hold the lock while waiting for a channel. scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") @@ -352,7 +352,7 @@ class HelpChannels(commands.Cog): ) # Adding the help channel to the dynamic message, and editing/sending that message. - self.available_help_channels.add(channel.id) + self.available_help_channels.add(channel) await self.update_available_help_channels() _stats.report_counts() @@ -489,11 +489,11 @@ class HelpChannels(commands.Cog): """Updates the dynamic message within #how-to-get-help for available help channels.""" if not self.available_help_channels: self.available_help_channels = set( - c.id for c in self.available_category.channels if 'help-' in c.name + c for c in self.available_category.channels if not _channel.is_excluded_channel(c) ) available_channels = AVAILABLE_HELP_CHANNELS.format( - available=', '.join(f"<#{c}>" for c in self.available_help_channels) or None + available=', '.join(c.mention for c in self.available_help_channels) or None ) if self.how_to_get_help is None: -- cgit v1.2.3 From 2b437bfc4858d6ed08eee43defd9a97584140706 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 6 Feb 2021 02:09:20 -0800 Subject: Removed unnecessary task creation. --- bot/exts/help_channels/_cog.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index dae9b5730..2dbe930d3 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -286,13 +286,8 @@ class HelpChannels(commands.Cog): self.close_command.enabled = True # Getting channels that need to be included in the dynamic message. - task = asyncio.create_task(self.update_available_help_channels()) - self.queue_tasks.append(task) - - await task - + await self.update_available_help_channels() log.trace("Dynamic available help message updated.") - self.queue_tasks.remove(task) await self.init_available() _stats.report_counts() -- cgit v1.2.3 From 88fba5fd3489988320431b8a96879941988b5f13 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 6 Feb 2021 02:21:20 -0800 Subject: Formatted available constant, added missing dynamic message trace --- bot/exts/help_channels/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 2dbe930d3..554c27c95 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -21,7 +21,7 @@ NAMESPACE = "help" HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ -AVAILABLE_HELP_CHANNELS = """**Currently available help channel(s):** {available}""" +AVAILABLE_HELP_CHANNELS = "**Currently available help channel(s):** {available}" class HelpChannels(commands.Cog): @@ -500,3 +500,4 @@ class HelpChannels(commands.Cog): await self.dynamic_message.edit(content=available_channels) except discord.NotFound: self.dynamic_message = await self.how_to_get_help.send(available_channels) + log.trace("A dynamic message was sent for later modification because one couldn't be found.") -- cgit v1.2.3 From d0c87c7f12ca20ec9be54bf0d299ca23a5e559db Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 12:54:45 +0200 Subject: discord.Channel -> discord.TextChannel --- bot/resources/tags/local-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index 309ca4820..a587139ee 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -20,5 +20,5 @@ After this, you can send embed and attachment to Discord: ```py await channel.send(file=file, embed=embed) ``` -This example uses `discord.Channel` for sending, but any `discord.Messageable` can be used for sending. +This example uses `discord.TextChannel` for sending, but any `discord.Messageable` can be used for sending. -- cgit v1.2.3 From 44be3e8a7411a715b502802863dfc1fb2d6658c3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 12:56:52 +0200 Subject: discord.Messageable -> discord.abc.Messageable --- bot/resources/tags/local-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index a587139ee..d78258fa2 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -20,5 +20,5 @@ After this, you can send embed and attachment to Discord: ```py await channel.send(file=file, embed=embed) ``` -This example uses `discord.TextChannel` for sending, but any `discord.Messageable` can be used for sending. +This example uses `discord.TextChannel` for sending, but any `discord.abc.Messageable` can be used for sending. -- cgit v1.2.3 From 6db50230970188b4b1a24ec0b4ff84b4896cc78a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 13:03:35 +0200 Subject: Remove additional newline from end of tag --- bot/resources/tags/local-file.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index d78258fa2..9e4e0e551 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -21,4 +21,3 @@ After this, you can send embed and attachment to Discord: await channel.send(file=file, embed=embed) ``` This example uses `discord.TextChannel` for sending, but any `discord.abc.Messageable` can be used for sending. - -- cgit v1.2.3 From 1a9d820638acce176f73867b6b321c8c1dbfb479 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 13:38:39 +0200 Subject: Ignore attachment-only messages for duplicates antispam rule --- bot/rules/duplicates.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py index 455764b53..23aefd3dc 100644 --- a/bot/rules/duplicates.py +++ b/bot/rules/duplicates.py @@ -13,6 +13,7 @@ async def apply( if ( msg.author == last_message.author and msg.content == last_message.content + and (msg.content and not msg.attachments) ) ) -- cgit v1.2.3 From 84a46c9ab27f0a593c413f5ee09ba19cf5fb1d1b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 13:45:07 +0200 Subject: Lower max attachments per 10 seconds to 3 --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index d3b267159..d323a946d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -367,7 +367,7 @@ anti_spam: rules: attachments: interval: 10 - max: 9 + max: 3 burst: interval: 10 -- cgit v1.2.3 From e1fa3182254727c564afc86d87fc7043b2444c3c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 13:56:50 +0200 Subject: Mention instance in comment about Messageable --- bot/resources/tags/local-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index 9e4e0e551..c28e14a05 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -20,4 +20,4 @@ After this, you can send embed and attachment to Discord: ```py await channel.send(file=file, embed=embed) ``` -This example uses `discord.TextChannel` for sending, but any `discord.abc.Messageable` can be used for sending. +This example uses `discord.TextChannel` for sending, but any instance of `discord.abc.Messageable` can be used for sending. -- cgit v1.2.3 From 90a9bac84cdac0288c256157f1b5769b0cd2b973 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 14:03:41 +0200 Subject: Add hyperlinks for local file tag discord.py references --- bot/resources/tags/local-file.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index c28e14a05..344f35667 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -1,4 +1,4 @@ -Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of `discord.File` class: +Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of [`discord.File`](https://discordpy.readthedocs.io/en/latest/api.html#discord.File) class: ```py # When you know the file exact path, you can pass it. file = discord.File("/this/is/path/to/my/file.png", filename="file.png") @@ -10,14 +10,14 @@ with open("/this/is/path/to/my/file.png", "rb") as f: When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing filename to it is not necessary. Please note that `filename` can't contain underscores. This is Discord limitation. -`discord.Embed` instance has method `set_image` what can be used to set attachment as image: +[`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instance has method [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) what can be used to set attachment as image: ```py embed = discord.Embed() # Set other fields -embed.set_image("attachment://file.png") # Filename here must be exactly same as attachment filename. +embed.set_image(url="attachment://file.png") # Filename here must be exactly same as attachment filename. ``` After this, you can send embed and attachment to Discord: ```py await channel.send(file=file, embed=embed) ``` -This example uses `discord.TextChannel` for sending, but any instance of `discord.abc.Messageable` can be used for sending. +This example uses [`discord.TextChannel`](https://discordpy.readthedocs.io/en/latest/api.html#discord.TextChannel) for sending, but any instance of [`discord.abc.Messageable`](https://discordpy.readthedocs.io/en/latest/api.html#discord.abc.Messageable) can be used for sending. -- cgit v1.2.3 From fbdfaeafb5c3381d545657a395efec07daaea092 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Sat, 6 Feb 2021 10:30:54 -0500 Subject: Rewrite to use simpler examples. The previous examples might have been confusing for some readers. I also removed the part about inverting a dict because I think that's out of scope and would require more explanation given all the consequences that could have. --- bot/resources/tags/dictcomps.md | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md index 11867d77b..c9f9e62f7 100644 --- a/bot/resources/tags/dictcomps.md +++ b/bot/resources/tags/dictcomps.md @@ -1,20 +1,15 @@ -**Dictionary Comprehensions** - -Like lists, there is a convenient way of creating dictionaries: +Dictionary comprehensions (*dict comps*) provide a convenient way to make dictionaries, just like list comps: ```py ->>> ftoc = {f: round((5/9)*(f-32)) for f in range(-40,101,20)} ->>> print(ftoc) -{-40: -40, -20: -29, 0: -18, 20: -7, 40: 4, 60: 16, 80: 27, 100: 38} +>>> {word.lower(): len(word) for word in ('I', 'love', 'Python')} +{'i': 1, 'love': 4, 'python': 6} ``` -In the example above, I created a dictionary of temperatures in Fahrenheit, that are mapped to (*roughly*) their Celsius counterpart within a small range. These comprehensions are useful for succinctly creating dictionaries from some other sequence. +The syntax is very similar to list comps except that you surround it with curly braces and have two expressions: one for the key and one for the value. -They are also very useful for inverting the key value pairs of a dictionary that already exists, such that the value in the old dictionary is now the key, and the corresponding key is now its value: +One can use a dict comp to change an existing dictionary using its `items` method ```py ->>> ctof = {v:k for k, v in ftoc.items()} ->>> print(ctof) -{-40: -40, -29: -20, -18: 0, -7: 20, 4: 40, 16: 60, 27: 80, 38: 100} +>>> first_dict = {'i': 1, 'love': 4, 'python': 6} +>>> {key.upper(): value * 2 for key, value in first_dict.items()} +{'I': 2, 'LOVE': 8, 'PYTHON': 12} ``` -Also like list comprehensions, you can add a conditional to it in order to filter out items you don't want. - -For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/) +For more information and examples, check out [PEP 274](https://www.python.org/dev/peps/pep-0274/) -- cgit v1.2.3 From 9655acb6e4b19eec9aadb5cc1b7ed76ef55aff82 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Sat, 6 Feb 2021 10:33:36 -0500 Subject: More robust example with no reference to Python versions or `str.format`. The example emphasizes that you can evaluate expressions in the curly braces. Python 3.5 has already reached EOL, so anyone who doesn't have f-strings at this point is probably running 2.7 anyway. I also removed the information about `str.format` to reduce the scope. --- bot/resources/tags/f-strings.md | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md index 69bc82487..4f12640aa 100644 --- a/bot/resources/tags/f-strings.md +++ b/bot/resources/tags/f-strings.md @@ -1,17 +1,9 @@ -In Python, there are several ways to do string interpolation, including using `%s`\'s and by using the `+` operator to concatenate strings together. However, because some of these methods offer poor readability and require typecasting to prevent errors, you should for the most part be using a feature called format strings. +Creating a Python string with your variables using the `+` operator can be difficult to write and read. F-strings (*format-strings*) make it easy to insert values into a string. If you put an `f` in front of the first quote, you can then put Python expressions between curly braces in the string. -**In Python 3.6 or later, we can use f-strings like this:** ```py -snake = "Pythons" -print(f"{snake} are some of the largest snakes in the world") -``` -**In earlier versions of Python or in projects where backwards compatibility is very important, use str.format() like this:** -```py -snake = "Pythons" - -# With str.format() you can either use indexes -print("{0} are some of the largest snakes in the world".format(snake)) - -# Or keyword arguments -print("{family} are some of the largest snakes in the world".format(family=snake)) +>>> snake = "pythons" +>>> number = 21 +>>> f"There are {number * 2} {snake} on the plane." +"There are 42 pythons on the plane." ``` +Note that even when you include an expression that isn't a string, like `number * 2`, Python will handle converting it to a string. -- cgit v1.2.3 From d333a777aff579ac9d4f38467345fb946dd46bc3 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Sat, 6 Feb 2021 10:35:27 -0500 Subject: New example to emphasize the mapping functionality rather than filtering. Previously, the example only conveyed how the `if` statement of list comps could be used to filter a list, whereas the mapping functionality is what people primarily use list comps for. --- bot/resources/tags/listcomps.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md index 0003b9bb8..ba00a4bf7 100644 --- a/bot/resources/tags/listcomps.md +++ b/bot/resources/tags/listcomps.md @@ -1,14 +1,19 @@ -Do you ever find yourself writing something like: +Do you ever find yourself writing something like this? ```py -even_numbers = [] -for n in range(20): - if n % 2 == 0: - even_numbers.append(n) +>>> squares = [] +>>> for n in range(5): +... squares.append(n ** 2) +[0, 1, 4, 9, 16] ``` -Using list comprehensions can simplify this significantly, and greatly improve code readability. If we rewrite the example above to use list comprehensions, it would look like this: +Using list comprehensions can make this both shorter and more readable. As a list comprehension, the same code would look like this: ```py -even_numbers = [n for n in range(20) if n % 2 == 0] +>>> [n ** 2 for n in range(5)] +[0, 1, 4, 9, 16] +``` +List comprehensions also get an `if` statement: +```python +>>> [n ** 2 for n in range(5) if n % 2 == 0] +[0, 4, 16] ``` -This also works for generators, dicts and sets by using `()` or `{}` instead of `[]`. -For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/). +For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python). -- cgit v1.2.3 From 8f51f239f2ded1d7176a94039d2332ef74532a95 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 18:07:07 +0200 Subject: Fix grammar of local-file tag --- bot/resources/tags/local-file.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index 344f35667..52539c64e 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -10,13 +10,13 @@ with open("/this/is/path/to/my/file.png", "rb") as f: When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing filename to it is not necessary. Please note that `filename` can't contain underscores. This is Discord limitation. -[`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instance has method [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) what can be used to set attachment as image: +[`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: ```py embed = discord.Embed() # Set other fields embed.set_image(url="attachment://file.png") # Filename here must be exactly same as attachment filename. ``` -After this, you can send embed and attachment to Discord: +After this, you send an embed with an attachment to Discord: ```py await channel.send(file=file, embed=embed) ``` -- cgit v1.2.3 From 496129080733096ab7eddd03128750b9fd3a53a2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 18:17:00 +0200 Subject: Add back removed 'can' to local-file tag --- bot/resources/tags/local-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index 52539c64e..a4aeee736 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -16,7 +16,7 @@ embed = discord.Embed() # Set other fields embed.set_image(url="attachment://file.png") # Filename here must be exactly same as attachment filename. ``` -After this, you send an embed with an attachment to Discord: +After this, you can send an embed with an attachment to Discord: ```py await channel.send(file=file, embed=embed) ``` -- cgit v1.2.3 From 65ea1657de016a3ba1e58412950ae4bf735bf0fe Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 18:19:40 +0200 Subject: Add missing 'a' article in local-file tag --- bot/resources/tags/local-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index a4aeee736..29fce3ff5 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -8,7 +8,7 @@ with open("/this/is/path/to/my/file.png", "rb") as f: file = discord.File(f) ``` When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing filename to it is not necessary. -Please note that `filename` can't contain underscores. This is Discord limitation. +Please note that `filename` can't contain underscores. This is a Discord limitation.. [`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: ```py -- cgit v1.2.3 From 691f2393f0fed5d17ec641d5006ea2e486015614 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 18:21:55 +0200 Subject: Remove unnecessary period from local-file tag --- bot/resources/tags/local-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index 29fce3ff5..fdce5605c 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -8,7 +8,7 @@ with open("/this/is/path/to/my/file.png", "rb") as f: file = discord.File(f) ``` When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing filename to it is not necessary. -Please note that `filename` can't contain underscores. This is a Discord limitation.. +Please note that `filename` can't contain underscores. This is a Discord limitation. [`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: ```py -- cgit v1.2.3 From 174f70e216e327e30a9df6902619944f47eea5ad Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 6 Feb 2021 18:10:17 +0100 Subject: add reason attribute --- bot/errors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/errors.py b/bot/errors.py index a6fc33312..ab0adcd42 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -32,5 +32,6 @@ class InvalidInfractedUser(Exception): def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."): self.user = user + self.reason = reason super().__init__(reason) -- cgit v1.2.3 From 255f2215279d08386152b12dc8adec037538cba7 Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Sat, 6 Feb 2021 21:16:23 +0400 Subject: Reword comment in example --- bot/resources/tags/dict-get.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md index 6f8299dc7..7657f420a 100644 --- a/bot/resources/tags/dict-get.md +++ b/bot/resources/tags/dict-get.md @@ -1,5 +1,5 @@ Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary.\ -While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them.\ +While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them. __**The `dict.get` method**__ @@ -8,7 +8,7 @@ The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) metho >>> my_dict = {"foo": 1, "bar": 2} >>> print(my_dict.get("foobar")) None ->>> print(my_dict.get("foobar", 3)) # here 3 is the default value to be returned, in case the key doesn't exist +>>> print(my_dict.get("foobar", 3)) # here 3 is the default value to be returned, because the key doesn't exist 3 ``` -- cgit v1.2.3 From df22551dbf7a4dae4e374eb1dd95d9354b73474c Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Sat, 6 Feb 2021 21:41:13 +0400 Subject: Fix trailing whitespaces --- bot/resources/tags/defaultdict.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/defaultdict.md b/bot/resources/tags/defaultdict.md index a15ebff2a..9361d6f2a 100644 --- a/bot/resources/tags/defaultdict.md +++ b/bot/resources/tags/defaultdict.md @@ -1,6 +1,6 @@ **[`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict)** -The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically create the key and generate a default value for it. +The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically create the key and generate a default value for it. While instantiating a `defaultdict`, we pass in a function that tells it how to create a default value for missing keys. ```py -- cgit v1.2.3 From 5dc8f0e7e0cf150a9a89787b518fdbb7f8f2ba5c Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Sat, 6 Feb 2021 23:28:04 +0400 Subject: Correct examples, reword description --- bot/resources/tags/defaultdict.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/bot/resources/tags/defaultdict.md b/bot/resources/tags/defaultdict.md index 9361d6f2a..b6c3175fc 100644 --- a/bot/resources/tags/defaultdict.md +++ b/bot/resources/tags/defaultdict.md @@ -1,20 +1,21 @@ **[`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict)** -The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically create the key and generate a default value for it. +The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically insert the key and generate a default value for it. While instantiating a `defaultdict`, we pass in a function that tells it how to create a default value for missing keys. ```py >>> from collections import defaultdict ->>> my_dict = defaultdict(int, {"foo": 1, "bar": 2}) ->>> print(my_dict) -defaultdict(, {'foo': 1, 'bar': 2}) +>>> my_dict = defaultdict(int) +>>> my_dict +defaultdict(, {}) ``` -In this example, we've used the `int` function - this means that if we try to access a non-existent key, it provides the default value of 0. +In this example, we've used the `int` class which returns 0 when called like a function, so any missing key will get a default value of 0. You can also get an empty list by default with `list` or an empty string with `str`. ```py ->>> print(my_dict["foobar"]) +>>> my_dict["foo"] 0 ->>> print(my_dict) -defaultdict(, {'foo': 1, 'bar': 2, 'foobar': 0}) +>>> my_dict["bar"] += 5 +>>> my_dict +defaultdict(, {'foo': 0, 'bar': 5}) ``` -- cgit v1.2.3 From fc451e1b3c375a73b000cea21f822b7f95d900d7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 6 Feb 2021 22:04:10 +0200 Subject: Put filename between backticks --- bot/resources/tags/local-file.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index fdce5605c..ae41d589c 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -7,7 +7,7 @@ file = discord.File("/this/is/path/to/my/file.png", filename="file.png") with open("/this/is/path/to/my/file.png", "rb") as f: file = discord.File(f) ``` -When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing filename to it is not necessary. +When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing `filename` to it is not necessary. Please note that `filename` can't contain underscores. This is a Discord limitation. [`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: -- cgit v1.2.3 From 90eeeb046b392c1b770c44b766dd2ce78816b8bb Mon Sep 17 00:00:00 2001 From: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> Date: Sat, 6 Feb 2021 16:51:35 -0500 Subject: Removed extra blank line. It added more vertical white space than was wanted. Co-authored-by: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> --- bot/resources/tags/dictcomps.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md index c9f9e62f7..6c8018761 100644 --- a/bot/resources/tags/dictcomps.md +++ b/bot/resources/tags/dictcomps.md @@ -11,5 +11,4 @@ One can use a dict comp to change an existing dictionary using its `items` metho >>> {key.upper(): value * 2 for key, value in first_dict.items()} {'I': 2, 'LOVE': 8, 'PYTHON': 12} ``` - For more information and examples, check out [PEP 274](https://www.python.org/dev/peps/pep-0274/) -- cgit v1.2.3 From 9a9eb8fc6f62ac8527f08cba6f72537c13522291 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> Date: Sat, 6 Feb 2021 16:52:36 -0500 Subject: "handle converting" -> "convert ... for you". Per Gustav's suggestion. Co-authored-by: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> --- bot/resources/tags/f-strings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md index 4f12640aa..5ccafe723 100644 --- a/bot/resources/tags/f-strings.md +++ b/bot/resources/tags/f-strings.md @@ -6,4 +6,4 @@ Creating a Python string with your variables using the `+` operator can be diffi >>> f"There are {number * 2} {snake} on the plane." "There are 42 pythons on the plane." ``` -Note that even when you include an expression that isn't a string, like `number * 2`, Python will handle converting it to a string. +Note that even when you include an expression that isn't a string, like `number * 2`, Python will convert it to a string for you. -- cgit v1.2.3 From 1cb760e158259264bc9cf575a609bd2b6e64d1f3 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 7 Feb 2021 15:14:54 +0300 Subject: Revert "Dynamic available help channels message" --- bot/constants.py | 1 - bot/exts/help_channels/_cog.py | 40 ---------------------------------------- config-default.yml | 1 - 3 files changed, 42 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6b86d33a3..95e22513f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -406,7 +406,6 @@ class Channels(metaclass=YAMLGetter): meta: int python_general: int - how_to_get_help: int cooldown: int diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 554c27c95..0995c8a79 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,7 +11,6 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.constants import Channels from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling @@ -21,7 +20,6 @@ NAMESPACE = "help" HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ -AVAILABLE_HELP_CHANNELS = "**Currently available help channel(s):** {available}" class HelpChannels(commands.Cog): @@ -74,11 +72,6 @@ class HelpChannels(commands.Cog): self.last_notification: t.Optional[datetime] = None - # Acquiring and modifying the channel to dynamically update the available help channels message. - self.how_to_get_help: discord.TextChannel = None - self.available_help_channels: t.Set[discord.TextChannel] = set() - self.dynamic_message: discord.Message = None - # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] self.init_task = self.bot.loop.create_task(self.init_cog()) @@ -121,9 +114,6 @@ class HelpChannels(commands.Cog): await _caches.unanswered.set(message.channel.id, True) - # Removing the help channel from the dynamic message, and editing/sending that message. - self.available_help_channels.remove(message.channel) - # Not awaited because it may indefinitely hold the lock while waiting for a channel. scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") @@ -285,10 +275,6 @@ class HelpChannels(commands.Cog): # This may confuse users. So would potentially long delays for the cog to become ready. self.close_command.enabled = True - # Getting channels that need to be included in the dynamic message. - await self.update_available_help_channels() - log.trace("Dynamic available help message updated.") - await self.init_available() _stats.report_counts() @@ -346,10 +332,6 @@ class HelpChannels(commands.Cog): category_id=constants.Categories.help_available, ) - # Adding the help channel to the dynamic message, and editing/sending that message. - self.available_help_channels.add(channel) - await self.update_available_help_channels() - _stats.report_counts() async def move_to_dormant(self, channel: discord.TextChannel) -> None: @@ -479,25 +461,3 @@ class HelpChannels(commands.Cog): self.queue_tasks.remove(task) return channel - - async def update_available_help_channels(self) -> None: - """Updates the dynamic message within #how-to-get-help for available help channels.""" - if not self.available_help_channels: - self.available_help_channels = set( - c for c in self.available_category.channels if not _channel.is_excluded_channel(c) - ) - - available_channels = AVAILABLE_HELP_CHANNELS.format( - available=', '.join(c.mention for c in self.available_help_channels) or None - ) - - if self.how_to_get_help is None: - self.how_to_get_help = await channel_utils.try_get_channel(Channels.how_to_get_help) - - try: - if self.dynamic_message is None: - self.dynamic_message = await self.how_to_get_help.fetch_message(self.how_to_get_help.last_message_id) - await self.dynamic_message.edit(content=available_channels) - except discord.NotFound: - self.dynamic_message = await self.how_to_get_help.send(available_channels) - log.trace("A dynamic message was sent for later modification because one couldn't be found.") diff --git a/config-default.yml b/config-default.yml index fc1f3b3a8..d3b267159 100644 --- a/config-default.yml +++ b/config-default.yml @@ -159,7 +159,6 @@ guild: # Python Help: Available cooldown: 720603994149486673 - how_to_get_help: 704250143020417084 # Topical discord_py: 343944376055103488 -- cgit v1.2.3 From 16f8fd31b3cd321e4ac7d6eeb0ba20eeb8c78892 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers Date: Tue, 9 Feb 2021 10:53:51 +0100 Subject: Tiny grammar edit --- bot/resources/tags/empty-json.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index 3851dc142..ceb8c6eae 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -1,4 +1,4 @@ -When using JSON you might run into the following error: +When using JSON, you might run into the following error: ``` JSONDecodeError: Expecting value: line 1 column 1 (char 0) ``` @@ -6,6 +6,6 @@ This error could have appeared because you just created the JSON file and there Whilst having the data empty is no problem, the file itself may never be completely empty. -You most likely wanted to structure your JSON as a dictionary. For this change your JSON file to read `{}`. +You most likely wanted to structure your JSON as a dictionary. To do this, change your JSON to read `{}`. Different data types are also supported. If you wish to read more on these, please reffer to the following article: https://www.tutorialspoint.com/json/json_data_types.htm -- cgit v1.2.3 From 8d1a46c1866c12b719b991719c84a6c1d6f25bb4 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers <61157793+sebkuip@users.noreply.github.com> Date: Tue, 9 Feb 2021 11:08:06 +0100 Subject: A small typo Co-authored-by: Kieran Siek --- bot/resources/tags/empty-json.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index ceb8c6eae..21b0860c7 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -8,4 +8,4 @@ Whilst having the data empty is no problem, the file itself may never be complet You most likely wanted to structure your JSON as a dictionary. To do this, change your JSON to read `{}`. -Different data types are also supported. If you wish to read more on these, please reffer to the following article: https://www.tutorialspoint.com/json/json_data_types.htm +Different data types are also supported. If you wish to read more on these, please refer to the following article: https://www.tutorialspoint.com/json/json_data_types.htm -- cgit v1.2.3 From 2627bc98da2c71a6a10a6b7039522d1938c08552 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers <61157793+sebkuip@users.noreply.github.com> Date: Tue, 9 Feb 2021 11:29:09 +0100 Subject: Hyperlink URL Suggestion of @Numelor Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/resources/tags/empty-json.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index 21b0860c7..93e2cadba 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -8,4 +8,4 @@ Whilst having the data empty is no problem, the file itself may never be complet You most likely wanted to structure your JSON as a dictionary. To do this, change your JSON to read `{}`. -Different data types are also supported. If you wish to read more on these, please refer to the following article: https://www.tutorialspoint.com/json/json_data_types.htm +Different data types are also supported. If you wish to read more on these, please refer to [this article](https://www.tutorialspoint.com/json/json_data_types.htm). -- cgit v1.2.3 From 160bf89303436e3ba0ff566241a206a120a25d66 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 9 Feb 2021 13:37:28 +0300 Subject: Moves Off Topic Name Translator Breaks out the off topic name translation functionality into its own function. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/converters.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 0d9a519df..80ce99459 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -357,27 +357,38 @@ class Duration(DurationDelta): class OffTopicName(Converter): """A converter that ensures an added off-topic name is valid.""" + ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" + + @classmethod + def translate_name(cls, name: str, *, from_unicode: bool = True) -> str: + """ + Translates `name` into a format that is allowed in discord channel names. + + If `from_unicode` is True, the name is translated from a discord-safe format, back to normalized text. + """ + if from_unicode: + table = str.maketrans(cls.ALLOWED_CHARACTERS, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-') + else: + table = str.maketrans('𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-', cls.ALLOWED_CHARACTERS) + + return name.translate(table) + async def convert(self, ctx: Context, argument: str) -> str: """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" - allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" - # Chain multiple words to a single one argument = "-".join(argument.split()) if not (2 <= len(argument) <= 96): raise BadArgument("Channel name must be between 2 and 96 chars long") - elif not all(c.isalnum() or c in allowed_characters for c in argument): + elif not all(c.isalnum() or c in self.ALLOWED_CHARACTERS for c in argument): raise BadArgument( "Channel name must only consist of " "alphanumeric characters, minus signs or apostrophes." ) # Replace invalid characters with unicode alternatives. - table = str.maketrans( - allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-' - ) - return argument.translate(table) + return self.translate_name(argument) class ISODateTime(Converter): -- cgit v1.2.3 From 66cda4fd2a0b26e2f9e983f1597a15bfb9527143 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 9 Feb 2021 13:38:12 +0300 Subject: Makes Off Topic Name Search Case Insensitive Modifies the off topic channel name search to match upper and lower cased letters, as well as punctuation. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/fun/off_topic_names.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index 7fc93b88c..845b8175c 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -139,10 +139,20 @@ class OffTopicNames(Cog): @has_any_role(*MODERATION_ROLES) async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: """Search for an off-topic name.""" - result = await self.bot.api_client.get('bot/off-topic-channel-names') - 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)) + query = OffTopicName.translate_name(query, from_unicode=False).lower() + + # Map normalized names to returned names for search purposes + result = { + OffTopicName.translate_name(name, from_unicode=False).lower(): name + for name in await self.bot.api_client.get('bot/off-topic-channel-names') + } + + # Search normalized keys + in_matches = {name for name in result.keys() if query in name} + close_matches = difflib.get_close_matches(query, result.keys(), n=10, cutoff=0.70) + + # Send Results + lines = sorted(f"• {result[name]}" for name in in_matches.union(close_matches)) embed = Embed( title="Query results", colour=Colour.blue() -- cgit v1.2.3 From 9d8162a688023a3b5e830057b09c2ab2e132582f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 10 Feb 2021 01:07:43 +0000 Subject: Migrate API utilities to use internal DNS routing --- bot/api.py | 2 +- bot/constants.py | 1 + config-default.yml | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/api.py b/bot/api.py index d93f9f2ba..6ce9481f4 100644 --- a/bot/api.py +++ b/bot/api.py @@ -53,7 +53,7 @@ class APIClient: @staticmethod def _url_for(endpoint: str) -> str: - return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" + return f"{URLs.site_api_schema}{URLs.site_api}/{quote_url(endpoint)}" async def close(self) -> None: """Close the aiohttp session.""" diff --git a/bot/constants.py b/bot/constants.py index 95e22513f..91e41e334 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -530,6 +530,7 @@ class URLs(metaclass=YAMLGetter): site: str site_api: str site_schema: str + site_api_schema: str # Site endpoints site_logs_view: str diff --git a/config-default.yml b/config-default.yml index d3b267159..c585151c9 100644 --- a/config-default.yml +++ b/config-default.yml @@ -335,9 +335,10 @@ keys: urls: # PyDis site vars site: &DOMAIN "pythondiscord.com" - site_api: &API !JOIN ["api.", *DOMAIN] + site_api: &API "pydis-api.default.svc.cluster.local" site_paste: &PASTE !JOIN ["paste.", *DOMAIN] site_schema: &SCHEMA "https://" + site_api_schema: "http://" site_staff: &STAFF !JOIN ["staff.", *DOMAIN] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] -- cgit v1.2.3 From 578a0e48514fd9f902cde45db557fa1f3425c289 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 10 Feb 2021 01:07:54 +0000 Subject: Migrate ping command to ping internal API --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 572fc934b..e62811b91 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -37,7 +37,7 @@ class Latency(commands.Cog): bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: - delay = await aioping.ping(URLs.site, family=socket.AddressFamily.AF_INET) * 1000 + delay = await aioping.ping(URLs.site_api, family=socket.AddressFamily.AF_INET) * 1000 site_ping = f"{delay:.{ROUND_LATENCY}f} ms" except TimeoutError: -- cgit v1.2.3 From 9111f9f247fb1292add29929c660e9633e4f31da Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 10 Feb 2021 01:57:00 +0000 Subject: Alphabetical sorting in config-default.yml --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index c585151c9..d7415c821 100644 --- a/config-default.yml +++ b/config-default.yml @@ -336,9 +336,9 @@ urls: # PyDis site vars site: &DOMAIN "pythondiscord.com" site_api: &API "pydis-api.default.svc.cluster.local" + site_api_schema: "http://" site_paste: &PASTE !JOIN ["paste.", *DOMAIN] site_schema: &SCHEMA "https://" - site_api_schema: "http://" site_staff: &STAFF !JOIN ["staff.", *DOMAIN] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] -- cgit v1.2.3 From bafa6a9dbf61ae30ef235537408f0b073a88dd19 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 10 Feb 2021 02:13:42 +0000 Subject: ICMP is disabled in production, so we can't ping the API --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index e62811b91..572fc934b 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -37,7 +37,7 @@ class Latency(commands.Cog): bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: - delay = await aioping.ping(URLs.site_api, family=socket.AddressFamily.AF_INET) * 1000 + delay = await aioping.ping(URLs.site, family=socket.AddressFamily.AF_INET) * 1000 site_ping = f"{delay:.{ROUND_LATENCY}f} ms" except TimeoutError: -- cgit v1.2.3 From ea35aa9c77a81f46ea14acf36862c42f3ffe9016 Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Thu, 11 Feb 2021 09:37:33 +0400 Subject: Split example codeblock in two --- bot/resources/tags/dict-get.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md index 7657f420a..867f0b7d9 100644 --- a/bot/resources/tags/dict-get.md +++ b/bot/resources/tags/dict-get.md @@ -1,15 +1,17 @@ Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary.\ While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them. -__**The `dict.get` method**__ +**The `dict.get` method** -The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, or None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. +The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, and None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. ```py >>> my_dict = {"foo": 1, "bar": 2} >>> print(my_dict.get("foobar")) None ->>> print(my_dict.get("foobar", 3)) # here 3 is the default value to be returned, because the key doesn't exist +``` +Below, 3 is the default value to be returned, because the key doesn't exist- +```py +>>> print(my_dict.get("foobar", 3)) 3 ``` - -Some other methods that can be used for handling KeyErrors gracefully are the [`dict.setdefault`](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method, or by using [`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) (check out the `!defaultdict` tag). +Some other methods for handling `KeyError`s gracefully are the [`dict.setdefault`](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method and [`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) (check out the `!defaultdict` tag). -- cgit v1.2.3 From a3749786c3ff90397427032ef219b590ee4e2837 Mon Sep 17 00:00:00 2001 From: Anand Krishna <40204976+anand2312@users.noreply.github.com> Date: Thu, 11 Feb 2021 10:20:06 +0400 Subject: Remove reference to `try - except` --- bot/resources/tags/dict-get.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md index 867f0b7d9..e02df03ab 100644 --- a/bot/resources/tags/dict-get.md +++ b/bot/resources/tags/dict-get.md @@ -1,5 +1,4 @@ -Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary.\ -While you can use a `try` and `except` block to catch the `KeyError`, Python also gives you some other neat ways to handle them. +Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary. Python gives you some neat ways to handle them. **The `dict.get` method** -- cgit v1.2.3 From 84c0aa4268f91027cd71016e01a00ffe59151cc2 Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 11 Feb 2021 02:40:42 -0800 Subject: Added base of the pypi command. --- bot/exts/info/pypi.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 bot/exts/info/pypi.py diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py new file mode 100644 index 000000000..9567516c2 --- /dev/null +++ b/bot/exts/info/pypi.py @@ -0,0 +1,35 @@ +from discord import Embed +from discord.ext.commands import Cog, Context, command + +from bot.bot import Bot +from bot.constants import NEGATIVE_REPLIES + +URL = "https://pypi.org/pypi/{package}/json" + + +class PyPi(Cog): + """Cog for getting information about PyPi packages.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @command(name="pypi", aliases=("package", "pack")) + async def get_package_info(self, ctx: Context, package: str) -> None: + """Getting information about a specific package.""" + embed = Embed(title="PyPi package information") + + async with self.bot.http_session.get(URL.format(package_name=package)) as response: + if response.status == 404: + return await ctx.send(f"Package with name '{package}' could not be found.") + elif response.status == 200 and response.content_type == "application/json": + response_json = await response.json() + info = response_json["info"] + else: + return await ctx.send("There was an error when fetching your PyPi package.") + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Load the PyPi cog.""" + bot.add_cog(PyPi(bot)) -- cgit v1.2.3 From 1610f330fbc583df2c161629b7d8d72b77b9253d Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 11 Feb 2021 03:49:59 -0800 Subject: Added more fields and responses. --- bot/exts/info/pypi.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 9567516c2..e4c90090d 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -1,10 +1,16 @@ +import logging +from random import choice + from discord import Embed from discord.ext.commands import Cog, Context, command from bot.bot import Bot -from bot.constants import NEGATIVE_REPLIES +from bot.constants import NEGATIVE_REPLIES, Colours URL = "https://pypi.org/pypi/{package}/json" +FIELDS = ["author", "requires_python", "description", "license"] + +log = logging.getLogger(__name__) class PyPi(Cog): @@ -16,16 +22,30 @@ class PyPi(Cog): @command(name="pypi", aliases=("package", "pack")) async def get_package_info(self, ctx: Context, package: str) -> None: """Getting information about a specific package.""" - embed = Embed(title="PyPi package information") + embed = Embed(title=choice(NEGATIVE_REPLIES), colour=Colours.soft_red) - async with self.bot.http_session.get(URL.format(package_name=package)) as response: + async with self.bot.http_session.get(URL.format(package=package)) as response: if response.status == 404: - return await ctx.send(f"Package with name '{package}' could not be found.") + embed.description = f"Package could not be found." + elif response.status == 200 and response.content_type == "application/json": response_json = await response.json() info = response_json["info"] + + embed.title = "Python Package Index" + embed.colour = Colours.soft_green + embed.description = f"[{info['name']} v{info['version']}]({info['download_url']})\n" + + for field in FIELDS: + embed.add_field( + name=field.replace("_", " ").title(), + value=info[field], + inline=False, + ) + else: - return await ctx.send("There was an error when fetching your PyPi package.") + embed.description = "There was an error when fetching your PyPi package." + log.trace(f"Error when fetching PyPi package: {response.status}.") await ctx.send(embed=embed) -- cgit v1.2.3 From ded34bc8ab063064fbd50199d07bbeec1db884ad Mon Sep 17 00:00:00 2001 From: xithrius Date: Thu, 11 Feb 2021 03:54:40 -0800 Subject: Made flake8 very happy. --- bot/exts/info/pypi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index e4c90090d..544b52b49 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -5,7 +5,7 @@ from discord import Embed from discord.ext.commands import Cog, Context, command from bot.bot import Bot -from bot.constants import NEGATIVE_REPLIES, Colours +from bot.constants import Colours, NEGATIVE_REPLIES URL = "https://pypi.org/pypi/{package}/json" FIELDS = ["author", "requires_python", "description", "license"] @@ -26,7 +26,7 @@ class PyPi(Cog): async with self.bot.http_session.get(URL.format(package=package)) as response: if response.status == 404: - embed.description = f"Package could not be found." + embed.description = "Package could not be found." elif response.status == 200 and response.content_type == "application/json": response_json = await response.json() -- cgit v1.2.3 From ed7fde738db677ced25388a53ed9bd539f4490fb Mon Sep 17 00:00:00 2001 From: xithrius Date: Fri, 12 Feb 2021 00:14:48 -0800 Subject: Empty fields have been accounted for by getting usually non-empty ones. --- bot/exts/info/pypi.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 544b52b49..7a5d7f4b7 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -8,7 +8,7 @@ from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES URL = "https://pypi.org/pypi/{package}/json" -FIELDS = ["author", "requires_python", "description", "license"] +FIELDS = ["author", "requires_python", "summary", "license"] log = logging.getLogger(__name__) @@ -34,14 +34,15 @@ class PyPi(Cog): embed.title = "Python Package Index" embed.colour = Colours.soft_green - embed.description = f"[{info['name']} v{info['version']}]({info['download_url']})\n" + embed.description = f"[{info['name']} v{info['version']}]({info['package_url']})\n" for field in FIELDS: - embed.add_field( - name=field.replace("_", " ").title(), - value=info[field], - inline=False, - ) + if field_value := info[field]: + embed.add_field( + name=field.replace("_", " ").title(), + value=field_value, + inline=False, + ) else: embed.description = "There was an error when fetching your PyPi package." -- cgit v1.2.3 From 2a9f349429694d48cca86af972ef327a57af552d Mon Sep 17 00:00:00 2001 From: xithrius Date: Fri, 12 Feb 2021 00:20:51 -0800 Subject: Accounting for completely empty fields that only contain whitespaces. --- bot/exts/info/pypi.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 7a5d7f4b7..990a5c905 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -37,7 +37,8 @@ class PyPi(Cog): embed.description = f"[{info['name']} v{info['version']}]({info['package_url']})\n" for field in FIELDS: - if field_value := info[field]: + # Field could be completely empty, in some cases can be a string with whitespaces. + if field_value := info[field].strip(): embed.add_field( name=field.replace("_", " ").title(), value=field_value, -- cgit v1.2.3 From 889de9b678a044331f02eef647c7d1c963f37edd Mon Sep 17 00:00:00 2001 From: xithrius Date: Fri, 12 Feb 2021 00:49:27 -0800 Subject: Finalized logic to account for null cases. --- bot/exts/info/pypi.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 990a5c905..4ad72b673 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -37,11 +37,11 @@ class PyPi(Cog): embed.description = f"[{info['name']} v{info['version']}]({info['package_url']})\n" for field in FIELDS: - # Field could be completely empty, in some cases can be a string with whitespaces. - if field_value := info[field].strip(): + # Field could be completely empty, in some cases can be a string with whitespaces, or None. + if info[field] and not info[field].isspace(): embed.add_field( name=field.replace("_", " ").title(), - value=field_value, + value=info[field], inline=False, ) -- cgit v1.2.3 From bcab6614bba3ca71edeb134089846570e0e47547 Mon Sep 17 00:00:00 2001 From: xithrius Date: Fri, 12 Feb 2021 01:22:12 -0800 Subject: Moved hyperlink to title. --- bot/exts/info/pypi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 4ad72b673..c7ec22fc6 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -32,9 +32,9 @@ class PyPi(Cog): response_json = await response.json() info = response_json["info"] - embed.title = "Python Package Index" + embed.title = f"{info['name']} v{info['version']}" + embed.url = info['package_url'] embed.colour = Colours.soft_green - embed.description = f"[{info['name']} v{info['version']}]({info['package_url']})\n" for field in FIELDS: # Field could be completely empty, in some cases can be a string with whitespaces, or None. -- cgit v1.2.3 From 94ad4dd207226d7d1a2b080ffe47352e7c2b9e73 Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Fri, 12 Feb 2021 02:09:17 -0800 Subject: Made docstring more specific. Co-authored-by: Shivansh-007 <69356296+Shivansh-007@users.noreply.github.com> --- bot/exts/info/pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index c7ec22fc6..79931c665 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -21,7 +21,7 @@ class PyPi(Cog): @command(name="pypi", aliases=("package", "pack")) async def get_package_info(self, ctx: Context, package: str) -> None: - """Getting information about a specific package.""" + """Provide information about a specific package from PyPI.""" embed = Embed(title=choice(NEGATIVE_REPLIES), colour=Colours.soft_red) async with self.bot.http_session.get(URL.format(package=package)) as response: -- cgit v1.2.3 From 9ce9ab617ba0fdacb1922e2ed2007ed05e53c526 Mon Sep 17 00:00:00 2001 From: xithrius Date: Fri, 12 Feb 2021 13:10:25 -0800 Subject: Added colours yellow, blue, and white. --- bot/constants.py | 9 ++++++--- config-default.yml | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 91e41e334..8a93ff9cf 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -246,13 +246,16 @@ class Colours(metaclass=YAMLGetter): section = "style" subsection = "colours" + blue: int bright_green: int - soft_green: int - soft_orange: int - soft_red: int orange: int pink: int purple: int + soft_green: int + soft_orange: int + soft_red: int + white: int + yellow: int class DuckPond(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index d7415c821..25bbcc3c5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -24,13 +24,16 @@ bot: style: colours: + blue: 0x3775a8 bright_green: 0x01d277 - soft_green: 0x68c290 - soft_orange: 0xf9cb54 - soft_red: 0xcd6d6d orange: 0xe67e22 pink: 0xcf84e0 purple: 0xb734eb + soft_green: 0x68c290 + soft_orange: 0xf9cb54 + soft_red: 0xcd6d6d + white: 0xfffffe + yellow: 0xffd241 emojis: badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" -- cgit v1.2.3 From aa0b60534d1b8cef2e34bbaf50709553c71a14ff Mon Sep 17 00:00:00 2001 From: xithrius Date: Fri, 12 Feb 2021 13:12:24 -0800 Subject: Rotating colours in embed, title now links to package. --- bot/exts/info/pypi.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index c7ec22fc6..c7d4d321c 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -1,5 +1,6 @@ +import itertools import logging -from random import choice +import random from discord import Embed from discord.ext.commands import Cog, Context, command @@ -8,7 +9,9 @@ from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES URL = "https://pypi.org/pypi/{package}/json" -FIELDS = ["author", "requires_python", "summary", "license"] +FIELDS = ("author", "requires_python", "summary", "license") +PYPI_ICON = "https://cdn.discordapp.com/emojis/766274397257334814.png" +PYPI_COLOURS = itertools.cycle((Colours.yellow, Colours.blue, Colours.white)) log = logging.getLogger(__name__) @@ -21,8 +24,12 @@ class PyPi(Cog): @command(name="pypi", aliases=("package", "pack")) async def get_package_info(self, ctx: Context, package: str) -> None: - """Getting information about a specific package.""" - embed = Embed(title=choice(NEGATIVE_REPLIES), colour=Colours.soft_red) + """Provide information about a specific package from PyPI.""" + embed = Embed( + title=random.choice(NEGATIVE_REPLIES), + colour=Colours.soft_red + ) + embed.set_thumbnail(url=PYPI_ICON) async with self.bot.http_session.get(URL.format(package=package)) as response: if response.status == 404: @@ -34,7 +41,7 @@ class PyPi(Cog): embed.title = f"{info['name']} v{info['version']}" embed.url = info['package_url'] - embed.colour = Colours.soft_green + embed.colour = next(PYPI_COLOURS) for field in FIELDS: # Field could be completely empty, in some cases can be a string with whitespaces, or None. -- cgit v1.2.3 From 059940b5ae3cc2921303579ebf161835fe09076d Mon Sep 17 00:00:00 2001 From: xithrius Date: Fri, 12 Feb 2021 15:47:06 -0800 Subject: Taking only the first line of multiline fields. --- bot/exts/info/pypi.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index c7d4d321c..cf45b068f 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -44,11 +44,16 @@ class PyPi(Cog): embed.colour = next(PYPI_COLOURS) for field in FIELDS: + field_data = info[field] + # Field could be completely empty, in some cases can be a string with whitespaces, or None. - if info[field] and not info[field].isspace(): + if field_data and not field_data.isspace(): + if '\n' in field_data and field == "license": + field_data = field_data.split('\n')[0] + embed.add_field( name=field.replace("_", " ").title(), - value=info[field], + value=field_data, inline=False, ) -- cgit v1.2.3 From 8fcb4c6ee7718143c949aa41627064635b2b364b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 13 Feb 2021 08:24:04 +0200 Subject: Move Git SHA defining at end of Dockerfile to re-enable caching Defining SHA at the beginning of build breaks caching, so this should be avoided. --- Dockerfile | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5d0380b44..994b8ee49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,10 @@ FROM python:3.8-slim -# Define Git SHA build argument -ARG git_sha="development" - # Set pip to have cleaner logs and no saved cache ENV PIP_NO_CACHE_DIR=false \ PIPENV_HIDE_EMOJIS=1 \ PIPENV_IGNORE_VIRTUALENVS=1 \ - PIPENV_NOSPIN=1 \ - GIT_SHA=$git_sha + PIPENV_NOSPIN=1 RUN apt-get -y update \ && apt-get install -y \ @@ -25,6 +21,12 @@ WORKDIR /bot COPY Pipfile* ./ RUN pipenv install --system --deploy +# Define Git SHA build argument +ARG git_sha="development" + +# Set Git SHA environment variable here to enable caching +ENV GIT_SHA=$git_sha + # Copy the source code in last to optimize rebuilding the image COPY . . -- cgit v1.2.3 From 88b5b32696c876ee0aa5299eb78bb0d775c5b800 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 13 Feb 2021 00:25:09 -0800 Subject: Escaping markdown in all fields that are created. --- bot/exts/info/pypi.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index cf45b068f..73ec31870 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -7,6 +7,7 @@ from discord.ext.commands import Cog, Context, command from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES +from discord.utils import escape_markdown URL = "https://pypi.org/pypi/{package}/json" FIELDS = ("author", "requires_python", "summary", "license") @@ -53,7 +54,7 @@ class PyPi(Cog): embed.add_field( name=field.replace("_", " ").title(), - value=field_data, + value=escape_markdown(field_data), inline=False, ) -- cgit v1.2.3 From f14c391e3a1228953cda29be7993c9a5ec51ca6f Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 13 Feb 2021 00:34:33 -0800 Subject: Made flake8 even happier. --- bot/exts/info/pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 73ec31870..3e326e8bb 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -4,10 +4,10 @@ import random from discord import Embed from discord.ext.commands import Cog, Context, command +from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES -from discord.utils import escape_markdown URL = "https://pypi.org/pypi/{package}/json" FIELDS = ("author", "requires_python", "summary", "license") -- cgit v1.2.3 From 55f7e7085fe821b4af7e59e811148808a3a40738 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 14 Feb 2021 22:06:44 +0000 Subject: Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 994b8ee49..1a75e5669 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ RUN pipenv install --system --deploy # Define Git SHA build argument ARG git_sha="development" -# Set Git SHA environment variable here to enable caching +# Set Git SHA environment variable for Sentry ENV GIT_SHA=$git_sha # Copy the source code in last to optimize rebuilding the image -- cgit v1.2.3 From aa5e39c3866a9100fda242221106bf6d2caae38c Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 19 Feb 2021 10:07:35 +0100 Subject: Delete free.md Reasoning behind this is it is rarely used and when its used its often just to test something or as an attempt to close a help channel. --- bot/resources/tags/free.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 bot/resources/tags/free.md diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md deleted file mode 100644 index 1493076c7..000000000 --- a/bot/resources/tags/free.md +++ /dev/null @@ -1,5 +0,0 @@ -**We have a new help channel system!** - -Please see <#704250143020417084> for further information. - -A more detailed guide can be found on [our website](https://pythondiscord.com/pages/resources/guides/help-channels/). -- cgit v1.2.3 From 0f4365e2430d40f17ab9a545d3e8614a4b3a9669 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 20 Feb 2021 11:54:52 +0200 Subject: Remove attachments check in duplicates filter --- bot/rules/duplicates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py index 23aefd3dc..8e4fbc12d 100644 --- a/bot/rules/duplicates.py +++ b/bot/rules/duplicates.py @@ -13,7 +13,7 @@ async def apply( if ( msg.author == last_message.author and msg.content == last_message.content - and (msg.content and not msg.attachments) + and msg.content ) ) -- cgit v1.2.3 From 7f980be37a572f1998160ce6a2221504e414d285 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sat, 20 Feb 2021 11:55:09 +0200 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ad813d893..7217cb443 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,11 +7,15 @@ bot/exts/utils/extensions.py @MarkKoz bot/exts/utils/snekbox.py @MarkKoz @Akarys42 bot/exts/help_channels/** @MarkKoz @Akarys42 bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129 -bot/exts/info/** @Akarys42 @mbaruh @Den4200 +bot/exts/info/** @Akarys42 @Den4200 +bot/exts/info/information.py @mbaruh bot/exts/filters/** @mbaruh bot/exts/fun/** @ks129 bot/exts/utils/** @ks129 +# Rules +bot/rules/** @mbaruh + # Utils bot/utils/extensions.py @MarkKoz bot/utils/function.py @MarkKoz -- cgit v1.2.3 From e3b980e53c13fd5dcaf51408f97c99b629c1a6ec Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 20 Feb 2021 11:55:26 +0200 Subject: Set max attachment from 3 -> 6 --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index d323a946d..e9dce7845 100644 --- a/config-default.yml +++ b/config-default.yml @@ -367,7 +367,7 @@ anti_spam: rules: attachments: interval: 10 - max: 3 + max: 6 burst: interval: 10 -- cgit v1.2.3 From 93c3327414dabd12236e47210be2be1151b71719 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 21 Feb 2021 13:50:37 +0100 Subject: Show the last three characters of censored tokens --- bot/exts/filters/token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index bd6a1f97a..33b39cc2d 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -147,7 +147,7 @@ class TokenRemover(Cog): channel=msg.channel.mention, user_id=token.user_id, timestamp=token.timestamp, - hmac='x' * len(token.hmac), + hmac='x' * (len(token.hmac) - 3) + token.hmac[-3:], ) @classmethod -- cgit v1.2.3 From 04e233685e163d1e513a21acd236c2385536b0b7 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 21 Feb 2021 13:51:57 +0100 Subject: Ping the mods if a token present in the server is found no matter the kind --- bot/exts/filters/token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 33b39cc2d..93f1f3c33 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -135,7 +135,7 @@ class TokenRemover(Cog): user_id=user_id, user_name=str(user), kind="BOT" if user.bot else "USER", - ), not user.bot + ), True else: return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False -- cgit v1.2.3 From 27e60e94bd1fe6784c2b7674433bb175255fa217 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 21 Feb 2021 14:06:54 +0100 Subject: Update token remover unittests --- tests/bot/exts/filters/test_token_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index f99cc3370..51feae9cb 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -291,7 +291,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): channel=self.msg.channel.mention, user_id=token.user_id, timestamp=token.timestamp, - hmac="x" * len(token.hmac), + hmac="xxxxxxxxxxxxxxxxxxxxxxxxjf4", ) @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") @@ -318,7 +318,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): return_value = TokenRemover.format_userid_log_message(msg, token) - self.assertEqual(return_value, (known_user_log_message.format.return_value, False)) + self.assertEqual(return_value, (known_user_log_message.format.return_value, True)) known_user_log_message.format.assert_called_once_with( user_id=472265943062413332, -- cgit v1.2.3 From 584ed52c7107c7d3e3b838ee1e8df3a22ae95e35 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 21 Feb 2021 23:07:38 +0000 Subject: Update max available channels to 3 Partially resolves #1427 --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index beaf89f2c..8e9a29a51 100644 --- a/config-default.yml +++ b/config-default.yml @@ -470,7 +470,7 @@ help_channels: deleted_idle_minutes: 5 # Maximum number of channels to put in the available category - max_available: 2 + max_available: 3 # Maximum number of channels across all 3 categories # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50 -- cgit v1.2.3 From 1daf01ef9a3853252d4cadab5fc6abce14df3557 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 22 Feb 2021 11:35:12 +0000 Subject: Rewrite inline codeblock tag --- bot/resources/tags/inline.md | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md index a6a7c35d6..4ece74ef7 100644 --- a/bot/resources/tags/inline.md +++ b/bot/resources/tags/inline.md @@ -1,16 +1,7 @@ **Inline codeblocks** -In addition to multi-line codeblocks, discord has support for inline codeblocks as well. These are small codeblocks that are usually a single line, that can fit between non-codeblocks on the same line. +Inline codeblocks look `like this`. To create them you surround text with single backticks, so \`hello\` would become `hello`. -The following is an example of how it's done: +Note that backticks are not quotes, see [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) if you are struggling to find the backtick key. -The \`\_\_init\_\_\` method customizes the newly created instance. - -And results in the following: - -The `__init__` method customizes the newly created instance. - -**Note:** -• These are **backticks** not quotes -• Avoid using them for multiple lines -• Useful for negating formatting you don't want +For how to make multiline codeblocks see the `!codeblock` tag. -- cgit v1.2.3 From b116688be7d8b3d83c88a78969e2118e0504fadc Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 22 Feb 2021 11:38:45 +0000 Subject: Add pep 8 song to pep 8 tag --- bot/resources/tags/pep8.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md index cab4c4db8..57b176122 100644 --- a/bot/resources/tags/pep8.md +++ b/bot/resources/tags/pep8.md @@ -1,3 +1,5 @@ -**PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like `flake8` to verify that the code they\'re writing complies with the style guide. +**PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like flake8 to verify that the code they're writing complies with the style guide. -You can find the PEP 8 document [here](https://www.python.org/dev/peps/pep-0008). +More information: +• [PEP 8 document](https://www.python.org/dev/peps/pep-0008) +• [Our PEP 8 song!](https://www.youtube.com/watch?v=hgI0p1zf31k) :notes: -- cgit v1.2.3 From 0b11d7dfb408f4e5fe6248ae8377ddc7aa1aa5ee Mon Sep 17 00:00:00 2001 From: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> Date: Tue, 23 Feb 2021 03:48:35 +0100 Subject: Add truncate_message util --- bot/utils/messages.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 077dd9569..c01fa5d0e 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -154,3 +154,12 @@ async def send_denial(ctx: Context, reason: str) -> None: def format_user(user: discord.abc.User) -> str: """Return a string for `user` which has their mention and ID.""" return f"{user.mention} (`{user.id}`)" + + +def truncate_message(message: discord.Message, limit: int) -> str: + """Returns a truncated version of the message content, up to the specified limit.""" + text = message.content + if len(text) > limit: + return text[:limit-3] + "..." + else: + return text -- cgit v1.2.3 From e1d269d82eed8a01d3d3b0ff33d05e3c79324007 Mon Sep 17 00:00:00 2001 From: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> Date: Tue, 23 Feb 2021 04:00:01 +0100 Subject: Add function to DM users when opening help channel --- bot/exts/help_channels/_message.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 2bbd4bdd6..12ac4035d 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -8,6 +8,7 @@ import bot from bot import constants from bot.exts.help_channels import _caches from bot.utils.channel import is_in_category +from bot.utils.messages import truncate_message log = logging.getLogger(__name__) @@ -92,6 +93,38 @@ async def is_empty(channel: discord.TextChannel) -> bool: return False +async def dm_on_open(message: discord.Message) -> None: + """ + DM claimant with a link to the claimed channel's first message, with a 100 letter preview of the message. + + Does nothing if the user has DMs disabled. + """ + embed = discord.Embed( + title="Help channel opened", + description=f"You claimed {message.channel.mention}.", + colour=bot.constants.Colours.bright_green, + timestamp=message.created_at, + ) + + embed.set_thumbnail(url=constants.Icons.green_questionmark) + embed.add_field( + name="Your message", value=truncate_message(message, limit=100), inline=False + ) + embed.add_field( + name="Want to go there?", + value=f"[Jump to message!]({message.jump_url})", + inline=False, + ) + + try: + await message.author.send(embed=embed) + log.trace(f"Sent DM to {message.author.id} after claiming help channel.") + except discord.errors.Forbidden: + log.trace( + f"Ignoring to send DM to {message.author.id} after claiming help channel: DMs disabled." + ) + + async def notify(channel: discord.TextChannel, last_notification: t.Optional[datetime]) -> t.Optional[datetime]: """ Send a message in `channel` notifying about a lack of available help channels. -- cgit v1.2.3 From e6483d633ac6ecc2a88051442108d9c88e5f7745 Mon Sep 17 00:00:00 2001 From: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> Date: Tue, 23 Feb 2021 04:00:58 +0100 Subject: Add green question mark to default config Add green question mark to default config Add green question mark to config --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 8a93ff9cf..69bc82b89 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -326,6 +326,7 @@ class Icons(metaclass=YAMLGetter): filtering: str green_checkmark: str + green_questionmark: str guild_update: str hash_blurple: str diff --git a/config-default.yml b/config-default.yml index 8e9a29a51..7d9afaa0e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -90,6 +90,7 @@ style: filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png" green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-checkmark-dist.png" + green_questionmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-question-mark-dist.png" guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png" hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png" -- cgit v1.2.3 From e34ea2f1c108d1900e251d17b38563536345d2de Mon Sep 17 00:00:00 2001 From: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> Date: Tue, 23 Feb 2021 04:07:05 +0100 Subject: Send DM when user claims help channel --- bot/exts/help_channels/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 0995c8a79..a18ddc900 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -102,6 +102,7 @@ class HelpChannels(commands.Cog): await _cooldown.revoke_send_permissions(message.author, self.scheduler) await _message.pin(message) + await _message.dm_on_open(message) # Add user with channel for dormant check. await _caches.claimants.set(message.channel.id, message.author.id) -- cgit v1.2.3 From bb9e56c3cb874ef76ab82db02ce8242117e0da92 Mon Sep 17 00:00:00 2001 From: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> Date: Tue, 23 Feb 2021 11:08:41 +0100 Subject: Update embed field title to be more formal --- bot/exts/help_channels/_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 12ac4035d..95aca067a 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -111,7 +111,7 @@ async def dm_on_open(message: discord.Message) -> None: name="Your message", value=truncate_message(message, limit=100), inline=False ) embed.add_field( - name="Want to go there?", + name="Conversation", value=f"[Jump to message!]({message.jump_url})", inline=False, ) -- cgit v1.2.3 From cae0d84757e026976f1a9e87d52c581669b7b8e8 Mon Sep 17 00:00:00 2001 From: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> Date: Tue, 23 Feb 2021 11:14:31 +0100 Subject: Use textwrap.shorten instead of custom function This applies to the help channel DM embed, where the user is sent a truncated version of their message. --- bot/exts/help_channels/_message.py | 6 ++++-- bot/utils/messages.py | 9 --------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 95aca067a..4113e51c5 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -1,4 +1,5 @@ import logging +import textwrap import typing as t from datetime import datetime @@ -8,7 +9,6 @@ import bot from bot import constants from bot.exts.help_channels import _caches from bot.utils.channel import is_in_category -from bot.utils.messages import truncate_message log = logging.getLogger(__name__) @@ -108,7 +108,9 @@ async def dm_on_open(message: discord.Message) -> None: embed.set_thumbnail(url=constants.Icons.green_questionmark) embed.add_field( - name="Your message", value=truncate_message(message, limit=100), inline=False + name="Your message", + value=textwrap.shorten(message.content, width=100, placeholder="..."), + inline=False, ) embed.add_field( name="Conversation", diff --git a/bot/utils/messages.py b/bot/utils/messages.py index c01fa5d0e..077dd9569 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -154,12 +154,3 @@ async def send_denial(ctx: Context, reason: str) -> None: def format_user(user: discord.abc.User) -> str: """Return a string for `user` which has their mention and ID.""" return f"{user.mention} (`{user.id}`)" - - -def truncate_message(message: discord.Message, limit: int) -> str: - """Returns a truncated version of the message content, up to the specified limit.""" - text = message.content - if len(text) > limit: - return text[:limit-3] + "..." - else: - return text -- cgit v1.2.3 From d71ac9f6e240ffd2d4195d9dbbf5740a0c2413a1 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 23 Feb 2021 19:24:18 +0300 Subject: Fixes Problems With Help Channel DM Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/help_channels/_cog.py | 5 ++++- bot/exts/help_channels/_message.py | 8 +++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a18ddc900..6abf99810 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -102,7 +102,10 @@ class HelpChannels(commands.Cog): await _cooldown.revoke_send_permissions(message.author, self.scheduler) await _message.pin(message) - await _message.dm_on_open(message) + try: + await _message.dm_on_open(message) + except Exception as e: + log.warning("Error occurred while sending DM:", exc_info=e) # Add user with channel for dormant check. await _caches.claimants.set(message.channel.id, message.author.id) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 4113e51c5..36388f9bd 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -107,11 +107,9 @@ async def dm_on_open(message: discord.Message) -> None: ) embed.set_thumbnail(url=constants.Icons.green_questionmark) - embed.add_field( - name="Your message", - value=textwrap.shorten(message.content, width=100, placeholder="..."), - inline=False, - ) + formatted_message = textwrap.shorten(message.content, width=100, placeholder="...") + if formatted_message: + embed.add_field(name="Your message", value=formatted_message, inline=False) embed.add_field( name="Conversation", value=f"[Jump to message!]({message.jump_url})", -- cgit v1.2.3 From 44eb00ca03dae1b3d5faf40be63fae04ca515790 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 18:27:25 +0100 Subject: Add off-topic etiquette to the off-topic tag --- bot/resources/tags/off-topic.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md index c7f98a813..6a864a1d5 100644 --- a/bot/resources/tags/off-topic.md +++ b/bot/resources/tags/off-topic.md @@ -6,3 +6,5 @@ There are three off-topic channels: • <#463035268514185226> Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. + +Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations. -- cgit v1.2.3 From c5e113734d16e8d5ac2eede6c1f29e019cfc2f28 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 25 Feb 2021 12:58:15 +0300 Subject: Adds More Descriptive Startup Error Messages Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/__main__.py | 24 ++++++++++++++++++++---- bot/bot.py | 13 ++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 257216fa7..e4df4b77d 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,10 +1,26 @@ +import logging + +import aiohttp + import bot from bot import constants -from bot.bot import Bot +from bot.bot import Bot, StartupError from bot.log import setup_sentry setup_sentry() -bot.instance = Bot.create() -bot.instance.load_extensions() -bot.instance.run(constants.Bot.token) +try: + bot.instance = Bot.create() + bot.instance.load_extensions() + bot.instance.run(constants.Bot.token) +except StartupError as e: + message = "Unknown Startup Error Occurred." + if isinstance(e.exception, aiohttp.ClientConnectorError): + message = "Could not connect to site API. Is it running?" + elif isinstance(e.exception, OSError): + message = "Could not connect to Redis. Is it running?" + + # The exception is logged with an empty message so the actual message is visible at the bottom + log = logging.getLogger("bot") + log.fatal("", exc_info=e.exception) + log.fatal(message) diff --git a/bot/bot.py b/bot/bot.py index d5f108575..df80868ee 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -19,6 +19,14 @@ log = logging.getLogger('bot') LOCALHOST = "127.0.0.1" +class StartupError(Exception): + """Exception class for startup errors.""" + + def __init__(self, base: Exception): + super() + self.exception = base + + class Bot(commands.Bot): """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client.""" @@ -318,5 +326,8 @@ def _create_redis_session(loop: asyncio.AbstractEventLoop) -> RedisSession: use_fakeredis=constants.Redis.use_fakeredis, global_namespace="bot", ) - loop.run_until_complete(redis_session.connect()) + try: + loop.run_until_complete(redis_session.connect()) + except OSError as e: + raise StartupError(e) return redis_session -- cgit v1.2.3 From 866f3156cb05e49a8ca2c9ebdb13688829f15914 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 25 Feb 2021 12:59:37 +0300 Subject: Adds Site Readiness Checks Attempts to connect to the site multiple times before throwing an exception to allow the site to warm up when running in docker. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/bot.py | 21 +++++++++++++++++++++ bot/constants.py | 2 ++ config-default.yml | 2 ++ 3 files changed, 25 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index df80868ee..cd8e26325 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -89,6 +89,22 @@ class Bot(commands.Bot): for item in full_cache: self.insert_item_into_filter_list_cache(item) + async def ping_services(self) -> None: + """A helper to make sure all the services the bot relies on are available on startup.""" + # Connect Site/API + attempts = 0 + while True: + try: + log.info(f"Attempting site connection: {attempts + 1}/{constants.URLs.connect_max_retries}") + await self.api_client.get("healthcheck") + break + + except aiohttp.ClientConnectorError as e: + attempts += 1 + if attempts == constants.URLs.connect_max_retries: + raise e + await asyncio.sleep(constants.URLs.connect_cooldown) + @classmethod def create(cls) -> "Bot": """Create and return an instance of a Bot.""" @@ -231,6 +247,11 @@ class Bot(commands.Bot): # here. Normally, this shouldn't happen. await self.redis_session.connect() + try: + await self.ping_services() + except Exception as e: + raise StartupError(e) + # Build the FilterList cache await self.cache_filter_list_data() diff --git a/bot/constants.py b/bot/constants.py index 69bc82b89..7cf31e835 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -531,6 +531,8 @@ class URLs(metaclass=YAMLGetter): github_bot_repo: str # Base site vars + connect_max_retries: int + connect_cooldown: int site: str site_api: str site_schema: str diff --git a/config-default.yml b/config-default.yml index 7d9afaa0e..a9fb2262e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -338,6 +338,8 @@ keys: urls: # PyDis site vars + connect_max_retries: 3 + connect_cooldown: 5 site: &DOMAIN "pythondiscord.com" site_api: &API "pydis-api.default.svc.cluster.local" site_api_schema: "http://" -- cgit v1.2.3 From 900923cc6a9b4d40b625b8f33e8bef18a286a84f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 25 Feb 2021 13:23:14 +0300 Subject: Catches All Site Startup Issues Adds a missing exception when trying to connect to the site on startup. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/__main__.py | 2 +- bot/bot.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index e4df4b77d..d3abcd7b2 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -15,7 +15,7 @@ try: bot.instance.run(constants.Bot.token) except StartupError as e: message = "Unknown Startup Error Occurred." - if isinstance(e.exception, aiohttp.ClientConnectorError): + if type(e.exception) in [aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError]: message = "Could not connect to site API. Is it running?" elif isinstance(e.exception, OSError): message = "Could not connect to Redis. Is it running?" diff --git a/bot/bot.py b/bot/bot.py index cd8e26325..1a815c31e 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -99,7 +99,7 @@ class Bot(commands.Bot): await self.api_client.get("healthcheck") break - except aiohttp.ClientConnectorError as e: + except (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError) as e: attempts += 1 if attempts == constants.URLs.connect_max_retries: raise e -- cgit v1.2.3 From 4c566bb2445d0bc637e11242c44a69baa8a39e48 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 25 Feb 2021 13:42:22 +0300 Subject: Cleans Up Startup Error Handler Code Style Co-authored-by: Akarys42 Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/__main__.py | 4 +++- bot/bot.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index d3abcd7b2..9317563c8 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -15,7 +15,7 @@ try: bot.instance.run(constants.Bot.token) except StartupError as e: message = "Unknown Startup Error Occurred." - if type(e.exception) in [aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError]: + if isinstance(e.exception, (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError)): message = "Could not connect to site API. Is it running?" elif isinstance(e.exception, OSError): message = "Could not connect to Redis. Is it running?" @@ -24,3 +24,5 @@ except StartupError as e: log = logging.getLogger("bot") log.fatal("", exc_info=e.exception) log.fatal(message) + + exit(69) diff --git a/bot/bot.py b/bot/bot.py index 1a815c31e..3218a60b4 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -102,7 +102,7 @@ class Bot(commands.Bot): except (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError) as e: attempts += 1 if attempts == constants.URLs.connect_max_retries: - raise e + raise await asyncio.sleep(constants.URLs.connect_cooldown) @classmethod -- cgit v1.2.3 From 283857f543ca50e188f39a9b880cef9963f486db Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 25 Feb 2021 13:44:18 +0300 Subject: Call Super __init__ in Startup Error Co-authored-by: Matteo Bertucci --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index 3218a60b4..1b4037076 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -23,7 +23,7 @@ class StartupError(Exception): """Exception class for startup errors.""" def __init__(self, base: Exception): - super() + super().__init__() self.exception = base -- cgit v1.2.3 From fb7e21a0897e6de4964ff883f1cd52a9dd443722 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 25 Feb 2021 13:48:52 +0300 Subject: Removes Unused Variable Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index 1b4037076..3a2af472d 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -99,7 +99,7 @@ class Bot(commands.Bot): await self.api_client.get("healthcheck") break - except (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError) as e: + except (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError): attempts += 1 if attempts == constants.URLs.connect_max_retries: raise -- cgit v1.2.3 From ad2bc5d2d1d94ac3ef60d9b60e6f716be5827bf2 Mon Sep 17 00:00:00 2001 From: Sebastian Kuipers <61157793+sebkuip@users.noreply.github.com> Date: Thu, 25 Feb 2021 17:17:00 +0100 Subject: Apply suggestions from code review Co-authored-by: Mark --- bot/resources/tags/empty-json.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index 93e2cadba..935544bb7 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -4,8 +4,8 @@ JSONDecodeError: Expecting value: line 1 column 1 (char 0) ``` This error could have appeared because you just created the JSON file and there is nothing in it at the moment. -Whilst having the data empty is no problem, the file itself may never be completely empty. +Whilst having empty data is no problem, the file itself may never be completely empty. -You most likely wanted to structure your JSON as a dictionary. To do this, change your JSON to read `{}`. +You most likely wanted to structure your JSON as a dictionary. To do this, edit your empty JSON file so that it instead contains `{}`. Different data types are also supported. If you wish to read more on these, please refer to [this article](https://www.tutorialspoint.com/json/json_data_types.htm). -- cgit v1.2.3 From 82190ee57bd25a1e999b7a8fb323513696e7e042 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Feb 2021 02:25:18 +0000 Subject: Bump aiohttp from 3.7.3 to 3.7.4 Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.7.3 to 3.7.4. - [Release notes](https://github.com/aio-libs/aiohttp/releases) - [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst) - [Commits](https://github.com/aio-libs/aiohttp/compare/v3.7.3...v3.7.4) Signed-off-by: dependabot[bot] --- Pipfile | 2 +- Pipfile.lock | 282 ++++++++++++++++++++++++++++------------------------------- 2 files changed, 133 insertions(+), 151 deletions(-) diff --git a/Pipfile b/Pipfile index efdd46522..0a94fb888 100644 --- a/Pipfile +++ b/Pipfile @@ -6,7 +6,7 @@ name = "pypi" [packages] aio-pika = "~=6.1" aiodns = "~=2.0" -aiohttp = "~=3.5" +aiohttp = "~=3.7" aioping = "~=0.3.1" aioredis = "~=1.3.1" "async-rediscache[fakeredis]" = "~=0.1.2" diff --git a/Pipfile.lock b/Pipfile.lock index 636d07b1a..f8cedb08f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "26c8089f17d6d6bac11dbed366b1b46818b4546f243af756a106a32af5d9d8f6" + "sha256": "228ae55fe5700ac3827ba6b661933b60b1d06f44fea8bcbe8c5a769fa10ab2fd" }, "pipfile-spec": 6, "requires": { @@ -34,46 +34,46 @@ }, "aiohttp": { "hashes": [ - "sha256:0b795072bb1bf87b8620120a6373a3c61bfcb8da7e5c2377f4bb23ff4f0b62c9", - "sha256:0d438c8ca703b1b714e82ed5b7a4412c82577040dadff479c08405e2a715564f", - "sha256:16a3cb5df5c56f696234ea9e65e227d1ebe9c18aa774d36ff42f532139066a5f", - "sha256:1edfd82a98c5161497bbb111b2b70c0813102ad7e0aa81cbeb34e64c93863005", - "sha256:2406dc1dda01c7f6060ab586e4601f18affb7a6b965c50a8c90ff07569cf782a", - "sha256:2858b2504c8697beb9357be01dc47ef86438cc1cb36ecb6991796d19475faa3e", - "sha256:2a7b7640167ab536c3cb90cfc3977c7094f1c5890d7eeede8b273c175c3910fd", - "sha256:3228b7a51e3ed533f5472f54f70fd0b0a64c48dc1649a0f0e809bec312934d7a", - "sha256:328b552513d4f95b0a2eea4c8573e112866107227661834652a8984766aa7656", - "sha256:39f4b0a6ae22a1c567cb0630c30dd082481f95c13ca528dc501a7766b9c718c0", - "sha256:3b0036c978cbcc4a4512278e98e3e6d9e6b834dc973206162eddf98b586ef1c6", - "sha256:3ea8c252d8df5e9166bcf3d9edced2af132f4ead8ac422eac723c5781063709a", - "sha256:41608c0acbe0899c852281978492f9ce2c6fbfaf60aff0cefc54a7c4516b822c", - "sha256:59d11674964b74a81b149d4ceaff2b674b3b0e4d0f10f0be1533e49c4a28408b", - "sha256:5e479df4b2d0f8f02133b7e4430098699450e1b2a826438af6bec9a400530957", - "sha256:684850fb1e3e55c9220aad007f8386d8e3e477c4ec9211ae54d968ecdca8c6f9", - "sha256:6ccc43d68b81c424e46192a778f97da94ee0630337c9bbe5b2ecc9b0c1c59001", - "sha256:6d42debaf55450643146fabe4b6817bb2a55b23698b0434107e892a43117285e", - "sha256:710376bf67d8ff4500a31d0c207b8941ff4fba5de6890a701d71680474fe2a60", - "sha256:756ae7efddd68d4ea7d89c636b703e14a0c686688d42f588b90778a3c2fc0564", - "sha256:77149002d9386fae303a4a162e6bce75cc2161347ad2ba06c2f0182561875d45", - "sha256:78e2f18a82b88cbc37d22365cf8d2b879a492faedb3f2975adb4ed8dfe994d3a", - "sha256:7d9b42127a6c0bdcc25c3dcf252bb3ddc70454fac593b1b6933ae091396deb13", - "sha256:8389d6044ee4e2037dca83e3f6994738550f6ee8cfb746762283fad9b932868f", - "sha256:9c1a81af067e72261c9cbe33ea792893e83bc6aa987bfbd6fdc1e5e7b22777c4", - "sha256:c1e0920909d916d3375c7a1fdb0b1c78e46170e8bb42792312b6eb6676b2f87f", - "sha256:c68fdf21c6f3573ae19c7ee65f9ff185649a060c9a06535e9c3a0ee0bbac9235", - "sha256:c733ef3bdcfe52a1a75564389bad4064352274036e7e234730526d155f04d914", - "sha256:c9c58b0b84055d8bc27b7df5a9d141df4ee6ff59821f922dd73155861282f6a3", - "sha256:d03abec50df423b026a5aa09656bd9d37f1e6a49271f123f31f9b8aed5dc3ea3", - "sha256:d2cfac21e31e841d60dc28c0ec7d4ec47a35c608cb8906435d47ef83ffb22150", - "sha256:dcc119db14757b0c7bce64042158307b9b1c76471e655751a61b57f5a0e4d78e", - "sha256:df3a7b258cc230a65245167a202dd07320a5af05f3d41da1488ba0fa05bc9347", - "sha256:df48a623c58180874d7407b4d9ec06a19b84ed47f60a3884345b1a5099c1818b", - "sha256:e1b95972a0ae3f248a899cdbac92ba2e01d731225f566569311043ce2226f5e7", - "sha256:f326b3c1bbfda5b9308252ee0dcb30b612ee92b0e105d4abec70335fab5b1245", - "sha256:f411cb22115cb15452d099fec0ee636b06cf81bfb40ed9c02d30c8dc2bc2e3d1" + "sha256:119feb2bd551e58d83d1b38bfa4cb921af8ddedec9fad7183132db334c3133e0", + "sha256:16d0683ef8a6d803207f02b899c928223eb219111bd52420ef3d7a8aa76227b6", + "sha256:2eb3efe243e0f4ecbb654b08444ae6ffab37ac0ef8f69d3a2ffb958905379daf", + "sha256:2ffea7904e70350da429568113ae422c88d2234ae776519549513c8f217f58a9", + "sha256:40bd1b101b71a18a528ffce812cc14ff77d4a2a1272dfb8b11b200967489ef3e", + "sha256:418597633b5cd9639e514b1d748f358832c08cd5d9ef0870026535bd5eaefdd0", + "sha256:481d4b96969fbfdcc3ff35eea5305d8565a8300410d3d269ccac69e7256b1329", + "sha256:4c1bdbfdd231a20eee3e56bd0ac1cd88c4ff41b64ab679ed65b75c9c74b6c5c2", + "sha256:5563ad7fde451b1986d42b9bb9140e2599ecf4f8e42241f6da0d3d624b776f40", + "sha256:58c62152c4c8731a3152e7e650b29ace18304d086cb5552d317a54ff2749d32a", + "sha256:5b50e0b9460100fe05d7472264d1975f21ac007b35dcd6fd50279b72925a27f4", + "sha256:5d84ecc73141d0a0d61ece0742bb7ff5751b0657dab8405f899d3ceb104cc7de", + "sha256:5dde6d24bacac480be03f4f864e9a67faac5032e28841b00533cd168ab39cad9", + "sha256:5e91e927003d1ed9283dee9abcb989334fc8e72cf89ebe94dc3e07e3ff0b11e9", + "sha256:62bc216eafac3204877241569209d9ba6226185aa6d561c19159f2e1cbb6abfb", + "sha256:6c8200abc9dc5f27203986100579fc19ccad7a832c07d2bc151ce4ff17190076", + "sha256:6ca56bdfaf825f4439e9e3673775e1032d8b6ea63b8953d3812c71bd6a8b81de", + "sha256:71680321a8a7176a58dfbc230789790639db78dad61a6e120b39f314f43f1907", + "sha256:7c7820099e8b3171e54e7eedc33e9450afe7cd08172632d32128bd527f8cb77d", + "sha256:7dbd087ff2f4046b9b37ba28ed73f15fd0bc9f4fdc8ef6781913da7f808d9536", + "sha256:822bd4fd21abaa7b28d65fc9871ecabaddc42767884a626317ef5b75c20e8a2d", + "sha256:8ec1a38074f68d66ccb467ed9a673a726bb397142c273f90d4ba954666e87d54", + "sha256:950b7ef08b2afdab2488ee2edaff92a03ca500a48f1e1aaa5900e73d6cf992bc", + "sha256:99c5a5bf7135607959441b7d720d96c8e5c46a1f96e9d6d4c9498be8d5f24212", + "sha256:b84ad94868e1e6a5e30d30ec419956042815dfaea1b1df1cef623e4564c374d9", + "sha256:bc3d14bf71a3fb94e5acf5bbf67331ab335467129af6416a437bd6024e4f743d", + "sha256:c2a80fd9a8d7e41b4e38ea9fe149deed0d6aaede255c497e66b8213274d6d61b", + "sha256:c44d3c82a933c6cbc21039326767e778eface44fca55c65719921c4b9661a3f7", + "sha256:cc31e906be1cc121ee201adbdf844522ea3349600dd0a40366611ca18cd40e81", + "sha256:d5d102e945ecca93bcd9801a7bb2fa703e37ad188a2f81b1e65e4abe4b51b00c", + "sha256:dd7936f2a6daa861143e376b3a1fb56e9b802f4980923594edd9ca5670974895", + "sha256:dee68ec462ff10c1d836c0ea2642116aba6151c6880b688e56b4c0246770f297", + "sha256:e76e78863a4eaec3aee5722d85d04dcbd9844bc6cd3bfa6aa880ff46ad16bfcb", + "sha256:eab51036cac2da8a50d7ff0ea30be47750547c9aa1aa2cf1a1b710a1827e7dbe", + "sha256:f4496d8d04da2e98cc9133e238ccebf6a13ef39a93da2e87146c8c8ac9768242", + "sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0", + "sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2" ], "index": "pypi", - "version": "==3.7.3" + "version": "==3.7.4" }, "aioping": { "hashes": [ @@ -96,7 +96,6 @@ "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573", "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e" ], - "markers": "python_version >= '3.6'", "version": "==3.3.1" }, "alabaster": { @@ -123,7 +122,6 @@ "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af" ], "index": "pypi", - "markers": "python_version ~= '3.7'", "version": "==0.1.4" }, "async-timeout": { @@ -131,7 +129,6 @@ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" ], - "markers": "python_full_version >= '3.5.3'", "version": "==3.0.1" }, "attrs": { @@ -139,7 +136,6 @@ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, "babel": { @@ -147,7 +143,6 @@ "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5", "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.9.0" }, "beautifulsoup4": { @@ -168,44 +163,45 @@ }, "cffi": { "hashes": [ - "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e", - "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", - "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", - "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", - "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", - "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", - "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", - "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", - "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", - "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698", - "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2", - "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", - "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", - "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009", - "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03", - "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", - "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", - "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53", - "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35", - "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", - "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", - "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", - "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", - "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", - "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", - "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", - "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", - "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", - "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", - "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", - "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", - "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", - "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", - "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", - "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", - "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f" - ], - "version": "==1.14.4" + "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", + "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", + "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", + "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", + "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", + "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", + "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", + "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", + "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", + "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", + "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", + "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", + "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", + "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", + "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", + "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", + "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", + "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", + "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", + "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", + "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", + "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", + "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", + "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", + "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", + "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", + "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", + "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", + "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", + "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", + "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", + "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", + "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", + "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", + "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", + "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", + "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" + ], + "version": "==1.14.5" }, "chardet": { "hashes": [ @@ -219,6 +215,7 @@ "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" ], + "index": "pypi", "markers": "sys_platform == 'win32'", "version": "==0.4.4" }, @@ -251,7 +248,6 @@ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "emoji": { @@ -334,7 +330,6 @@ "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390", "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "humanfriendly": { @@ -342,7 +337,6 @@ "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d", "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==9.1" }, "idna": { @@ -350,7 +344,6 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "imagesize": { @@ -358,16 +351,14 @@ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "jinja2": { "hashes": [ - "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", - "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", + "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.11.2" + "version": "==2.11.3" }, "lxml": { "hashes": [ @@ -427,8 +418,12 @@ "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", + "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", + "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", @@ -437,26 +432,40 @@ "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", + "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", + "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", + "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", + "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", + "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", + "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "more-itertools": { @@ -507,23 +516,20 @@ "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" ], - "markers": "python_version >= '3.6'", "version": "==5.1.0" }, "ordered-set": { "hashes": [ "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95" ], - "markers": "python_version >= '3.5'", "version": "==4.0.2" }, "packaging": { "hashes": [ - "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858", - "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093" + "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", + "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.8" + "version": "==20.9" }, "pamqp": { "hashes": [ @@ -571,23 +577,20 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { "hashes": [ - "sha256:bc9591213a8f0e0ca1a5e68a479b4887fdc3e75d0774e5c71c31920c427de435", - "sha256:df49d09b498e83c1a73128295860250b0b7edd4c723a32e9bc0d295c7c2ec337" + "sha256:37a13ba168a02ac54cc5891a42b1caec333e59b66addb7fa633ea8a6d73445c0", + "sha256:b21b072d0ccdf29297a82a2363359d99623597b8a265b8081760e4d0f7153c88" ], - "markers": "python_version >= '3.5'", - "version": "==2.7.4" + "version": "==2.8.0" }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.4.7" }, "python-dateutil": { @@ -600,10 +603,10 @@ }, "pytz": { "hashes": [ - "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", - "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" + "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", + "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" ], - "version": "==2020.5" + "version": "==2021.1" }, "pyyaml": { "hashes": [ @@ -629,7 +632,6 @@ "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==3.5.3" }, "requests": { @@ -653,15 +655,14 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.15.0" }, "snowballstemmer": { "hashes": [ - "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", - "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" + "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2", + "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914" ], - "version": "==2.0.0" + "version": "==2.1.0" }, "sortedcontainers": { "hashes": [ @@ -672,11 +673,11 @@ }, "soupsieve": { "hashes": [ - "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851", - "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e" + "sha256:407fa1e8eb3458d1b5614df51d9651a1180ea5fedf07feb46e45d7e25e6d6cdd", + "sha256:d3a5ea5b350423f47d07639f74475afedad48cf41c0ad7a82ca13a3928af34f6" ], "markers": "python_version >= '3.0'", - "version": "==2.1" + "version": "==2.2" }, "sphinx": { "hashes": [ @@ -691,7 +692,6 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -699,7 +699,6 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], - "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -707,7 +706,6 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -715,7 +713,6 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], - "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -723,7 +720,6 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], - "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -731,7 +727,6 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], - "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "statsd": { @@ -752,11 +747,10 @@ }, "urllib3": { "hashes": [ - "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", - "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" + "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", + "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.2" + "version": "==1.26.3" }, "yarl": { "hashes": [ @@ -798,7 +792,6 @@ "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" ], - "markers": "python_version >= '3.6'", "version": "==1.6.3" } }, @@ -815,7 +808,6 @@ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, "certifi": { @@ -830,7 +822,6 @@ "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1" ], - "markers": "python_full_version >= '3.6.1'", "version": "==3.2.0" }, "chardet": { @@ -995,18 +986,16 @@ }, "identify": { "hashes": [ - "sha256:18994e850ba50c37bcaed4832be8b354d6a06c8fb31f54e0e7ece76d32f69bc8", - "sha256:892473bf12e655884132a3a32aca737a3cbefaa34a850ff52d501773a45837bc" + "sha256:de7129142a5c86d75a52b96f394d94d96d497881d2aaf8eafe320cdbe8ac4bcc", + "sha256:e0dae57c0397629ce13c289f6ddde0204edf518f557bfdb1e56474aa143e77c3" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.12" + "version": "==1.5.14" }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "mccabe": { @@ -1044,7 +1033,6 @@ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "pydocstyle": { @@ -1052,7 +1040,6 @@ "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" ], - "markers": "python_version >= '3.5'", "version": "==5.1.1" }, "pyflakes": { @@ -1060,7 +1047,6 @@ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, "pyyaml": { @@ -1095,39 +1081,35 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.15.0" }, "snowballstemmer": { "hashes": [ - "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", - "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" + "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2", + "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914" ], - "version": "==2.0.0" + "version": "==2.1.0" }, "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==0.10.2" }, "urllib3": { "hashes": [ - "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", - "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" + "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", + "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.2" + "version": "==1.26.3" }, "virtualenv": { "hashes": [ - "sha256:0c111a2236b191422b37fe8c28b8c828ced39aab4bf5627fa5c331aeffb570d9", - "sha256:14b34341e742bdca219e10708198e704e8a7064dd32f474fc16aca68ac53a306" + "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d", + "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.1" + "version": "==20.4.2" } } } -- cgit v1.2.3 From 64e85ddcc57e2789627c4a4a7869424d7583dc17 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Fri, 26 Feb 2021 21:02:31 +0000 Subject: !int socketstats improvements - Comma separate event values - Make fields inline for smaller embed --- bot/exts/utils/internal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index a7ab43f37..d193e4d4f 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -245,7 +245,7 @@ async def func(): # (None,) -> Any ) for event_type, count in self.socket_events.most_common(25): - stats_embed.add_field(name=event_type, value=count, inline=False) + stats_embed.add_field(name=event_type, value=f"{count:,}", inline=True) await ctx.send(embed=stats_embed) -- cgit v1.2.3 From de226ea845e8f68735ce6d20193bece9f50b1d5f Mon Sep 17 00:00:00 2001 From: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> Date: Fri, 26 Feb 2021 22:24:50 +0100 Subject: Make "event" plural in socketstats embed --- bot/exts/utils/internal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index d193e4d4f..6f2da3131 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -240,7 +240,7 @@ async def func(): # (None,) -> Any stats_embed = discord.Embed( title="WebSocket statistics", - description=f"Receiving {per_s:0.2f} event per second.", + description=f"Receiving {per_s:0.2f} events per second.", color=discord.Color.blurple() ) -- cgit v1.2.3 From e82429c88f8643f8eaa89ea5541d0ffe860ec338 Mon Sep 17 00:00:00 2001 From: SavagePastaMan <69145546+SavagePastaMan@users.noreply.github.com> Date: Sat, 27 Feb 2021 09:54:50 -0500 Subject: Create comparison.md --- bot/resources/tags/comparison.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 bot/resources/tags/comparison.md diff --git a/bot/resources/tags/comparison.md b/bot/resources/tags/comparison.md new file mode 100644 index 000000000..12844bd2f --- /dev/null +++ b/bot/resources/tags/comparison.md @@ -0,0 +1,12 @@ +**Assignment vs. Comparison** + +The assignment operator (`=`) is used to assign variables. +```python +x = 5 +print(x) # Prints 5 +``` +The equality operator (`==`) is used to compare values. +```python +if x == 5: + print("The value of x is 5") +``` -- cgit v1.2.3 From c3a9e704080bfc670993b396d721ffd762348591 Mon Sep 17 00:00:00 2001 From: Bast Date: Mon, 1 Mar 2021 02:20:53 -0800 Subject: Add alias !u for !user --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 4499e4c25..88e904d03 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -202,7 +202,7 @@ class Information(Cog): await ctx.send(embed=embed) - @command(name="user", aliases=["user_info", "member", "member_info"]) + @command(name="user", aliases=["user_info", "member", "member_info", "u"]) async def user_info(self, ctx: Context, user: FetchedMember = None) -> None: """Returns info about a user.""" if user is None: -- cgit v1.2.3 From 5b31aa992db27cd1798e4dce5f1c4256aa8848fa Mon Sep 17 00:00:00 2001 From: Bast Date: Mon, 1 Mar 2021 02:22:59 -0800 Subject: Add alias !tban for !tempban --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 7349d65f2..406c6b53f 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -126,7 +126,7 @@ class Infractions(InfractionScheduler, commands.Cog): duration = await Duration().convert(ctx, "1h") await self.apply_mute(ctx, user, reason, expires_at=duration) - @command() + @command(aliases=("tban",)) async def tempban( self, ctx: Context, -- cgit v1.2.3 From 58c37361d0a322b308869492d50f2008ae497b3d Mon Sep 17 00:00:00 2001 From: Bast Date: Mon, 1 Mar 2021 02:23:46 -0800 Subject: Add !superstar and !unsuperstar aliases for !superstarify --- bot/exts/moderation/infraction/superstarify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index ffc470c54..704dddf9c 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -104,7 +104,7 @@ class Superstarify(InfractionScheduler, Cog): await self.reapply_infraction(infraction, action) - @command(name="superstarify", aliases=("force_nick", "star", "starify")) + @command(name="superstarify", aliases=("force_nick", "star", "starify", "superstar")) async def superstarify( self, ctx: Context, @@ -183,7 +183,7 @@ class Superstarify(InfractionScheduler, Cog): ) await ctx.send(embed=embed) - @command(name="unsuperstarify", aliases=("release_nick", "unstar", "unstarify")) + @command(name="unsuperstarify", aliases=("release_nick", "unstar", "unstarify", "unsuperstar")) async def unsuperstarify(self, ctx: Context, member: Member) -> None: """Remove the superstarify infraction and allow the user to change their nickname.""" await self.pardon_infraction(ctx, "superstar", member) -- cgit v1.2.3 From dec9a9dba77aa4322f9dc37b6493a8410e7482ec Mon Sep 17 00:00:00 2001 From: Bast Date: Mon, 1 Mar 2021 02:38:41 -0800 Subject: Add !stban alias for !shadowtempban --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 406c6b53f..3b5b1df45 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -198,7 +198,7 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Temporary shadow infractions - @command(hidden=True, aliases=["shadowtempban", "stempban"]) + @command(hidden=True, aliases=["shadowtempban", "stempban", "stban"]) async def shadow_tempban( self, ctx: Context, -- cgit v1.2.3