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