From 7e006192d9e8569c614e7466def59682b228c00c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 27 Sep 2020 09:23:59 +0300 Subject: Help: Add handling of disabled commands to avoid DisabledCommand error --- bot/exts/info/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 99d503f5c..5d83f8f86 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -174,7 +174,7 @@ class CustomHelpCommand(HelpCommand): command_details += f"**Can also use:** {aliases}\n\n" # check if the user is allowed to run this command - if not await command.can_run(self.context): + if not command.enabled or not await command.can_run(self.context): command_details += "***You cannot run this command.***\n\n" command_details += f"*{command.help or 'No details provided.'}*\n" -- cgit v1.2.3 From fe4eaeb182553f81afc7451c4aeaf1d865a9b18c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 28 Sep 2020 15:36:02 +0300 Subject: Help: Show different message for case when command is disabled --- bot/exts/info/help.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 5d83f8f86..803fcb2bb 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -173,8 +173,11 @@ class CustomHelpCommand(HelpCommand): if aliases: command_details += f"**Can also use:** {aliases}\n\n" - # check if the user is allowed to run this command - if not command.enabled or not await command.can_run(self.context): + # when command is disabled, show message about it + # otherwise check if the user is allowed to run this command + if not command.enabled: + command_details += "***This command is disabled.***\n\n" + elif not await command.can_run(self.context): command_details += "***You cannot run this command.***\n\n" command_details += f"*{command.help or 'No details provided.'}*\n" -- cgit v1.2.3 From 929e8352553bbe90a196548c9afabd0ef63bd98e Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 1 Oct 2020 12:55:00 +0800 Subject: Fuzzy match roles for `!role` command. An arbitrary cutoff score of 80 is chosen because it works. A bug in the test for the same command is also fixed. --- bot/exts/info/information.py | 10 +++++++--- tests/bot/exts/info/test_information.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f6ed176f1..0386a2909 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,7 +6,8 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils +import fuzzywuzzy +from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from discord.utils import escape_markdown @@ -108,18 +109,21 @@ class Information(Cog): parsed_roles = [] failed_roles = [] + all_roles = {role.id: role.name for role in ctx.guild.roles} for role_name in roles: if isinstance(role_name, Role): # Role conversion has already succeeded parsed_roles.append(role_name) continue - role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) + match = fuzzywuzzy.process.extractOne(role_name, all_roles, score_cutoff=80) - if not role: + if not match: failed_roles.append(role_name) continue + # `match` is a (role name, score, role id) tuple + role = ctx.guild.get_role(match[2]) parsed_roles.append(role) if failed_roles: diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d3f2995fb..7bc7dbb5d 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -68,7 +68,7 @@ class InformationCogTests(unittest.TestCase): permissions=discord.Permissions(0), ) - self.ctx.guild.roles.append([dummy_role, admin_role]) + self.ctx.guild.roles.extend([dummy_role, admin_role]) self.cog.role_info.can_run = unittest.mock.AsyncMock() self.cog.role_info.can_run.return_value = True -- cgit v1.2.3 From 063fb4cbed635e91404e3be833629d5ad1cdf136 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 1 Oct 2020 13:08:55 +0800 Subject: Use basic scorer to fuzz a bit stricter. This prevents weird fuzz matches like `!role a b c d` working. --- bot/exts/info/information.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 0386a2909..bfc05cea9 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -116,7 +116,10 @@ class Information(Cog): parsed_roles.append(role_name) continue - match = fuzzywuzzy.process.extractOne(role_name, all_roles, score_cutoff=80) + match = fuzzywuzzy.process.extractOne( + role_name, all_roles, score_cutoff=80, + scorer=fuzzywuzzy.fuzz.ratio + ) if not match: failed_roles.append(role_name) -- cgit v1.2.3 From c57b9dd7db59ed39ff1fb2fe99ba515bf3e51815 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 1 Oct 2020 13:11:02 +0800 Subject: Avoid duplicate roles. --- bot/exts/info/information.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index bfc05cea9..0f074c45d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -106,14 +106,14 @@ class Information(Cog): To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. """ - parsed_roles = [] - failed_roles = [] + parsed_roles = set() + failed_roles = set() all_roles = {role.id: role.name for role in ctx.guild.roles} for role_name in roles: if isinstance(role_name, Role): # Role conversion has already succeeded - parsed_roles.append(role_name) + parsed_roles.add(role_name) continue match = fuzzywuzzy.process.extractOne( @@ -122,12 +122,12 @@ class Information(Cog): ) if not match: - failed_roles.append(role_name) + failed_roles.add(role_name) continue # `match` is a (role name, score, role id) tuple role = ctx.guild.get_role(match[2]) - parsed_roles.append(role) + parsed_roles.add(role) if failed_roles: await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}") -- cgit v1.2.3 From d2c1b270cebfcca5f081daceeda3dadfa28313e1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 7 Nov 2020 18:45:51 +0200 Subject: Catch CommandError for help command can_run await --- bot/exts/info/help.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 5e0651f8a..44bff1f88 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -5,7 +5,7 @@ from contextlib import suppress from typing import List, Union from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand +from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand, DisabledCommand, CommandError from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process @@ -173,12 +173,16 @@ class CustomHelpCommand(HelpCommand): if aliases: command_details += f"**Can also use:** {aliases}\n\n" - # when command is disabled, show message about it + # when command is disabled, show message about it, + # when other CommandError instance is raised, log warning about it # otherwise check if the user is allowed to run this command - if not command.enabled: + try: + if not await command.can_run(self.context): + command_details += "***You cannot run this command.***\n\n" + except DisabledCommand: command_details += "***This command is disabled.***\n\n" - elif not await command.can_run(self.context): - command_details += "***You cannot run this command.***\n\n" + except CommandError as e: + log.warning(f"An exception raised when trying to check {command.name} command running permission: {e}") command_details += f"*{command.help or 'No details provided.'}*\n" embed.description = command_details -- cgit v1.2.3 From 0390bb8936d5c8aaeb9f5ddf5fccc9970ae18cad Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 7 Nov 2020 19:39:03 +0200 Subject: Fix import order of help command file --- bot/exts/info/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 44bff1f88..9d7f0702e 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -5,7 +5,7 @@ from contextlib import suppress from typing import List, Union from discord import Colour, Embed -from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand, DisabledCommand, CommandError +from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process -- cgit v1.2.3 From 6ff9a5e1500805f98ee136757192c868fa17c764 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 21 Nov 2020 09:37:08 +0200 Subject: Don't log CommandError, handle it as check fail --- bot/exts/info/help.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 9d7f0702e..518330370 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -181,8 +181,8 @@ class CustomHelpCommand(HelpCommand): command_details += "***You cannot run this command.***\n\n" except DisabledCommand: command_details += "***This command is disabled.***\n\n" - except CommandError as e: - log.warning(f"An exception raised when trying to check {command.name} command running permission: {e}") + except CommandError: + command_details += "***You cannot run this command.***\n\n" command_details += f"*{command.help or 'No details provided.'}*\n" embed.description = command_details -- cgit v1.2.3 From 85a115ae4c029397c8cefee6262e866d264fd681 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 2 Dec 2020 16:36:30 +0200 Subject: Move not allowed to run message to constant and match comment with code --- bot/exts/info/help.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 518330370..6c262e355 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -20,6 +20,8 @@ log = logging.getLogger(__name__) COMMANDS_PER_PAGE = 8 PREFIX = constants.Bot.prefix +NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n" + Category = namedtuple("Category", ["name", "description", "cogs"]) @@ -174,15 +176,15 @@ class CustomHelpCommand(HelpCommand): command_details += f"**Can also use:** {aliases}\n\n" # when command is disabled, show message about it, - # when other CommandError instance is raised, log warning about it - # otherwise check if the user is allowed to run this command + # when other CommandError or user is not allowed to run command, + # add this to help message. try: if not await command.can_run(self.context): - command_details += "***You cannot run this command.***\n\n" + command_details += NOT_ALLOWED_TO_RUN_MESSAGE except DisabledCommand: command_details += "***This command is disabled.***\n\n" except CommandError: - command_details += "***You cannot run this command.***\n\n" + command_details += NOT_ALLOWED_TO_RUN_MESSAGE command_details += f"*{command.help or 'No details provided.'}*\n" embed.description = command_details -- cgit v1.2.3 From 4dad590ef87a3e67e82b3a43a2ab113efa86abbe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 10:58:16 -0800 Subject: HelpChannels: use a more accurate lock for messages Use the `lock_arg` decorator to keep a separate lock for each channel rather than a single lock used by all messages. Separate the core logic in `on_message` into a separate function to facilitate the use of `lock_arg` - not everything in `on_message` needs to be under the lock. --- bot/exts/help_channels/_cog.py | 82 ++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 983c5d183..76c671641 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -3,6 +3,7 @@ import logging import random import typing as t from datetime import datetime, timezone +from operator import attrgetter import discord import discord.abc @@ -11,11 +12,12 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name -from bot.utils import channel as channel_utils +from bot.utils import channel as channel_utils, lock from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +NAMESPACE = "help" HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ @@ -73,7 +75,6 @@ class HelpChannels(commands.Cog): # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] - self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) def cog_unload(self) -> None: @@ -87,6 +88,31 @@ class HelpChannels(commands.Cog): self.scheduler.cancel_all() + @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) + async def claim_channel(self, message: discord.Message) -> None: + """ + Claim the channel in which the question `message` was sent. + + Move the channel to the In Use category and pin the `message`. Add a cooldown to the + claimant to prevent them from asking another question. + """ + log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") + await self.move_to_in_use(message.channel) + await _cooldown.revoke_send_permissions(message.author, self.scheduler) + + await _message.pin(message) + + # Add user with channel for dormant check. + await _caches.claimants.set(message.channel.id, message.author.id) + + self.bot.stats.incr("help.claimed") + + # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. + timestamp = datetime.now(timezone.utc).timestamp() + await _caches.claim_times.set(message.channel.id, timestamp) + + await _caches.unanswered.set(message.channel.id, True) + def create_channel_queue(self) -> asyncio.Queue: """ Return a queue of dormant channels to use for getting the next available channel. @@ -436,51 +462,13 @@ class HelpChannels(commands.Cog): if message.author.bot: return # Ignore messages sent by bots. - channel = message.channel - - await _message.check_for_answer(message) - - is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or _channel.is_excluded_channel(channel): - return # Ignore messages outside the Available category or in excluded channels. - - log.trace("Waiting for the cog to be ready before processing messages.") - await self.init_task - - log.trace("Acquiring lock to prevent a channel from being processed twice...") - async with self.on_message_lock: - log.trace(f"on_message lock acquired for {message.id}.") - - if not channel_utils.is_in_category(channel, constants.Categories.help_available): - log.debug( - f"Message {message.id} will not make #{channel} ({channel.id}) in-use " - f"because another message in the channel already triggered that." - ) - return - - log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") - await self.move_to_in_use(channel) - await _cooldown.revoke_send_permissions(message.author, self.scheduler) - - await _message.pin(message) - - # Add user with channel for dormant check. - await _caches.claimants.set(channel.id, message.author.id) - - self.bot.stats.incr("help.claimed") - - # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. - timestamp = datetime.now(timezone.utc).timestamp() - await _caches.claim_times.set(channel.id, timestamp) - - await _caches.unanswered.set(channel.id, True) - - log.trace(f"Releasing on_message lock for {message.id}.") - - # Move a dormant channel to the Available category to fill in the gap. - # This is done last and outside the lock because it may wait indefinitely for a channel to - # be put in the queue. - await self.move_to_available() + if channel_utils.is_in_category(message.channel, constants.Categories.help_available): + if not _channel.is_excluded_channel(message.channel): + await self.init_task + await self.claim_channel(message) + await self.move_to_available() # Not in a lock because it may wait indefinitely. + else: + await _message.check_for_answer(message) @commands.Cog.listener() async def on_message_delete(self, msg: discord.Message) -> None: -- cgit v1.2.3 From 025edaa3e5b0774401ab83e925c830928e54e0fe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 11:07:27 -0800 Subject: HelpChannels: await init task at the start of event listeners It feels safer to do this since the init task moves channels to different categories and the listeners check if channels are in certain categories. --- bot/exts/help_channels/_cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 76c671641..a9ca01cdd 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -462,9 +462,10 @@ class HelpChannels(commands.Cog): if message.author.bot: return # Ignore messages sent by bots. + await self.init_task + if channel_utils.is_in_category(message.channel, constants.Categories.help_available): if not _channel.is_excluded_channel(message.channel): - await self.init_task await self.claim_channel(message) await self.move_to_available() # Not in a lock because it may wait indefinitely. else: @@ -477,15 +478,14 @@ class HelpChannels(commands.Cog): The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ + await self.init_task + if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return if not await _message.is_empty(msg.channel): return - log.trace("Waiting for the cog to be ready before processing deleted messages.") - await self.init_task - log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") # Cancel existing dormant task before scheduling new. -- cgit v1.2.3 From 0d330d7470a97660e91280979d064983f7716ecf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 11:21:13 -0800 Subject: HelpChannels: prevent user from claiming multiple channels quickly It's conceivable for a user to be able to quickly send a message in all available channels before the code has a chance to add the cooldown role. Place a lock on the author to prevent the claim code from running multiple times for the same user. --- bot/exts/help_channels/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a9ca01cdd..5695d0d05 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -89,6 +89,7 @@ class HelpChannels(commands.Cog): self.scheduler.cancel_all() @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) + @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) async def claim_channel(self, message: discord.Message) -> None: """ Claim the channel in which the question `message` was sent. -- cgit v1.2.3 From e281bb4981cd16cdc716c2fb28b470c310700ab9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 11:36:13 -0800 Subject: HelpChannels: move function to the channel module --- bot/exts/help_channels/_channel.py | 42 +++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_cog.py | 45 +++----------------------------------- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index e717d7af8..224214b00 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -4,8 +4,10 @@ from datetime import datetime, timedelta import discord +import bot from bot import constants from bot.exts.help_channels import _caches, _message +from bot.utils.channel import try_get_channel log = logging.getLogger(__name__) @@ -55,3 +57,43 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: """Check if a channel should be excluded from the help channel system.""" return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + + +async def move_to_bottom(channel: discord.TextChannel, category_id: int, **options) -> None: + """ + Move the `channel` to the bottom position of `category` and edit channel attributes. + + To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current + positions of the other channels in the category as-is. This should make sure that the channel + really ends up at the bottom of the category. + + If `options` are provided, the channel will be edited after the move is completed. This is the + same order of operations that `discord.TextChannel.edit` uses. For information on available + options, see the documentation on `discord.TextChannel.edit`. While possible, position-related + options should be avoided, as it may interfere with the category move we perform. + """ + # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. + category = await try_get_channel(category_id) + + payload = [{"id": c.id, "position": c.position} for c in category.channels] + + # Calculate the bottom position based on the current highest position in the category. If the + # category is currently empty, we simply use the current position of the channel to avoid making + # unnecessary changes to positions in the guild. + bottom_position = payload[-1]["position"] + 1 if payload else channel.position + + payload.append( + { + "id": channel.id, + "position": bottom_position, + "parent_id": category.id, + "lock_permissions": True, + } + ) + + # We use d.py's method to ensure our request is processed by d.py's rate limit manager + await bot.instance.http.bulk_channel_update(category.guild.id, payload) + + # Now that the channel is moved, we can edit the other attributes + if options: + await channel.edit(**options) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5695d0d05..f25bf132c 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -325,45 +325,6 @@ class HelpChannels(commands.Cog): self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) - async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: - """ - Move the `channel` to the bottom position of `category` and edit channel attributes. - - To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current - positions of the other channels in the category as-is. This should make sure that the channel - really ends up at the bottom of the category. - - If `options` are provided, the channel will be edited after the move is completed. This is the - same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documentation on `discord.TextChannel.edit`. While possible, position-related - options should be avoided, as it may interfere with the category move we perform. - """ - # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await channel_utils.try_get_channel(category_id) - - payload = [{"id": c.id, "position": c.position} for c in category.channels] - - # Calculate the bottom position based on the current highest position in the category. If the - # category is currently empty, we simply use the current position of the channel to avoid making - # unnecessary changes to positions in the guild. - bottom_position = payload[-1]["position"] + 1 if payload else channel.position - - payload.append( - { - "id": channel.id, - "position": bottom_position, - "parent_id": category.id, - "lock_permissions": True, - } - ) - - # We use d.py's method to ensure our request is processed by d.py's rate limit manager - await self.bot.http.bulk_channel_update(category.guild.id, payload) - - # Now that the channel is moved, we can edit the other attributes - if options: - await channel.edit(**options) - async def move_to_available(self) -> None: """Make a channel available.""" log.trace("Making a channel available.") @@ -375,7 +336,7 @@ class HelpChannels(commands.Cog): log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") - await self.move_to_bottom_position( + await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_available, ) @@ -390,7 +351,7 @@ class HelpChannels(commands.Cog): """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await self.move_to_bottom_position( + await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_dormant, ) @@ -446,7 +407,7 @@ class HelpChannels(commands.Cog): """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") - await self.move_to_bottom_position( + await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_in_use, ) -- cgit v1.2.3 From 46973747f5f0dec03e3ea0f87ea07ebbda6b07da Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 12:06:15 -0800 Subject: HelpChannels: refactor stat tracking Move significant code related to stats to a separate module. --- bot/exts/help_channels/_cog.py | 33 ++++++-------------------------- bot/exts/help_channels/_stats.py | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 27 deletions(-) create mode 100644 bot/exts/help_channels/_stats.py diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index f25bf132c..854696f87 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,7 +11,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name +from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats from bot.utils import channel as channel_utils, lock from bot.utils.scheduling import Scheduler @@ -275,20 +275,10 @@ class HelpChannels(commands.Cog): self.close_command.enabled = True await self.init_available() - self.report_stats() + _stats.report_counts() log.info("Cog is ready!") - def report_stats(self) -> None: - """Report the channel count stats.""" - total_in_use = sum(1 for _ in _channel.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in _channel.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in _channel.get_category_channels(self.dormant_category)) - - self.bot.stats.gauge("help.total.in_use", total_in_use) - self.bot.stats.gauge("help.total.available", total_available) - self.bot.stats.gauge("help.total.dormant", total_dormant) - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -341,7 +331,7 @@ class HelpChannels(commands.Cog): category_id=constants.Categories.help_available, ) - self.report_stats() + _stats.report_counts() async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: """ @@ -357,18 +347,7 @@ class HelpChannels(commands.Cog): ) await self.unclaim_channel(channel) - - self.bot.stats.incr(f"help.dormant_calls.{caller}") - - in_use_time = await _channel.get_in_use_time(channel.id) - if in_use_time: - self.bot.stats.timing("help.in_use_time", in_use_time) - - unanswered = await _caches.unanswered.get(channel.id) - if unanswered: - self.bot.stats.incr("help.sessions.unanswered") - elif unanswered is not None: - self.bot.stats.incr("help.sessions.answered") + await _stats.report_complete_session(channel.id, caller) log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") @@ -379,7 +358,7 @@ class HelpChannels(commands.Cog): log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) - self.report_stats() + _stats.report_counts() async def unclaim_channel(self, channel: discord.TextChannel) -> None: """ @@ -416,7 +395,7 @@ class HelpChannels(commands.Cog): log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) - self.report_stats() + _stats.report_counts() @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py new file mode 100644 index 000000000..8e6ff8fe5 --- /dev/null +++ b/bot/exts/help_channels/_stats.py @@ -0,0 +1,41 @@ +import logging + +from more_itertools import ilen + +import bot +from bot import constants +from bot.exts.help_channels import _caches, _channel + +log = logging.getLogger(__name__) + + +def report_counts() -> None: + """Report channel count stats of each help category.""" + for name in ("in_use", "available", "dormant"): + id_ = getattr(constants.Categories, f"help_{name}") + category = bot.instance.get_channel(id_) + + if category: + total = ilen(_channel.get_category_channels(category)) + bot.instance.stats.gauge(f"help.total.{name}", total) + else: + log.warning(f"Couldn't find category {name!r} to track channel count stats.") + + +async def report_complete_session(channel_id: int, caller: str) -> None: + """ + Report stats for a completed help session channel `channel_id`. + + `caller` is used to track stats on how `channel_id` was unclaimed (either 'auto' or 'command'). + """ + bot.instance.stats.incr(f"help.dormant_calls.{caller}") + + in_use_time = await _channel.get_in_use_time(channel_id) + if in_use_time: + bot.instance.stats.timing("help.in_use_time", in_use_time) + + unanswered = await _caches.unanswered.get(channel_id) + if unanswered: + bot.instance.stats.incr("help.sessions.unanswered") + elif unanswered is not None: + bot.instance.stats.incr("help.sessions.answered") -- cgit v1.2.3 From 573839ba58646ec49444087c6477be502553c060 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 12:11:13 -0800 Subject: HelpChannels: remove obsolete channel position trace log --- bot/exts/help_channels/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 854696f87..026832d46 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -349,7 +349,6 @@ class HelpChannels(commands.Cog): await self.unclaim_channel(channel) await _stats.report_complete_session(channel.id, caller) - log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") embed = discord.Embed(description=_message.DORMANT_MSG) await channel.send(embed=embed) -- cgit v1.2.3 From 6dbeecd01acae5661502755ed28949a4ece2b687 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 12:25:29 -0800 Subject: HelpChannels: refactor channel unclaiming Narrow the scope of `move_to_dormant` to just moving the channel. Following the design of `claim_channel`, make `unclaim_channel` handle cooldowns and unpinning. --- bot/exts/help_channels/_cog.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 026832d46..bc42b5c2a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -181,7 +181,7 @@ class HelpChannels(commands.Cog): return if await self.dormant_check(ctx): - await self.move_to_dormant(ctx.channel, "command") + await self.unclaim_channel(ctx.channel, "command") self.scheduler.cancel(ctx.channel.id) async def get_available_candidate(self) -> discord.TextChannel: @@ -228,7 +228,7 @@ class HelpChannels(commands.Cog): elif missing < 0: log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") for channel in channels[:abs(missing)]: - await self.move_to_dormant(channel, "auto") + await self.unclaim_channel(channel, "auto") async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" @@ -301,7 +301,7 @@ class HelpChannels(commands.Cog): f"and will be made dormant." ) - await self.move_to_dormant(channel, "auto") + await self.unclaim_channel(channel, "auto") else: # Cancel the existing task, if any. if has_task: @@ -333,42 +333,36 @@ class HelpChannels(commands.Cog): _stats.report_counts() - async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: - """ - Make the `channel` dormant. - - A caller argument is provided for metrics. - """ + async def move_to_dormant(self, channel: discord.TextChannel) -> None: + """Make the `channel` dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_dormant, ) - await self.unclaim_channel(channel) - await _stats.report_complete_session(channel.id, caller) - log.trace(f"Sending dormant message for #{channel} ({channel.id}).") embed = discord.Embed(description=_message.DORMANT_MSG) await channel.send(embed=embed) - await _message.unpin(channel) - log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) + _stats.report_counts() - async def unclaim_channel(self, channel: discord.TextChannel) -> None: + async def unclaim_channel(self, channel: discord.TextChannel, caller: str) -> None: """ - Mark the channel as unclaimed and remove the cooldown role from the claimant if needed. + Unclaim an in-use help `channel` to make it dormant. - The role is only removed if they have no claimed channels left once the current one is unclaimed. - This method also handles canceling the automatic removal of the cooldown role. + Unpin the claimant's question message and move the channel to the Dormant category. + Remove the cooldown role from the channel claimant if they have no other channels claimed. + Cancel the scheduled cooldown role removal task. + + `caller` is used to track stats on how `channel` was unclaimed (either 'auto' or 'command'). """ claimant_id = await _caches.claimants.pop(channel.id) - # Ignore missing task when cooldown has passed but the channel still isn't dormant. + # Ignore missing tasks because a channel may still be dormant after the cooldown expires. if claimant_id in self.scheduler: self.scheduler.cancel(claimant_id) @@ -381,6 +375,11 @@ class HelpChannels(commands.Cog): if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): await _cooldown.remove_cooldown_role(claimant) + await _message.unpin(channel) + await _stats.report_complete_session(channel.id, caller) + + await self.move_to_dormant(channel) + async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -- cgit v1.2.3 From 361e34529c1dc6c88620842ba92226ce7c87a6e1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 15:01:39 -0800 Subject: Lock: support waiting until a lock is available It's been determined that asyncio.Lock is safe to use in such manner. Therefore, replace LockGuard entirely with asyncio.Lock. --- bot/utils/lock.py | 62 ++++++++++++++++++++++--------------------------------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 7aaafbc88..e44776340 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,3 +1,4 @@ +import asyncio import inspect import logging from collections import defaultdict @@ -16,39 +17,21 @@ _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] ResourceId = Union[Hashable, _IdCallable] -class LockGuard: - """ - A context manager which acquires and releases a lock (mutex). - - Raise RuntimeError if trying to acquire a locked lock. - """ - - def __init__(self): - self._locked = False - - @property - def locked(self) -> bool: - """Return True if currently locked or False if unlocked.""" - return self._locked - - def __enter__(self): - if self._locked: - raise RuntimeError("Cannot acquire a locked lock.") - - self._locked = True - - def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001 - self._locked = False - return False # Indicate any raised exception shouldn't be suppressed. - - -def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable: +def lock( + namespace: Hashable, + resource_id: ResourceId, + *, + raise_error: bool = False, + wait: bool = False, +) -> Callable: """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. - If any other mutually exclusive function currently holds the lock for a resource, do not run the - decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if - the lock cannot be acquired. + If `wait` is True, wait until the lock becomes available. Otherwise, if any other mutually + exclusive function currently holds the lock for a resource, do not run the decorated function + and return None. + + If `raise_error` is True, raise `LockedResourceError` if the lock cannot be acquired. `namespace` is an identifier used to prevent collisions among resource IDs. @@ -78,15 +61,19 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa else: id_ = resource_id - log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + log.trace(f"{name}: getting the lock object for resource {namespace!r}:{id_!r}") # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock_guard = locks.setdefault(id_, LockGuard()) - - if not lock_guard.locked: - log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - with lock_guard: + lock_ = locks.setdefault(id_, asyncio.Lock()) + + # It's safe to check an asyncio.Lock is free before acquiring it because: + # 1. Synchronous code like `if not lock_.locked()` does not yield execution + # 2. `asyncio.Lock.acquire()` does not internally await anything if the lock is free + # 3. awaits only yield execution to the event loop at actual I/O boundaries + if wait or not lock_.locked(): + log.debug(f"{name}: acquiring lock for resource {namespace!r}:{id_!r}...") + async with lock_: return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") @@ -103,6 +90,7 @@ def lock_arg( func: Callable[[Any], _IdCallableReturn] = None, *, raise_error: bool = False, + wait: bool = False, ) -> Callable: """ Apply the `lock` decorator using the value of the arg at the given name/position as the ID. @@ -110,5 +98,5 @@ def lock_arg( `func` is an optional callable or awaitable which will return the ID given the argument value. See `lock` docs for more information. """ - decorator_func = partial(lock, namespace, raise_error=raise_error) + decorator_func = partial(lock, namespace, raise_error=raise_error, wait=wait) return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) -- cgit v1.2.3 From 35de5fbe64624091c0742c28f811a0ea6da7cd4a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 15:52:22 -0800 Subject: HelpChannels: fix race condition between claiming and unclaiming The race condition is when a user claims a channel while their other channel is being unclaimed. Specifically, it's while their cooldown is being removed. The lock ensures that either the cooldown will be re-applied after it's removed or that it won't be removed since `unclaim_channel` will see the user has another claimed channel. --- bot/exts/help_channels/_cog.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index bc42b5c2a..7ebec675a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -90,6 +90,7 @@ class HelpChannels(commands.Cog): @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) + @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True) async def claim_channel(self, message: discord.Message) -> None: """ Claim the channel in which the question `message` was sent. @@ -360,7 +361,20 @@ class HelpChannels(commands.Cog): `caller` is used to track stats on how `channel` was unclaimed (either 'auto' or 'command'). """ - claimant_id = await _caches.claimants.pop(channel.id) + claimant_id = await _caches.claimants.get(channel.id) + coroutine = self._unclaim_channel(channel, claimant_id, caller) + + # It could be possible that there is no claimant cached. In such case, it'd be useless and + # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. + if claimant_id is not None: + decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True) + coroutine = decorator(coroutine) + + return await coroutine + + async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, caller: str) -> None: + """Actual implementation of `unclaim_channel`. See that for full documentation.""" + await _caches.claimants.delete(channel.id) # Ignore missing tasks because a channel may still be dormant after the cooldown expires. if claimant_id in self.scheduler: -- cgit v1.2.3 From 918cdcebf97b754d0c72900503bd3aef96fe9dac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 16:52:10 -0800 Subject: Add asyncio.create_task wrapper which logs exceptions Normally exceptions are only logged when tasks are garbage collected. This wrapper will allow them to be logged immediately through a done callback. This is similar to how the Scheduler logs errors. --- bot/utils/scheduling.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 03f31d78f..4dd036e4f 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -155,3 +155,20 @@ class Scheduler: # Log the exception if one exists. if exception: self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) + + +def create_task(*args, **kwargs) -> asyncio.Task: + """Wrapper for `asyncio.create_task` which logs exceptions raised in the task.""" + task = asyncio.create_task(*args, **kwargs) + task.add_done_callback(_log_task_exception) + return task + + +def _log_task_exception(task: asyncio.Task) -> None: + """Retrieve and log the exception raised in `task` if one exists.""" + with contextlib.suppress(asyncio.CancelledError): + exception = task.exception() + # Log the exception if one exists. + if exception: + log = logging.getLogger(__name__) + log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception) -- cgit v1.2.3 From 180a2116eeca5fbabe81506a4a98f4569138be78 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 17:04:55 -0800 Subject: HelpChannels: make a channel available within the lock If the lock causes the function to abort, a new channel shouldn't be made available. However, the only way to know it's aborted from the outside would be through a return value or global variable. Neither seem as nice as just just using `create_task` within the lock to avoid having `move_to_available` hold the lock. --- bot/exts/help_channels/_cog.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 7ebec675a..e99dd92db 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,8 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats -from bot.utils import channel as channel_utils, lock -from bot.utils.scheduling import Scheduler +from bot.utils import channel as channel_utils, lock, scheduling log = logging.getLogger(__name__) @@ -60,7 +59,7 @@ class HelpChannels(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) + self.scheduler = scheduling.Scheduler(self.__class__.__name__) # Categories self.available_category: discord.CategoryChannel = None @@ -96,7 +95,7 @@ class HelpChannels(commands.Cog): Claim the channel in which the question `message` was sent. Move the channel to the In Use category and pin the `message`. Add a cooldown to the - claimant to prevent them from asking another question. + claimant to prevent them from asking another question. Lastly, make a new channel available. """ log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(message.channel) @@ -115,6 +114,9 @@ class HelpChannels(commands.Cog): await _caches.unanswered.set(message.channel.id, True) + # Not awaited because it may indefinitely hold the lock while waiting for a channel. + scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") + def create_channel_queue(self) -> asyncio.Queue: """ Return a queue of dormant channels to use for getting the next available channel. @@ -420,7 +422,6 @@ class HelpChannels(commands.Cog): if channel_utils.is_in_category(message.channel, constants.Categories.help_available): if not _channel.is_excluded_channel(message.channel): await self.claim_channel(message) - await self.move_to_available() # Not in a lock because it may wait indefinitely. else: await _message.check_for_answer(message) -- cgit v1.2.3 From d98a34ff042418c93b50f4128d16a0847b479083 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:07:22 -0800 Subject: HelpChannels: refactor the close command check --- bot/exts/help_channels/_cog.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e99dd92db..a15e6295e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -154,8 +154,12 @@ class HelpChannels(commands.Cog): log.debug(f"Creating a new dormant channel named {name}.") return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - async def dormant_check(self, ctx: commands.Context) -> bool: - """Return True if the user is the help channel claimant or passes the role check.""" + async def close_check(self, ctx: commands.Context) -> bool: + """Return True if the channel is in use and the user is the claimant or has a whitelisted role.""" + if ctx.channel.category != self.in_use_category: + log.debug(f"{ctx.author} invoked command 'close' outside an in-use help channel") + return False + if await _caches.claimants.get(ctx.channel.id) == ctx.author.id: log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") self.bot.stats.incr("help.dormant_invoke.claimant") @@ -174,16 +178,12 @@ class HelpChannels(commands.Cog): """ Make the current in-use help channel dormant. - Make the channel dormant if the user passes the `dormant_check`, + Make the channel dormant if the user passes the `close_check`, delete the message that invoked this. """ - log.trace("close command invoked; checking if the channel is in-use.") - - if ctx.channel.category != self.in_use_category: - log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") - return - - if await self.dormant_check(ctx): + # Don't use a discord.py check because the check needs to fail silently. + if await self.close_check(ctx): + log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") await self.unclaim_channel(ctx.channel, "command") self.scheduler.cancel(ctx.channel.id) -- cgit v1.2.3 From 6b60dbcdd695220d2e02349d4707197253095639 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:11:49 -0800 Subject: HelpChannels: clarify close command docstring Referencing internal functions in public-facing documentation is not helpful to users. --- bot/exts/help_channels/_cog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a15e6295e..2b7cfcba7 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -178,8 +178,7 @@ class HelpChannels(commands.Cog): """ Make the current in-use help channel dormant. - Make the channel dormant if the user passes the `close_check`, - delete the message that invoked this. + May only be invoked by the channel's claimant or by staff. """ # Don't use a discord.py check because the check needs to fail silently. if await self.close_check(ctx): -- cgit v1.2.3 From 6b02c5cb7c4792e9b7641a80965aa56e43ee6e2a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:22:04 -0800 Subject: HelpChannels: unschedule the dormant task in unclaim_channel Ensure the cancellation will be under the lock once the lock is added. --- bot/exts/help_channels/_cog.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 2b7cfcba7..9d80e193e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -184,7 +184,6 @@ class HelpChannels(commands.Cog): if await self.close_check(ctx): log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") await self.unclaim_channel(ctx.channel, "command") - self.scheduler.cancel(ctx.channel.id) async def get_available_candidate(self) -> discord.TextChannel: """ @@ -392,9 +391,13 @@ class HelpChannels(commands.Cog): await _message.unpin(channel) await _stats.report_complete_session(channel.id, caller) - await self.move_to_dormant(channel) + # Cancel the task that makes the channel dormant only if called by the close command. + # In other cases, the task is either already done or not-existent. + if caller == "command": + self.scheduler.cancel(channel.id) + async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") -- cgit v1.2.3 From 3fb3d915bedee9dc3daed11fe2a39588b22fedf9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:29:20 -0800 Subject: HelpChannels: change caller str parameter to a bool Booleans are less error-prone than strings. --- bot/exts/help_channels/_cog.py | 18 +++++++++--------- bot/exts/help_channels/_stats.py | 5 +++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 9d80e193e..bea5fd9c0 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -183,7 +183,7 @@ class HelpChannels(commands.Cog): # Don't use a discord.py check because the check needs to fail silently. if await self.close_check(ctx): log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") - await self.unclaim_channel(ctx.channel, "command") + await self.unclaim_channel(ctx.channel, is_auto=False) async def get_available_candidate(self) -> discord.TextChannel: """ @@ -229,7 +229,7 @@ class HelpChannels(commands.Cog): elif missing < 0: log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") for channel in channels[:abs(missing)]: - await self.unclaim_channel(channel, "auto") + await self.unclaim_channel(channel) async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" @@ -302,7 +302,7 @@ class HelpChannels(commands.Cog): f"and will be made dormant." ) - await self.unclaim_channel(channel, "auto") + await self.unclaim_channel(channel) else: # Cancel the existing task, if any. if has_task: @@ -351,7 +351,7 @@ class HelpChannels(commands.Cog): _stats.report_counts() - async def unclaim_channel(self, channel: discord.TextChannel, caller: str) -> None: + async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None: """ Unclaim an in-use help `channel` to make it dormant. @@ -359,10 +359,10 @@ class HelpChannels(commands.Cog): Remove the cooldown role from the channel claimant if they have no other channels claimed. Cancel the scheduled cooldown role removal task. - `caller` is used to track stats on how `channel` was unclaimed (either 'auto' or 'command'). + Set `is_auto` to True if the channel was automatically closed or False if manually closed. """ claimant_id = await _caches.claimants.get(channel.id) - coroutine = self._unclaim_channel(channel, claimant_id, caller) + coroutine = self._unclaim_channel(channel, claimant_id, is_auto) # It could be possible that there is no claimant cached. In such case, it'd be useless and # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. @@ -372,7 +372,7 @@ class HelpChannels(commands.Cog): return await coroutine - async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, caller: str) -> None: + async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) @@ -390,12 +390,12 @@ class HelpChannels(commands.Cog): await _cooldown.remove_cooldown_role(claimant) await _message.unpin(channel) - await _stats.report_complete_session(channel.id, caller) + await _stats.report_complete_session(channel.id, is_auto) await self.move_to_dormant(channel) # Cancel the task that makes the channel dormant only if called by the close command. # In other cases, the task is either already done or not-existent. - if caller == "command": + if not is_auto: self.scheduler.cancel(channel.id) async def move_to_in_use(self, channel: discord.TextChannel) -> None: diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py index 8e6ff8fe5..b8778e7d9 100644 --- a/bot/exts/help_channels/_stats.py +++ b/bot/exts/help_channels/_stats.py @@ -22,12 +22,13 @@ def report_counts() -> None: log.warning(f"Couldn't find category {name!r} to track channel count stats.") -async def report_complete_session(channel_id: int, caller: str) -> None: +async def report_complete_session(channel_id: int, is_auto: bool) -> None: """ Report stats for a completed help session channel `channel_id`. - `caller` is used to track stats on how `channel_id` was unclaimed (either 'auto' or 'command'). + Set `is_auto` to True if the channel was automatically closed or False if manually closed. """ + caller = "auto" if is_auto else "command" bot.instance.stats.incr(f"help.dormant_calls.{caller}") in_use_time = await _channel.get_in_use_time(channel_id) -- cgit v1.2.3 From e297fb26a5f476050729a93f32d871e46ea1316d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 Jan 2021 20:47:37 -0800 Subject: HelpChannels: fix race condition when unclaiming a channel Place a channel-specific lock on `unclaim_channel`. If both the dormant task and the command simultaneously unclaim a channel, one of them will silently be aborted. Fix #1341 --- bot/exts/help_channels/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index bea5fd9c0..f866e98af 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -351,6 +351,7 @@ class HelpChannels(commands.Cog): _stats.report_counts() + @lock.lock_arg(f"{NAMESPACE}.unclaim", "channel") async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None: """ Unclaim an in-use help `channel` to make it dormant. -- cgit v1.2.3 From 8d50a090baa1f2b64c9ba4e9f1830c5b2a2b80a0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 Jan 2021 17:10:59 -0800 Subject: HelpChannels: fix manual use of lock decorator --- bot/exts/help_channels/_cog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index f866e98af..3bdd896f2 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -363,15 +363,15 @@ class HelpChannels(commands.Cog): Set `is_auto` to True if the channel was automatically closed or False if manually closed. """ claimant_id = await _caches.claimants.get(channel.id) - coroutine = self._unclaim_channel(channel, claimant_id, is_auto) + _unclaim_channel = self._unclaim_channel # It could be possible that there is no claimant cached. In such case, it'd be useless and # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. if claimant_id is not None: decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True) - coroutine = decorator(coroutine) + _unclaim_channel = decorator(_unclaim_channel) - return await coroutine + return await _unclaim_channel(channel, claimant_id, is_auto) async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" -- cgit v1.2.3 From 86babd4a90414d246f1b14cad80a367cc155f2ac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 Jan 2021 17:17:47 -0800 Subject: HelpChannels: fix unclaim exiting too early if claimant is None --- bot/exts/help_channels/_cog.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 3bdd896f2..0995c8a79 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -384,10 +384,8 @@ class HelpChannels(commands.Cog): claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) if claimant is None: log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") - return - - # Remove the cooldown role if the claimant has no other channels left - if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): + elif not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): + # Remove the cooldown role if the claimant has no other channels left await _cooldown.remove_cooldown_role(claimant) await _message.unpin(channel) -- cgit v1.2.3 From bc381bcf16cef694755ff4191d4ee95d7691798a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 Jan 2021 17:21:56 -0800 Subject: Set asyncio logger level to INFO If asyncio's debug mode is enabled, the asyncio logger's level gets set to DEBUG. While other features of the debug mode are useful, the DEBUG log level spams generally irrelevant stuff. --- bot/log.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/log.py b/bot/log.py index 0935666d1..e92233a33 100644 --- a/bot/log.py +++ b/bot/log.py @@ -54,6 +54,9 @@ def setup() -> None: logging.getLogger("chardet").setLevel(logging.WARNING) logging.getLogger("async_rediscache").setLevel(logging.WARNING) + # Set back to the default of INFO even if asyncio's debug mode is enabled. + logging.getLogger("asyncio").setLevel(logging.INFO) + def setup_sentry() -> None: """Set up the Sentry logging integrations.""" -- cgit v1.2.3 From eccbebf8f29998dcee72b690ed405221363a984f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 20 Jan 2021 16:38:58 +0000 Subject: Prevent bot from sending DMs to itself Fixes BOT-KX --- bot/exts/moderation/dm_relay.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 4d5142b55..6d081741c 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -52,6 +52,10 @@ class DMRelay(Cog): await ctx.message.add_reaction("❌") return + if member.id == self.bot.user.id: + log.debug("Not sending message to bot user") + return await ctx.send("🚫 I can't send messages to myself!") + try: await member.send(message) except discord.errors.Forbidden: -- cgit v1.2.3 From b75cb590b7ad7006b973e0c275a316057c5ff09c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 21 Jan 2021 01:03:40 +0200 Subject: Slowmode reset now uses slowmode set --- bot/exts/moderation/slowmode.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index efd862aa5..80eec34a0 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -75,16 +75,7 @@ class Slowmode(Cog): @slowmode_group.command(name='reset', aliases=['r']) async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: """Reset the slowmode delay for a text channel to 0 seconds.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') - - await channel.edit(slowmode_delay=0) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' - ) + await self.set_slowmode(ctx, channel, relativedelta(seconds=0)) async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" -- cgit v1.2.3 From 1478da27d899d23de72c9236a359a92a57708f1c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 21 Jan 2021 02:51:34 +0200 Subject: Added slowmode stat for python-general. --- bot/constants.py | 2 +- bot/exts/moderation/slowmode.py | 6 +++++- config-default.yml | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index be8d303f6..f2cc64702 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -429,7 +429,7 @@ class Channels(metaclass=YAMLGetter): off_topic_1: int off_topic_2: int organisation: int - python_discussion: int + python_general: int python_events: int python_news: int reddit: int diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index 80eec34a0..b72985d73 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -7,7 +7,7 @@ from discord import TextChannel from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES +from bot.constants import Channels, Emojis, MODERATION_ROLES from bot.converters import DurationDelta from bot.utils import time @@ -58,6 +58,10 @@ class Slowmode(Cog): log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') await channel.edit(slowmode_delay=slowmode_delay) + if channel.id == Channels.python_general: + log.info(f'Recording slowmode change in stats for {channel.name}.') + self.bot.stats.gauge(f"slowmode.{channel.name}", slowmode_delay) + await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' ) diff --git a/config-default.yml b/config-default.yml index f8368c5d2..530feffe5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -157,7 +157,7 @@ guild: # Discussion meta: 429409067623251969 - python_discussion: &PY_DISCUSSION 267624335836053506 + python_general: &PY_GENERAL 267624335836053506 # Python Help: Available cooldown: 720603994149486673 @@ -430,7 +430,7 @@ code_block: # The channels which will be affected by a cooldown. These channels are also whitelisted. cooldown_channels: - - *PY_DISCUSSION + - *PY_GENERAL # Sending instructions triggers a cooldown on a per-channel basis. # More instruction messages will not be sent in the same channel until the cooldown has elapsed. -- cgit v1.2.3 From 83f9755a7a03d10cff62bcae0556a981e4b679c0 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 21 Jan 2021 04:05:23 +0200 Subject: Changed slowmode reset tests --- tests/bot/exts/moderation/test_slowmode.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py index dad751e0d..5483b7a64 100644 --- a/tests/bot/exts/moderation/test_slowmode.py +++ b/tests/bot/exts/moderation/test_slowmode.py @@ -85,22 +85,14 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): self.ctx.reset_mock() - async def test_reset_slowmode_no_channel(self) -> None: - """Reset slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) - - await self.cog.reset_slowmode(self.cog, self.ctx, None) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' - ) - - async def test_reset_slowmode_with_channel(self) -> None: + async def test_reset_slowmode_sets_delay_to_zero(self) -> None: """Reset slowmode with a given channel.""" text_channel = MockTextChannel(name='meta', slowmode_delay=1) + self.cog.set_slowmode = mock.AsyncMock() await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' + self.cog.set_slowmode.assert_awaited_once_with( + self.ctx, text_channel, relativedelta(seconds=0) ) @mock.patch("bot.exts.moderation.slowmode.has_any_role") -- cgit v1.2.3 From 5c45e7f9923a435732e6b593b335ae9750f00fd2 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 21 Jan 2021 14:36:57 +0200 Subject: Added slowmode tracking to dpy and ot0 --- bot/constants.py | 1 + bot/exts/moderation/slowmode.py | 10 ++++++++-- config-default.yml | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f2cc64702..2f5cf0e8a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -408,6 +408,7 @@ class Channels(metaclass=YAMLGetter): code_help_voice_2: int cooldown: int defcon: int + discord_py: int dev_contrib: int dev_core: int dev_log: int diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index b72985d73..c449752e1 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -15,6 +15,12 @@ log = logging.getLogger(__name__) SLOWMODE_MAX_DELAY = 21600 # seconds +COMMONLY_SLOWMODED_CHANNELS = { + Channels.python_general: "python_general", + Channels.discord_py: "discordpy", + Channels.off_topic_0: "ot0", +} + class Slowmode(Cog): """Commands for getting and setting slowmode delays of text channels.""" @@ -58,9 +64,9 @@ class Slowmode(Cog): log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') await channel.edit(slowmode_delay=slowmode_delay) - if channel.id == Channels.python_general: + if channel.id in COMMONLY_SLOWMODED_CHANNELS: log.info(f'Recording slowmode change in stats for {channel.name}.') - self.bot.stats.gauge(f"slowmode.{channel.name}", slowmode_delay) + self.bot.stats.gauge(f"slowmode.{COMMONLY_SLOWMODED_CHANNELS[channel.id]}", slowmode_delay) await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' diff --git a/config-default.yml b/config-default.yml index 530feffe5..6695cffed 100644 --- a/config-default.yml +++ b/config-default.yml @@ -162,6 +162,9 @@ guild: # Python Help: Available cooldown: 720603994149486673 + # Topical + discord_py: 343944376055103488 + # Logs attachment_log: &ATTACH_LOG 649243850006855680 message_log: &MESSAGE_LOG 467752170159079424 -- cgit v1.2.3 From a0c2869bcaecd4eeccb6f57b1d46925525364cc1 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 21 Jan 2021 19:03:46 +0000 Subject: Fix aliases of shadow tempban --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 18e937e87..b3d069b34 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -198,7 +198,7 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Temporary shadow infractions - @command(hidden=True, aliases=["shadowtempban, stempban"]) + @command(hidden=True, aliases=["shadowtempban", "stempban"]) async def shadow_tempban( self, ctx: Context, -- cgit v1.2.3 From 8ebc08351e84b49edb7aeb95e5430b74c2369ff9 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 22 Jan 2021 10:09:46 +0100 Subject: Update badges on the README file --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c813997e7..da2d0bf03 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ [![Build][3]][4] [![Deploy][5]][6] [![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot) -[![License](https://img.shields.io/github/license/python-discord/bot)](LICENSE) -[![Website](https://img.shields.io/badge/website-visit-brightgreen)](https://pythondiscord.com) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities and other tools to help keep the server running like a well-oiled machine. @@ -19,5 +18,5 @@ Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) [4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster [5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master [6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster -[7]: https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white -[8]: https://discord.gg/2B963hn +[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.png +[8]: https://discord.gg/python -- cgit v1.2.3 From 65e2640d8d13eac4cf6afdbee8ab9a68e7c0ecba Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 22 Jan 2021 10:44:49 +0100 Subject: Use the SVG badge on the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da2d0bf03..ac45e6340 100644 --- a/README.md +++ b/README.md @@ -18,5 +18,5 @@ Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) [4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster [5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master [6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster -[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.png +[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.svg [8]: https://discord.gg/python -- cgit v1.2.3