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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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