From db5cf7d18d6992165abc15cd27ed50a4713af124 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 16:26:29 +0800 Subject: Add append subcommand for infraction group --- bot/exts/moderation/infraction/management.py | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index de4fb4175..4e31947d4 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -45,6 +45,56 @@ class ModManagement(commands.Cog): """Infraction manipulation commands.""" await ctx.send_help(ctx.command) + @infraction_group.command(name="append", aliases=("amend", "add")) + async def infraction_append( + self, + ctx: Context, + infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 + *, + reason: str = None + ) -> None: + """ + Append text and/or edit the duration of an infraction. + + Durations are relative to the time of updating and should be appended with a unit of time. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction + authored by the command invoker should be edited. + + Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 + timestamp can be provided for the duration. + """ + if isinstance(infraction_id, str): + params = { + "actor__id": ctx.author.id, + "ordering": "-inserted_at" + } + infractions = await self.bot.api_client.get("bot/infractions", params=params) + + if infractions: + old_infraction = infractions[0] + infraction_id = old_infraction["id"] + else: + await ctx.send( + ":x: Couldn't find most recent infraction; you have never given an infraction." + ) + return + else: + old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") + + reason = f"{old_infraction['reason']} **Edit:** {reason}" + + await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) + @infraction_group.command(name='edit') async def infraction_edit( self, -- cgit v1.2.3 From f9971be6251dc083332c2adc4bffb746f2c8ccf2 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:05:22 +0800 Subject: Add get_latest_infraction utility function --- bot/exts/moderation/infraction/management.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 4e31947d4..7596d2ec1 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -338,6 +338,20 @@ class ModManagement(commands.Cog): return lines.strip() + async def get_latest_infraction(self, actor: int) -> t.Optional[dict]: + """Obtains the latest infraction from an actor.""" + params = { + "actor__id": actor, + "ordering": "-inserted_at" + } + + infractions = await self.bot.api_client.get("bot/infractions", params=params) + + if infractions: + return infractions[0] + + return None + # endregion # This cannot be static (must have a __func__ attribute). -- cgit v1.2.3 From 1ab0084581859a0d8e938b9292bdb86ce9caf523 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:11:21 +0800 Subject: Refactor routine for obtaining latest infraction --- bot/exts/moderation/infraction/management.py | 36 +++++++++++----------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 7596d2ec1..b841b11c3 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -74,20 +74,16 @@ class ModManagement(commands.Cog): timestamp can be provided for the duration. """ if isinstance(infraction_id, str): - params = { - "actor__id": ctx.author.id, - "ordering": "-inserted_at" - } - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - old_infraction = infractions[0] - infraction_id = old_infraction["id"] - else: - await ctx.send( + old_infraction = await self.get_latest_infraction(ctx.author.id) + + if old_infraction is None: + ctx.send( ":x: Couldn't find most recent infraction; you have never given an infraction." ) return + + infraction_id = old_infraction["id"] + else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") @@ -129,20 +125,16 @@ class ModManagement(commands.Cog): # Retrieve the previous infraction for its information. if isinstance(infraction_id, str): - params = { - "actor__id": ctx.author.id, - "ordering": "-inserted_at" - } - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - old_infraction = infractions[0] - infraction_id = old_infraction["id"] - else: - await ctx.send( + old_infraction = await self.get_latest_infraction(ctx.author.id) + + if old_infraction is None: + ctx.send( ":x: Couldn't find most recent infraction; you have never given an infraction." ) return + + infraction_id = old_infraction["id"] + else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") -- cgit v1.2.3 From 4ce59c673c035550ba4f2e55e79faba17002d40c Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:46:10 +0800 Subject: Fix unawaited coroutine --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b841b11c3..e35ebcbef 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -77,7 +77,7 @@ class ModManagement(commands.Cog): old_infraction = await self.get_latest_infraction(ctx.author.id) if old_infraction is None: - ctx.send( + await ctx.send( ":x: Couldn't find most recent infraction; you have never given an infraction." ) return @@ -128,7 +128,7 @@ class ModManagement(commands.Cog): old_infraction = await self.get_latest_infraction(ctx.author.id) if old_infraction is None: - ctx.send( + await ctx.send( ":x: Couldn't find most recent infraction; you have never given an infraction." ) return -- cgit v1.2.3 From abbb62a0720f68cbd0a0226f4abeb9c3b337de3c Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:47:50 +0800 Subject: Add "a" alias for append --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index e35ebcbef..78dc16b23 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -45,7 +45,7 @@ class ModManagement(commands.Cog): """Infraction manipulation commands.""" await ctx.send_help(ctx.command) - @infraction_group.command(name="append", aliases=("amend", "add")) + @infraction_group.command(name="append", aliases=("amend", "add", "a")) async def infraction_append( self, ctx: Context, -- cgit v1.2.3 From 1dd93784d09baf73a670621f22b96c24ffb6c762 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 17:55:03 +0800 Subject: Add visual buffer for appended reason --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 78dc16b23..ba1485978 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - reason = f"{old_infraction['reason']} **Edit:** {reason}" + reason = f"{old_infraction['reason']} || **Edit:** {reason}" await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) -- cgit v1.2.3 From b65c64575f12ddee57bd6bf9bfaffafe6131890a Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 24 Sep 2020 18:59:50 +0800 Subject: Make vertical bar separators escaped --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index ba1485978..bdb0e8ffa 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - reason = f"{old_infraction['reason']} || **Edit:** {reason}" + reason = fr"{old_infraction['reason']} \|\| **Edit:** {reason}" await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) -- cgit v1.2.3 From 84671acaeb251939722a9b93f217b6256637a998 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 27 Sep 2020 13:30:32 +0800 Subject: Remove prefix when appending a reason --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index bdb0e8ffa..6b3e701c7 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - reason = fr"{old_infraction['reason']} \|\| **Edit:** {reason}" + reason = fr"{old_infraction['reason']} \|\| {reason}" await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) -- cgit v1.2.3 From e18893760b115600b7b03a60ce5bfb80e59fb882 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Thu, 1 Oct 2020 04:01:58 +0800 Subject: Add bold styling for vertical bar separators --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 6b3e701c7..1cdcf6568 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): else: old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - reason = fr"{old_infraction['reason']} \|\| {reason}" + reason = fr"{old_infraction['reason']} **\|\|** {reason}" await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) -- cgit v1.2.3 From c1b46ecc916970ec95e267f017958d30e4773c2a Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sat, 3 Oct 2020 12:52:24 +0800 Subject: Invoke infraction_edit directly with method call --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 1cdcf6568..2cb9bce8b 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -89,7 +89,7 @@ class ModManagement(commands.Cog): reason = fr"{old_infraction['reason']} **\|\|** {reason}" - await ctx.invoke(self.infraction_edit, infraction_id=infraction_id, duration=duration, reason=reason) + await self.infraction_edit(infraction_id=infraction_id, duration=duration, reason=reason) @infraction_group.command(name='edit') async def infraction_edit( -- cgit v1.2.3 From 2de1a6a4091234703d99a26ad2884b586d7204f4 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sat, 3 Oct 2020 15:42:23 +0800 Subject: Add Infraction converter This adds the Infraction converter to be used in infraction_edit and infraction_append. --- bot/converters.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 2e118d476..962416238 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -549,6 +549,36 @@ def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: return int(match.group(1)) +class Infraction(Converter): + """ + Attempts to convert a given infraction ID into an infraction. + + Alternatively, `l`, `last`, or `recent` can be passed in order to + obtain the most recent infraction by the actor. + """ + + async def convert(self, ctx: Context, arg: str) -> t.Optional[dict]: + """Attempts to convert `arg` into an infraction `dict`.""" + if arg in ("l", "last", "recent"): + params = { + "actor__id": ctx.author.id, + "ordering": "-inserted_at" + } + + infractions = await ctx.bot.api_client.get("bot/infractions", params=params) + + if not infractions: + await ctx.send( + ":x: Couldn't find most recent infraction; you have never given an infraction." + ) + return None + + return infractions[0] + + else: + return ctx.bot.api_client.get(f"bot/infractions/{arg}") + + Expiry = t.Union[Duration, ISODateTime] FetchedMember = t.Union[discord.Member, FetchedUser] UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) -- cgit v1.2.3 From 1ae9b15d7718d7e2f96b4406de99b89f1778b971 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sat, 3 Oct 2020 15:43:51 +0800 Subject: Refactor infraction_edit and infraction_append This refactors the infraction_edit and infraction_append commands to utilize the Infraction converter. --- bot/exts/moderation/infraction/management.py | 47 +++++++++------------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 2cb9bce8b..8aeb45f96 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -10,7 +10,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user +from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -49,7 +49,7 @@ class ModManagement(commands.Cog): async def infraction_append( self, ctx: Context, - infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + infraction: Infraction, # noqa: F821 duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 *, reason: str = None @@ -73,29 +73,21 @@ class ModManagement(commands.Cog): Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - if isinstance(infraction_id, str): - old_infraction = await self.get_latest_infraction(ctx.author.id) - - if old_infraction is None: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." - ) - return - - infraction_id = old_infraction["id"] - - else: - old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") - - reason = fr"{old_infraction['reason']} **\|\|** {reason}" + if not infraction: + return - await self.infraction_edit(infraction_id=infraction_id, duration=duration, reason=reason) + await self.infraction_edit( + ctx=ctx, + infraction=infraction, + duration=duration, + reason=fr"{infraction['reason']} **\|\|** {reason}", + ) @infraction_group.command(name='edit') async def infraction_edit( self, ctx: Context, - infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821 + infraction: Infraction, # noqa: F821 duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 *, reason: str = None @@ -123,20 +115,11 @@ class ModManagement(commands.Cog): # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - # Retrieve the previous infraction for its information. - if isinstance(infraction_id, str): - old_infraction = await self.get_latest_infraction(ctx.author.id) - - if old_infraction is None: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." - ) - return - - infraction_id = old_infraction["id"] + if not infraction: + return - else: - old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}") + old_infraction = infraction + infraction_id = infraction["id"] request_data = {} confirm_messages = [] -- cgit v1.2.3 From 472faca4b19419f1101258c876fc5fbbd7da8f3a Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 8 Nov 2020 15:03:46 +0800 Subject: Remove unnecessary noqa pragma for flake8 --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 8aeb45f96..97fc7b1d8 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -49,7 +49,7 @@ class ModManagement(commands.Cog): async def infraction_append( self, ctx: Context, - infraction: Infraction, # noqa: F821 + infraction: Infraction, duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 *, reason: str = None @@ -87,7 +87,7 @@ class ModManagement(commands.Cog): async def infraction_edit( self, ctx: Context, - infraction: Infraction, # noqa: F821 + infraction: Infraction, duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 *, reason: str = None -- cgit v1.2.3 From 3e73fd76d73b7e84888af46e0b6b47a1dd4003d3 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 8 Nov 2020 15:16:30 +0800 Subject: Raise BadArgument in the Infraction converter --- bot/converters.py | 9 ++++----- bot/exts/moderation/infraction/management.py | 6 ------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 962416238..f350e863e 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -568,12 +568,11 @@ class Infraction(Converter): infractions = await ctx.bot.api_client.get("bot/infractions", params=params) if not infractions: - await ctx.send( - ":x: Couldn't find most recent infraction; you have never given an infraction." + raise BadArgument( + "Couldn't find most recent infraction; you have never given an infraction." ) - return None - - return infractions[0] + else: + return infractions[0] else: return ctx.bot.api_client.get(f"bot/infractions/{arg}") diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 97fc7b1d8..49ddfa473 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -73,9 +73,6 @@ class ModManagement(commands.Cog): Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - if not infraction: - return - await self.infraction_edit( ctx=ctx, infraction=infraction, @@ -115,9 +112,6 @@ class ModManagement(commands.Cog): # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - if not infraction: - return - old_infraction = infraction infraction_id = infraction["id"] -- cgit v1.2.3 From de0b6984cc1947f5454939b4e20a09e6eeaffa98 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 8 Nov 2020 15:23:03 +0800 Subject: Refactor redundant code in infraction_edit --- bot/exts/moderation/infraction/management.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 49ddfa473..88b2f98c6 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -112,14 +112,13 @@ class ModManagement(commands.Cog): # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") - old_infraction = infraction infraction_id = infraction["id"] request_data = {} confirm_messages = [] log_text = "" - if duration is not None and not old_infraction['active']: + if duration is not None and not infraction['active']: if reason is None: await ctx.send(":x: Cannot edit the expiration of an expired infraction.") return @@ -138,7 +137,7 @@ class ModManagement(commands.Cog): request_data['reason'] = reason confirm_messages.append("set a new reason") log_text += f""" - Previous reason: {old_infraction['reason']} + Previous reason: {infraction['reason']} New reason: {reason} """.rstrip() else: @@ -153,7 +152,7 @@ class ModManagement(commands.Cog): # Re-schedule infraction if the expiration has been updated if 'expires_at' in request_data: # A scheduled task should only exist if the old infraction wasn't permanent - if old_infraction['expires_at']: + if infraction['expires_at']: self.infractions_cog.scheduler.cancel(new_infraction['id']) # If the infraction was not marked as permanent, schedule a new expiration task @@ -161,7 +160,7 @@ class ModManagement(commands.Cog): self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" - Previous expiry: {old_infraction['expires_at'] or "Permanent"} + Previous expiry: {infraction['expires_at'] or "Permanent"} New expiry: {new_infraction['expires_at'] or "Permanent"} """.rstrip() -- cgit v1.2.3 From af7cfd35945f7885a7cd36490aa0ffcf91218c8b Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Sun, 8 Nov 2020 16:03:11 +0800 Subject: Automatically add periods as visual buffers --- bot/exts/moderation/infraction/management.py | 17 +++++++++++------ bot/utils/regex.py | 2 ++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 88b2f98c6..e6513c32d 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -16,6 +16,7 @@ from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.checks import in_whitelist_check +from bot.utils.regex import END_PUNCTUATION_RE log = logging.getLogger(__name__) @@ -72,13 +73,17 @@ class ModManagement(commands.Cog): Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp can be provided for the duration. + + If a previous infraction reason does not end with an ending punctuation mark, this automatically + adds a period before the amended reason. """ - await self.infraction_edit( - ctx=ctx, - infraction=infraction, - duration=duration, - reason=fr"{infraction['reason']} **\|\|** {reason}", - ) + add_period = not END_PUNCTUATION_RE.match(infraction["reason"]) + + new_reason = "".join(( + infraction["reason"], ". " if add_period else " ", reason, + )) + + await self.infraction_edit(ctx, infraction, duration, reason=new_reason) @infraction_group.command(name='edit') async def infraction_edit( diff --git a/bot/utils/regex.py b/bot/utils/regex.py index 0d2068f90..cfce52bb3 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -10,3 +10,5 @@ INVITE_RE = re.compile( r"([a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE ) + +END_PUNCTUATION_RE = re.compile("^.+?[.?!]$") -- cgit v1.2.3 From ed68051a47e67d8e60498a866a8c3c54840aa6fb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 11:39:18 -0700 Subject: Help channels: move to a subpackage --- bot/exts/help_channels.py | 940 ------------------------------------- bot/exts/help_channels/__init__.py | 17 + bot/exts/help_channels/_cog.py | 930 ++++++++++++++++++++++++++++++++++++ 3 files changed, 947 insertions(+), 940 deletions(-) delete mode 100644 bot/exts/help_channels.py create mode 100644 bot/exts/help_channels/__init__.py create mode 100644 bot/exts/help_channels/_cog.py diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py deleted file mode 100644 index ced2f72ef..000000000 --- a/bot/exts/help_channels.py +++ /dev/null @@ -1,940 +0,0 @@ -import asyncio -import json -import logging -import random -import typing as t -from collections import deque -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import discord -import discord.abc -from async_rediscache import RedisCache -from discord.ext import commands - -from bot import constants -from bot.bot import Bot -from bot.utils import channel as channel_utils -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - -ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) - -HELP_CHANNEL_TOPIC = """ -This is a Python help channel. You can claim your own help channel in the Python Help: Available category. -""" - -AVAILABLE_MSG = f""" -**Send your question here to claim the channel** -This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. - -**Keep in mind:** -• It's always ok to just ask your question. You don't need permission. -• Explain what you expect to happen and what actually happens. -• Include a code sample and error message, if you got any. - -For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. -""" - -AVAILABLE_TITLE = "Available help channel" - -AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." - -DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ -category at the bottom of the channel list. It is no longer possible to send messages in this \ -channel until it becomes available again. - -If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ -question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. -""" - -CoroutineFunc = t.Callable[..., t.Coroutine] - - -class HelpChannels(commands.Cog): - """ - Manage the help channel system of the guild. - - The system is based on a 3-category system: - - Available Category - - * Contains channels which are ready to be occupied by someone who needs help - * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically - from the pool of dormant channels - * Prioritise using the channels which have been dormant for the longest amount of time - * If there are no more dormant channels, the bot will automatically create a new one - * If there are no dormant channels to move, helpers will be notified (see `notify()`) - * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` - * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` - * To keep track of cooldowns, user which claimed a channel will have a temporary role - - In Use Category - - * Contains all channels which are occupied by someone needing help - * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle - * Command can prematurely mark a channel as dormant - * Channel claimant is allowed to use the command - * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` - * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent - - Dormant Category - - * Contains channels which aren't in use - * Channels are used to refill the Available category - - Help channels are named after the chemical elements in `bot/resources/elements.json`. - """ - - # This cache tracks which channels are claimed by which members. - # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] - help_channel_claimants = RedisCache() - - # This cache maps a help channel to whether it has had any - # activity other than the original claimant. True being no other - # activity and False being other activity. - # RedisCache[discord.TextChannel.id, bool] - unanswered = RedisCache() - - # This dictionary maps a help channel to the time it was claimed - # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] - claim_times = RedisCache() - - # This cache maps a help channel to original question message in same channel. - # RedisCache[discord.TextChannel.id, discord.Message.id] - question_messages = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - # Categories - self.available_category: discord.CategoryChannel = None - self.in_use_category: discord.CategoryChannel = None - self.dormant_category: discord.CategoryChannel = None - - # Queues - self.channel_queue: asyncio.Queue[discord.TextChannel] = None - self.name_queue: t.Deque[str] = None - - self.name_positions = self.get_names() - self.last_notification: t.Optional[datetime] = None - - # Asyncio stuff - self.queue_tasks: t.List[asyncio.Task] = [] - self.ready = asyncio.Event() - self.on_message_lock = asyncio.Lock() - self.init_task = self.bot.loop.create_task(self.init_cog()) - - def cog_unload(self) -> None: - """Cancel the init task and scheduled tasks when the cog unloads.""" - log.trace("Cog unload: cancelling the init_cog task") - self.init_task.cancel() - - log.trace("Cog unload: cancelling the channel queue tasks") - for task in self.queue_tasks: - task.cancel() - - self.scheduler.cancel_all() - - def create_channel_queue(self) -> asyncio.Queue: - """ - Return a queue of dormant channels to use for getting the next available channel. - - The channels are added to the queue in a random order. - """ - log.trace("Creating the channel queue.") - - channels = list(self.get_category_channels(self.dormant_category)) - random.shuffle(channels) - - log.trace("Populating the channel queue with channels.") - queue = asyncio.Queue() - for channel in channels: - queue.put_nowait(channel) - - return queue - - async def create_dormant(self) -> t.Optional[discord.TextChannel]: - """ - Create and return a new channel in the Dormant category. - - The new channel will sync its permission overwrites with the category. - - Return None if no more channel names are available. - """ - log.trace("Getting a name for a new dormant channel.") - - try: - name = self.name_queue.popleft() - except IndexError: - log.debug("No more names available for new dormant channels.") - return None - - log.debug(f"Creating a new dormant channel named {name}.") - return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - - def create_name_queue(self) -> deque: - """Return a queue of element names to use for creating new channels.""" - log.trace("Creating the chemical element name queue.") - - used_names = self.get_used_names() - - log.trace("Determining the available names.") - available_names = (name for name in self.name_positions if name not in used_names) - - log.trace("Populating the name queue with names.") - return deque(available_names) - - async def dormant_check(self, ctx: commands.Context) -> bool: - """Return True if the user is the help channel claimant or passes the role check.""" - if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: - log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") - self.bot.stats.incr("help.dormant_invoke.claimant") - return True - - log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") - has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) - - if has_role: - self.bot.stats.incr("help.dormant_invoke.staff") - - return has_role - - @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) - async def close_command(self, ctx: commands.Context) -> None: - """ - Make the current in-use help channel dormant. - - Make the channel dormant if the user passes the `dormant_check`, - delete the message that invoked this, - and reset the send permissions cooldown for the user who started the session. - """ - log.trace("close command invoked; checking if the channel is in-use.") - if ctx.channel.category == self.in_use_category: - if await self.dormant_check(ctx): - await self.remove_cooldown_role(ctx.author) - - # Ignore missing task when cooldown has passed but the channel still isn't dormant. - if ctx.author.id in self.scheduler: - self.scheduler.cancel(ctx.author.id) - - await self.move_to_dormant(ctx.channel, "command") - self.scheduler.cancel(ctx.channel.id) - else: - log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") - - async def get_available_candidate(self) -> discord.TextChannel: - """ - Return a dormant channel to turn into an available channel. - - If no channel is available, wait indefinitely until one becomes available. - """ - log.trace("Getting an available channel candidate.") - - try: - channel = self.channel_queue.get_nowait() - except asyncio.QueueEmpty: - log.info("No candidate channels in the queue; creating a new channel.") - channel = await self.create_dormant() - - if not channel: - log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - await self.notify() - channel = await self.wait_for_dormant_channel() - - return channel - - @staticmethod - def get_clean_channel_name(channel: discord.TextChannel) -> str: - """Return a clean channel name without status emojis prefix.""" - prefix = constants.HelpChannels.name_prefix - try: - # Try to remove the status prefix using the index of the channel prefix - name = channel.name[channel.name.index(prefix):] - log.trace(f"The clean name for `{channel}` is `{name}`") - except ValueError: - # If, for some reason, the channel name does not contain "help-" fall back gracefully - log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") - name = channel.name - - return name - - @staticmethod - def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: - """Check if a channel should be excluded from the help channel system.""" - return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS - - def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - - # This is faster than using category.channels because the latter sorts them. - for channel in self.bot.get_guild(constants.Guild.id).channels: - if channel.category_id == category.id and not self.is_excluded_channel(channel): - yield channel - - async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: - """Return the duration `channel_id` has been in use. Return None if it's not in use.""" - log.trace(f"Calculating in use time for channel {channel_id}.") - - claimed_timestamp = await self.claim_times.get(channel_id) - if claimed_timestamp: - claimed = datetime.utcfromtimestamp(claimed_timestamp) - return datetime.utcnow() - claimed - - @staticmethod - def get_names() -> t.List[str]: - """ - Return a truncated list of prefixed element names. - - The amount of names is configured with `HelpChannels.max_total_channels`. - The prefix is configured with `HelpChannels.name_prefix`. - """ - count = constants.HelpChannels.max_total_channels - prefix = constants.HelpChannels.name_prefix - - log.trace(f"Getting the first {count} element names from JSON.") - - with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - all_names = json.load(elements_file) - - if prefix: - return [prefix + name for name in all_names[:count]] - else: - return all_names[:count] - - def get_used_names(self) -> t.Set[str]: - """Return channel names which are already being used.""" - log.trace("Getting channel names which are already being used.") - - names = set() - for cat in (self.available_category, self.in_use_category, self.dormant_category): - for channel in self.get_category_channels(cat): - names.add(self.get_clean_channel_name(channel)) - - if len(names) > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"Too many help channels ({len(names)}) already exist! " - f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." - ) - - log.trace(f"Got {len(names)} used names: {names}") - return names - - @classmethod - async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: - """ - Return the time elapsed, in seconds, since the last message sent in the `channel`. - - Return None if the channel has no messages. - """ - log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - - msg = await cls.get_last_message(channel) - if not msg: - log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") - return None - - idle_time = (datetime.utcnow() - msg.created_at).seconds - - log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") - return idle_time - - @staticmethod - async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: - """Return the last message sent in the channel or None if no messages exist.""" - log.trace(f"Getting the last message in #{channel} ({channel.id}).") - - try: - return await channel.history(limit=1).next() # noqa: B305 - except discord.NoMoreItems: - log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") - return None - - async def init_available(self) -> None: - """Initialise the Available category with channels.""" - log.trace("Initialising the Available category with channels.") - - channels = list(self.get_category_channels(self.available_category)) - missing = constants.HelpChannels.max_available - len(channels) - - # If we've got less than `max_available` channel available, we should add some. - if missing > 0: - log.trace(f"Moving {missing} missing channels to the Available category.") - for _ in range(missing): - await self.move_to_available() - - # If for some reason we have more than `max_available` channels available, - # we should move the superfluous ones over to dormant. - elif missing < 0: - log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") - for channel in channels[:abs(missing)]: - await self.move_to_dormant(channel, "auto") - - async def init_categories(self) -> None: - """Get the help category objects. Remove the cog if retrieval fails.""" - log.trace("Getting the CategoryChannel objects for the help categories.") - - try: - self.available_category = await channel_utils.try_get_channel( - constants.Categories.help_available - ) - self.in_use_category = await channel_utils.try_get_channel( - constants.Categories.help_in_use - ) - self.dormant_category = await channel_utils.try_get_channel( - constants.Categories.help_dormant - ) - except discord.HTTPException: - log.exception("Failed to get a category; cog will be removed") - self.bot.remove_cog(self.qualified_name) - - async def init_cog(self) -> None: - """Initialise the help channel system.""" - log.trace("Waiting for the guild to be available before initialisation.") - await self.bot.wait_until_guild_available() - - log.trace("Initialising the cog.") - await self.init_categories() - await self.check_cooldowns() - - self.channel_queue = self.create_channel_queue() - self.name_queue = self.create_name_queue() - - log.trace("Moving or rescheduling in-use channels.") - for channel in self.get_category_channels(self.in_use_category): - await self.move_idle_channel(channel, has_task=False) - - # Prevent the command from being used until ready. - # The ready event wasn't used because channels could change categories between the time - # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). - # This may confuse users. So would potentially long delays for the cog to become ready. - self.close_command.enabled = True - - await self.init_available() - - log.info("Cog is ready!") - self.ready.set() - - self.report_stats() - - def report_stats(self) -> None: - """Report the channel count stats.""" - total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in self.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) - - self.bot.stats.gauge("help.total.in_use", total_in_use) - self.bot.stats.gauge("help.total.available", total_available) - self.bot.stats.gauge("help.total.dormant", total_dormant) - - @staticmethod - def is_claimant(member: discord.Member) -> bool: - """Return True if `member` has the 'Help Cooldown' role.""" - return any(constants.Roles.help_cooldown == role.id for role in member.roles) - - def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: - """Return `True` if the bot's `message`'s embed description matches `description`.""" - if not message or not message.embeds: - return False - - bot_msg_desc = message.embeds[0].description - if bot_msg_desc is discord.Embed.Empty: - log.trace("Last message was a bot embed but it was empty.") - return False - return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() - - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: - """ - Make the `channel` dormant if idle or schedule the move if still active. - - If `has_task` is True and rescheduling is required, the extant task to make the channel - dormant will first be cancelled. - """ - log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - - if not await self.is_empty(channel): - idle_seconds = constants.HelpChannels.idle_minutes * 60 - else: - idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 - - time_elapsed = await self.get_idle_time(channel) - - if time_elapsed is None or time_elapsed >= idle_seconds: - log.info( - f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " - f"and will be made dormant." - ) - - await self.move_to_dormant(channel, "auto") - else: - # Cancel the existing task, if any. - if has_task: - self.scheduler.cancel(channel.id) - - delay = idle_seconds - time_elapsed - log.info( - f"#{channel} ({channel.id}) is still active; " - f"scheduling it to be moved after {delay} seconds." - ) - - self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) - - async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: - """ - Move the `channel` to the bottom position of `category` and edit channel attributes. - - To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current - positions of the other channels in the category as-is. This should make sure that the channel - really ends up at the bottom of the category. - - If `options` are provided, the channel will be edited after the move is completed. This is the - same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documentation on `discord.TextChannel.edit`. While possible, position-related - options should be avoided, as it may interfere with the category move we perform. - """ - # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await channel_utils.try_get_channel(category_id) - - payload = [{"id": c.id, "position": c.position} for c in category.channels] - - # Calculate the bottom position based on the current highest position in the category. If the - # category is currently empty, we simply use the current position of the channel to avoid making - # unnecessary changes to positions in the guild. - bottom_position = payload[-1]["position"] + 1 if payload else channel.position - - payload.append( - { - "id": channel.id, - "position": bottom_position, - "parent_id": category.id, - "lock_permissions": True, - } - ) - - # We use d.py's method to ensure our request is processed by d.py's rate limit manager - await self.bot.http.bulk_channel_update(category.guild.id, payload) - - # Now that the channel is moved, we can edit the other attributes - if options: - await channel.edit(**options) - - async def move_to_available(self) -> None: - """Make a channel available.""" - log.trace("Making a channel available.") - - channel = await self.get_available_candidate() - log.info(f"Making #{channel} ({channel.id}) available.") - - await self.send_available_message(channel) - - log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") - - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_available, - ) - - self.report_stats() - - async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: - """ - Make the `channel` dormant. - - A caller argument is provided for metrics. - """ - log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - - await self.help_channel_claimants.delete(channel.id) - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_dormant, - ) - - self.bot.stats.incr(f"help.dormant_calls.{caller}") - - in_use_time = await self.get_in_use_time(channel.id) - if in_use_time: - self.bot.stats.timing("help.in_use_time", in_use_time) - - unanswered = await self.unanswered.get(channel.id) - if unanswered: - self.bot.stats.incr("help.sessions.unanswered") - elif unanswered is not None: - self.bot.stats.incr("help.sessions.answered") - - log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") - log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed(description=DORMANT_MSG) - await channel.send(embed=embed) - - await self.unpin(channel) - - log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") - self.channel_queue.put_nowait(channel) - self.report_stats() - - async def move_to_in_use(self, channel: discord.TextChannel) -> None: - """Make a channel in-use and schedule it to be made dormant.""" - log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") - - await self.move_to_bottom_position( - channel=channel, - category_id=constants.Categories.help_in_use, - ) - - timeout = constants.HelpChannels.idle_minutes * 60 - - log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") - self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) - self.report_stats() - - async def notify(self) -> None: - """ - Send a message notifying about a lack of available help channels. - - Configuration: - - * `HelpChannels.notify` - toggle notifications - * `HelpChannels.notify_channel` - destination channel for notifications - * `HelpChannels.notify_minutes` - minimum interval between notifications - * `HelpChannels.notify_roles` - roles mentioned in notifications - """ - if not constants.HelpChannels.notify: - return - - log.trace("Notifying about lack of channels.") - - if self.last_notification: - elapsed = (datetime.utcnow() - self.last_notification).seconds - minimum_interval = constants.HelpChannels.notify_minutes * 60 - should_send = elapsed >= minimum_interval - else: - should_send = True - - if not should_send: - log.trace("Notification not sent because it's too recent since the previous one.") - return - - try: - log.trace("Sending notification message.") - - channel = self.bot.get_channel(constants.HelpChannels.notify_channel) - mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) - allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] - - message = await channel.send( - f"{mentions} A new available help channel is needed but there " - f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels.", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - - self.bot.stats.incr("help.out_of_channel_alerts") - - self.last_notification = message.created_at - except Exception: - # Handle it here cause this feature isn't critical for the functionality of the system. - log.exception("Failed to send notification about lack of dormant channels!") - - async def check_for_answer(self, message: discord.Message) -> None: - """Checks for whether new content in a help channel comes from non-claimants.""" - channel = message.channel - - # Confirm the channel is an in use help channel - if channel_utils.is_in_category(channel, constants.Categories.help_in_use): - log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") - - # Check if there is an entry in unanswered - if await self.unanswered.contains(channel.id): - claimant_id = await self.help_channel_claimants.get(channel.id) - if not claimant_id: - # The mapping for this channel doesn't exist, we can't do anything. - return - - # Check the message did not come from the claimant - if claimant_id != message.author.id: - # Mark the channel as answered - await self.unanswered.set(channel.id, False) - - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Move an available channel to the In Use category and replace it with a dormant one.""" - if message.author.bot: - return # Ignore messages sent by bots. - - channel = message.channel - - await self.check_for_answer(message) - - is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or self.is_excluded_channel(channel): - return # Ignore messages outside the Available category or in excluded channels. - - log.trace("Waiting for the cog to be ready before processing messages.") - await self.ready.wait() - - log.trace("Acquiring lock to prevent a channel from being processed twice...") - async with self.on_message_lock: - log.trace(f"on_message lock acquired for {message.id}.") - - if not channel_utils.is_in_category(channel, constants.Categories.help_available): - log.debug( - f"Message {message.id} will not make #{channel} ({channel.id}) in-use " - f"because another message in the channel already triggered that." - ) - return - - log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") - await self.move_to_in_use(channel) - await self.revoke_send_permissions(message.author) - - await self.pin(message) - - # Add user with channel for dormant check. - await self.help_channel_claimants.set(channel.id, message.author.id) - - self.bot.stats.incr("help.claimed") - - # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. - timestamp = datetime.now(timezone.utc).timestamp() - await self.claim_times.set(channel.id, timestamp) - - await self.unanswered.set(channel.id, True) - - log.trace(f"Releasing on_message lock for {message.id}.") - - # Move a dormant channel to the Available category to fill in the gap. - # This is done last and outside the lock because it may wait indefinitely for a channel to - # be put in the queue. - await self.move_to_available() - - @commands.Cog.listener() - async def on_message_delete(self, msg: discord.Message) -> None: - """ - Reschedule an in-use channel to become dormant sooner if the channel is empty. - - The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. - """ - if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): - return - - if not await self.is_empty(msg.channel): - return - - log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") - - # Cancel existing dormant task before scheduling new. - self.scheduler.cancel(msg.channel.id) - - delay = constants.HelpChannels.deleted_idle_minutes * 60 - self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - - async def is_empty(self, channel: discord.TextChannel) -> bool: - """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" - log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - - # A limit of 100 results in a single API call. - # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. - # Not gonna do an extensive search for it cause it's too expensive. - async for msg in channel.history(limit=100): - if not msg.author.bot: - log.trace(f"#{channel} ({channel.id}) has a non-bot message.") - return False - - if self.match_bot_embed(msg, AVAILABLE_MSG): - log.trace(f"#{channel} ({channel.id}) has the available message embed.") - return True - - return False - - async def check_cooldowns(self) -> None: - """Remove expired cooldowns and re-schedule active ones.""" - log.trace("Checking all cooldowns to remove or re-schedule them.") - guild = self.bot.get_guild(constants.Guild.id) - cooldown = constants.HelpChannels.claim_minutes * 60 - - for channel_id, member_id in await self.help_channel_claimants.items(): - member = guild.get_member(member_id) - if not member: - continue # Member probably left the guild. - - in_use_time = await self.get_in_use_time(channel_id) - - if not in_use_time or in_use_time.seconds > cooldown: - # Remove the role if no claim time could be retrieved or if the cooldown expired. - # Since the channel is in the claimants cache, it is definitely strange for a time - # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. - await self.remove_cooldown_role(member) - else: - # The member is still on a cooldown; re-schedule it for the remaining time. - delay = cooldown - in_use_time.seconds - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - - async def add_cooldown_role(self, member: discord.Member) -> None: - """Add the help cooldown role to `member`.""" - log.trace(f"Adding cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.add_roles) - - async def remove_cooldown_role(self, member: discord.Member) -> None: - """Remove the help cooldown role from `member`.""" - log.trace(f"Removing cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.remove_roles) - - async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: - """ - Change `member`'s cooldown role via awaiting `coro_func` and handle errors. - - `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - guild = self.bot.get_guild(constants.Guild.id) - role = guild.get_role(constants.Roles.help_cooldown) - if role is None: - log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") - return - - try: - await coro_func(role) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - - async def revoke_send_permissions(self, member: discord.Member) -> None: - """ - Disallow `member` to send messages in the Available category for a certain time. - - The time until permissions are reinstated can be configured with - `HelpChannels.claim_minutes`. - """ - log.trace( - f"Revoking {member}'s ({member.id}) send message permissions in the Available category." - ) - - await self.add_cooldown_role(member) - - # Cancel the existing task, if any. - # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - if member.id in self.scheduler: - self.scheduler.cancel(member.id) - - delay = constants.HelpChannels.claim_minutes * 60 - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - - async def send_available_message(self, channel: discord.TextChannel) -> None: - """Send the available message by editing a dormant message or sending a new message.""" - channel_info = f"#{channel} ({channel.id})" - log.trace(f"Sending available message in {channel_info}.") - - embed = discord.Embed( - color=constants.Colours.bright_green, - description=AVAILABLE_MSG, - ) - embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) - embed.set_footer(text=AVAILABLE_FOOTER) - - msg = await self.get_last_message(channel) - if self.match_bot_embed(msg, DORMANT_MSG): - log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") - await msg.edit(embed=embed) - else: - log.trace(f"Dormant message not found in {channel_info}; sending a new message.") - await channel.send(embed=embed) - - async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: - """ - Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - - Return True if successful and False otherwise. - """ - channel_str = f"#{channel} ({channel.id})" - if pin: - func = self.bot.http.pin_message - verb = "pin" - else: - func = self.bot.http.unpin_message - verb = "unpin" - - try: - await func(channel.id, msg_id) - except discord.HTTPException as e: - if e.code == 10008: - log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") - else: - log.exception( - f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" - ) - return False - else: - log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") - return True - - async def pin(self, message: discord.Message) -> None: - """Pin an initial question `message` and store it in a cache.""" - if await self.pin_wrapper(message.id, message.channel, pin=True): - await self.question_messages.set(message.channel.id, message.id) - - async def unpin(self, channel: discord.TextChannel) -> None: - """Unpin the initial question message sent in `channel`.""" - msg_id = await self.question_messages.pop(channel.id) - if msg_id is None: - log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") - else: - await self.pin_wrapper(msg_id, channel, pin=False) - - async def wait_for_dormant_channel(self) -> discord.TextChannel: - """Wait for a dormant channel to become available in the queue and return it.""" - log.trace("Waiting for a dormant channel.") - - task = asyncio.create_task(self.channel_queue.get()) - self.queue_tasks.append(task) - channel = await task - - log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") - self.queue_tasks.remove(task) - - return channel - - -def validate_config() -> None: - """Raise a ValueError if the cog's config is invalid.""" - log.trace("Validating config.") - total = constants.HelpChannels.max_total_channels - available = constants.HelpChannels.max_available - - if total == 0 or available == 0: - raise ValueError("max_total_channels and max_available and must be greater than 0.") - - if total < available: - raise ValueError( - f"max_total_channels ({total}) must be greater than or equal to max_available " - f"({available})." - ) - - if total > MAX_CHANNELS_PER_CATEGORY: - raise ValueError( - f"max_total_channels ({total}) must be less than or equal to " - f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." - ) - - -def setup(bot: Bot) -> None: - """Load the HelpChannels cog.""" - try: - validate_config() - except ValueError as e: - log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") - else: - bot.add_cog(HelpChannels(bot)) diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py new file mode 100644 index 000000000..38444b707 --- /dev/null +++ b/bot/exts/help_channels/__init__.py @@ -0,0 +1,17 @@ +import logging + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: + """Load the HelpChannels cog.""" + # Defer import to reduce side effects from importing the sync package. + from bot.exts.help_channels import _cog + try: + _cog.validate_config() + except ValueError as e: + log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") + else: + bot.add_cog(_cog.HelpChannels(bot)) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py new file mode 100644 index 000000000..5e2a7dd71 --- /dev/null +++ b/bot/exts/help_channels/_cog.py @@ -0,0 +1,930 @@ +import asyncio +import json +import logging +import random +import typing as t +from collections import deque +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import discord +import discord.abc +from async_rediscache import RedisCache +from discord.ext import commands + +from bot import constants +from bot.bot import Bot +from bot.utils import channel as channel_utils +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) + +HELP_CHANNEL_TOPIC = """ +This is a Python help channel. You can claim your own help channel in the Python Help: Available category. +""" + +AVAILABLE_MSG = f""" +**Send your question here to claim the channel** +This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. + +**Keep in mind:** +• It's always ok to just ask your question. You don't need permission. +• Explain what you expect to happen and what actually happens. +• Include a code sample and error message, if you got any. + +For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. +""" + +AVAILABLE_TITLE = "Available help channel" + +AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." + +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +category at the bottom of the channel list. It is no longer possible to send messages in this \ +channel until it becomes available again. + +If your question wasn't answered yet, you can claim a new help channel from the \ +**Help: Available** category by simply asking your question again. Consider rephrasing the \ +question to maximize your chance of getting a good answer. If you're not sure how, have a look \ +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. +""" + +CoroutineFunc = t.Callable[..., t.Coroutine] + + +class HelpChannels(commands.Cog): + """ + Manage the help channel system of the guild. + + The system is based on a 3-category system: + + Available Category + + * Contains channels which are ready to be occupied by someone who needs help + * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically + from the pool of dormant channels + * Prioritise using the channels which have been dormant for the longest amount of time + * If there are no more dormant channels, the bot will automatically create a new one + * If there are no dormant channels to move, helpers will be notified (see `notify()`) + * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` + * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` + * To keep track of cooldowns, user which claimed a channel will have a temporary role + + In Use Category + + * Contains all channels which are occupied by someone needing help + * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle + * Command can prematurely mark a channel as dormant + * Channel claimant is allowed to use the command + * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` + * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent + + Dormant Category + + * Contains channels which aren't in use + * Channels are used to refill the Available category + + Help channels are named after the chemical elements in `bot/resources/elements.json`. + """ + + # This cache tracks which channels are claimed by which members. + # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] + help_channel_claimants = RedisCache() + + # This cache maps a help channel to whether it has had any + # activity other than the original claimant. True being no other + # activity and False being other activity. + # RedisCache[discord.TextChannel.id, bool] + unanswered = RedisCache() + + # This dictionary maps a help channel to the time it was claimed + # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] + claim_times = RedisCache() + + # This cache maps a help channel to original question message in same channel. + # RedisCache[discord.TextChannel.id, discord.Message.id] + question_messages = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + + # Categories + self.available_category: discord.CategoryChannel = None + self.in_use_category: discord.CategoryChannel = None + self.dormant_category: discord.CategoryChannel = None + + # Queues + self.channel_queue: asyncio.Queue[discord.TextChannel] = None + self.name_queue: t.Deque[str] = None + + self.name_positions = self.get_names() + self.last_notification: t.Optional[datetime] = None + + # Asyncio stuff + self.queue_tasks: t.List[asyncio.Task] = [] + self.ready = asyncio.Event() + self.on_message_lock = asyncio.Lock() + self.init_task = self.bot.loop.create_task(self.init_cog()) + + def cog_unload(self) -> None: + """Cancel the init task and scheduled tasks when the cog unloads.""" + log.trace("Cog unload: cancelling the init_cog task") + self.init_task.cancel() + + log.trace("Cog unload: cancelling the channel queue tasks") + for task in self.queue_tasks: + task.cancel() + + self.scheduler.cancel_all() + + def create_channel_queue(self) -> asyncio.Queue: + """ + Return a queue of dormant channels to use for getting the next available channel. + + The channels are added to the queue in a random order. + """ + log.trace("Creating the channel queue.") + + channels = list(self.get_category_channels(self.dormant_category)) + random.shuffle(channels) + + log.trace("Populating the channel queue with channels.") + queue = asyncio.Queue() + for channel in channels: + queue.put_nowait(channel) + + return queue + + async def create_dormant(self) -> t.Optional[discord.TextChannel]: + """ + Create and return a new channel in the Dormant category. + + The new channel will sync its permission overwrites with the category. + + Return None if no more channel names are available. + """ + log.trace("Getting a name for a new dormant channel.") + + try: + name = self.name_queue.popleft() + except IndexError: + log.debug("No more names available for new dormant channels.") + return None + + log.debug(f"Creating a new dormant channel named {name}.") + return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) + + def create_name_queue(self) -> deque: + """Return a queue of element names to use for creating new channels.""" + log.trace("Creating the chemical element name queue.") + + used_names = self.get_used_names() + + log.trace("Determining the available names.") + available_names = (name for name in self.name_positions if name not in used_names) + + log.trace("Populating the name queue with names.") + return deque(available_names) + + async def dormant_check(self, ctx: commands.Context) -> bool: + """Return True if the user is the help channel claimant or passes the role check.""" + if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: + log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") + self.bot.stats.incr("help.dormant_invoke.claimant") + return True + + log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") + has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) + + if has_role: + self.bot.stats.incr("help.dormant_invoke.staff") + + return has_role + + @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) + async def close_command(self, ctx: commands.Context) -> None: + """ + Make the current in-use help channel dormant. + + Make the channel dormant if the user passes the `dormant_check`, + delete the message that invoked this, + and reset the send permissions cooldown for the user who started the session. + """ + log.trace("close command invoked; checking if the channel is in-use.") + if ctx.channel.category == self.in_use_category: + if await self.dormant_check(ctx): + await self.remove_cooldown_role(ctx.author) + + # Ignore missing task when cooldown has passed but the channel still isn't dormant. + if ctx.author.id in self.scheduler: + self.scheduler.cancel(ctx.author.id) + + await self.move_to_dormant(ctx.channel, "command") + self.scheduler.cancel(ctx.channel.id) + else: + log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") + + async def get_available_candidate(self) -> discord.TextChannel: + """ + Return a dormant channel to turn into an available channel. + + If no channel is available, wait indefinitely until one becomes available. + """ + log.trace("Getting an available channel candidate.") + + try: + channel = self.channel_queue.get_nowait() + except asyncio.QueueEmpty: + log.info("No candidate channels in the queue; creating a new channel.") + channel = await self.create_dormant() + + if not channel: + log.info("Couldn't create a candidate channel; waiting to get one from the queue.") + await self.notify() + channel = await self.wait_for_dormant_channel() + + return channel + + @staticmethod + def get_clean_channel_name(channel: discord.TextChannel) -> str: + """Return a clean channel name without status emojis prefix.""" + prefix = constants.HelpChannels.name_prefix + try: + # Try to remove the status prefix using the index of the channel prefix + name = channel.name[channel.name.index(prefix):] + log.trace(f"The clean name for `{channel}` is `{name}`") + except ValueError: + # If, for some reason, the channel name does not contain "help-" fall back gracefully + log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") + name = channel.name + + return name + + @staticmethod + def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: + """Check if a channel should be excluded from the help channel system.""" + return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + + def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + """Yield the text channels of the `category` in an unsorted manner.""" + log.trace(f"Getting text channels in the category '{category}' ({category.id}).") + + # This is faster than using category.channels because the latter sorts them. + for channel in self.bot.get_guild(constants.Guild.id).channels: + if channel.category_id == category.id and not self.is_excluded_channel(channel): + yield channel + + async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: + """Return the duration `channel_id` has been in use. Return None if it's not in use.""" + log.trace(f"Calculating in use time for channel {channel_id}.") + + claimed_timestamp = await self.claim_times.get(channel_id) + if claimed_timestamp: + claimed = datetime.utcfromtimestamp(claimed_timestamp) + return datetime.utcnow() - claimed + + @staticmethod + def get_names() -> t.List[str]: + """ + Return a truncated list of prefixed element names. + + The amount of names is configured with `HelpChannels.max_total_channels`. + The prefix is configured with `HelpChannels.name_prefix`. + """ + count = constants.HelpChannels.max_total_channels + prefix = constants.HelpChannels.name_prefix + + log.trace(f"Getting the first {count} element names from JSON.") + + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + all_names = json.load(elements_file) + + if prefix: + return [prefix + name for name in all_names[:count]] + else: + return all_names[:count] + + def get_used_names(self) -> t.Set[str]: + """Return channel names which are already being used.""" + log.trace("Getting channel names which are already being used.") + + names = set() + for cat in (self.available_category, self.in_use_category, self.dormant_category): + for channel in self.get_category_channels(cat): + names.add(self.get_clean_channel_name(channel)) + + if len(names) > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"Too many help channels ({len(names)}) already exist! " + f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." + ) + + log.trace(f"Got {len(names)} used names: {names}") + return names + + @classmethod + async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: + """ + Return the time elapsed, in seconds, since the last message sent in the `channel`. + + Return None if the channel has no messages. + """ + log.trace(f"Getting the idle time for #{channel} ({channel.id}).") + + msg = await cls.get_last_message(channel) + if not msg: + log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") + return None + + idle_time = (datetime.utcnow() - msg.created_at).seconds + + log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") + return idle_time + + @staticmethod + async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: + """Return the last message sent in the channel or None if no messages exist.""" + log.trace(f"Getting the last message in #{channel} ({channel.id}).") + + try: + return await channel.history(limit=1).next() # noqa: B305 + except discord.NoMoreItems: + log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") + return None + + async def init_available(self) -> None: + """Initialise the Available category with channels.""" + log.trace("Initialising the Available category with channels.") + + channels = list(self.get_category_channels(self.available_category)) + missing = constants.HelpChannels.max_available - len(channels) + + # If we've got less than `max_available` channel available, we should add some. + if missing > 0: + log.trace(f"Moving {missing} missing channels to the Available category.") + for _ in range(missing): + await self.move_to_available() + + # If for some reason we have more than `max_available` channels available, + # we should move the superfluous ones over to dormant. + elif missing < 0: + log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") + for channel in channels[:abs(missing)]: + await self.move_to_dormant(channel, "auto") + + async def init_categories(self) -> None: + """Get the help category objects. Remove the cog if retrieval fails.""" + log.trace("Getting the CategoryChannel objects for the help categories.") + + try: + self.available_category = await channel_utils.try_get_channel( + constants.Categories.help_available + ) + self.in_use_category = await channel_utils.try_get_channel( + constants.Categories.help_in_use + ) + self.dormant_category = await channel_utils.try_get_channel( + constants.Categories.help_dormant + ) + except discord.HTTPException: + log.exception("Failed to get a category; cog will be removed") + self.bot.remove_cog(self.qualified_name) + + async def init_cog(self) -> None: + """Initialise the help channel system.""" + log.trace("Waiting for the guild to be available before initialisation.") + await self.bot.wait_until_guild_available() + + log.trace("Initialising the cog.") + await self.init_categories() + await self.check_cooldowns() + + self.channel_queue = self.create_channel_queue() + self.name_queue = self.create_name_queue() + + log.trace("Moving or rescheduling in-use channels.") + for channel in self.get_category_channels(self.in_use_category): + await self.move_idle_channel(channel, has_task=False) + + # Prevent the command from being used until ready. + # The ready event wasn't used because channels could change categories between the time + # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). + # This may confuse users. So would potentially long delays for the cog to become ready. + self.close_command.enabled = True + + await self.init_available() + + log.info("Cog is ready!") + self.ready.set() + + self.report_stats() + + def report_stats(self) -> None: + """Report the channel count stats.""" + total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in self.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) + + self.bot.stats.gauge("help.total.in_use", total_in_use) + self.bot.stats.gauge("help.total.available", total_available) + self.bot.stats.gauge("help.total.dormant", total_dormant) + + @staticmethod + def is_claimant(member: discord.Member) -> bool: + """Return True if `member` has the 'Help Cooldown' role.""" + return any(constants.Roles.help_cooldown == role.id for role in member.roles) + + def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: + """Return `True` if the bot's `message`'s embed description matches `description`.""" + if not message or not message.embeds: + return False + + bot_msg_desc = message.embeds[0].description + if bot_msg_desc is discord.Embed.Empty: + log.trace("Last message was a bot embed but it was empty.") + return False + return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() + + async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: + """ + Make the `channel` dormant if idle or schedule the move if still active. + + If `has_task` is True and rescheduling is required, the extant task to make the channel + dormant will first be cancelled. + """ + log.trace(f"Handling in-use channel #{channel} ({channel.id}).") + + if not await self.is_empty(channel): + idle_seconds = constants.HelpChannels.idle_minutes * 60 + else: + idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 + + time_elapsed = await self.get_idle_time(channel) + + if time_elapsed is None or time_elapsed >= idle_seconds: + log.info( + f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " + f"and will be made dormant." + ) + + await self.move_to_dormant(channel, "auto") + else: + # Cancel the existing task, if any. + if has_task: + self.scheduler.cancel(channel.id) + + delay = idle_seconds - time_elapsed + log.info( + f"#{channel} ({channel.id}) is still active; " + f"scheduling it to be moved after {delay} seconds." + ) + + self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) + + async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: + """ + Move the `channel` to the bottom position of `category` and edit channel attributes. + + To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current + positions of the other channels in the category as-is. This should make sure that the channel + really ends up at the bottom of the category. + + If `options` are provided, the channel will be edited after the move is completed. This is the + same order of operations that `discord.TextChannel.edit` uses. For information on available + options, see the documentation on `discord.TextChannel.edit`. While possible, position-related + options should be avoided, as it may interfere with the category move we perform. + """ + # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. + category = await channel_utils.try_get_channel(category_id) + + payload = [{"id": c.id, "position": c.position} for c in category.channels] + + # Calculate the bottom position based on the current highest position in the category. If the + # category is currently empty, we simply use the current position of the channel to avoid making + # unnecessary changes to positions in the guild. + bottom_position = payload[-1]["position"] + 1 if payload else channel.position + + payload.append( + { + "id": channel.id, + "position": bottom_position, + "parent_id": category.id, + "lock_permissions": True, + } + ) + + # We use d.py's method to ensure our request is processed by d.py's rate limit manager + await self.bot.http.bulk_channel_update(category.guild.id, payload) + + # Now that the channel is moved, we can edit the other attributes + if options: + await channel.edit(**options) + + async def move_to_available(self) -> None: + """Make a channel available.""" + log.trace("Making a channel available.") + + channel = await self.get_available_candidate() + log.info(f"Making #{channel} ({channel.id}) available.") + + await self.send_available_message(channel) + + log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") + + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_available, + ) + + self.report_stats() + + async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: + """ + Make the `channel` dormant. + + A caller argument is provided for metrics. + """ + log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") + + await self.help_channel_claimants.delete(channel.id) + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_dormant, + ) + + self.bot.stats.incr(f"help.dormant_calls.{caller}") + + in_use_time = await self.get_in_use_time(channel.id) + if in_use_time: + self.bot.stats.timing("help.in_use_time", in_use_time) + + unanswered = await self.unanswered.get(channel.id) + if unanswered: + self.bot.stats.incr("help.sessions.unanswered") + elif unanswered is not None: + self.bot.stats.incr("help.sessions.answered") + + log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") + log.trace(f"Sending dormant message for #{channel} ({channel.id}).") + embed = discord.Embed(description=DORMANT_MSG) + await channel.send(embed=embed) + + await self.unpin(channel) + + log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") + self.channel_queue.put_nowait(channel) + self.report_stats() + + async def move_to_in_use(self, channel: discord.TextChannel) -> None: + """Make a channel in-use and schedule it to be made dormant.""" + log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") + + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_in_use, + ) + + timeout = constants.HelpChannels.idle_minutes * 60 + + log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") + self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) + self.report_stats() + + async def notify(self) -> None: + """ + Send a message notifying about a lack of available help channels. + + Configuration: + + * `HelpChannels.notify` - toggle notifications + * `HelpChannels.notify_channel` - destination channel for notifications + * `HelpChannels.notify_minutes` - minimum interval between notifications + * `HelpChannels.notify_roles` - roles mentioned in notifications + """ + if not constants.HelpChannels.notify: + return + + log.trace("Notifying about lack of channels.") + + if self.last_notification: + elapsed = (datetime.utcnow() - self.last_notification).seconds + minimum_interval = constants.HelpChannels.notify_minutes * 60 + should_send = elapsed >= minimum_interval + else: + should_send = True + + if not should_send: + log.trace("Notification not sent because it's too recent since the previous one.") + return + + try: + log.trace("Sending notification message.") + + channel = self.bot.get_channel(constants.HelpChannels.notify_channel) + mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] + + message = await channel.send( + f"{mentions} A new available help channel is needed but there " + f"are no more dormant ones. Consider freeing up some in-use channels manually by " + f"using the `{constants.Bot.prefix}dormant` command within the channels.", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + + self.bot.stats.incr("help.out_of_channel_alerts") + + self.last_notification = message.created_at + except Exception: + # Handle it here cause this feature isn't critical for the functionality of the system. + log.exception("Failed to send notification about lack of dormant channels!") + + async def check_for_answer(self, message: discord.Message) -> None: + """Checks for whether new content in a help channel comes from non-claimants.""" + channel = message.channel + + # Confirm the channel is an in use help channel + if channel_utils.is_in_category(channel, constants.Categories.help_in_use): + log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") + + # Check if there is an entry in unanswered + if await self.unanswered.contains(channel.id): + claimant_id = await self.help_channel_claimants.get(channel.id) + if not claimant_id: + # The mapping for this channel doesn't exist, we can't do anything. + return + + # Check the message did not come from the claimant + if claimant_id != message.author.id: + # Mark the channel as answered + await self.unanswered.set(channel.id, False) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Move an available channel to the In Use category and replace it with a dormant one.""" + if message.author.bot: + return # Ignore messages sent by bots. + + channel = message.channel + + await self.check_for_answer(message) + + is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) + if not is_available or self.is_excluded_channel(channel): + return # Ignore messages outside the Available category or in excluded channels. + + log.trace("Waiting for the cog to be ready before processing messages.") + await self.ready.wait() + + log.trace("Acquiring lock to prevent a channel from being processed twice...") + async with self.on_message_lock: + log.trace(f"on_message lock acquired for {message.id}.") + + if not channel_utils.is_in_category(channel, constants.Categories.help_available): + log.debug( + f"Message {message.id} will not make #{channel} ({channel.id}) in-use " + f"because another message in the channel already triggered that." + ) + return + + log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") + await self.move_to_in_use(channel) + await self.revoke_send_permissions(message.author) + + await self.pin(message) + + # Add user with channel for dormant check. + await self.help_channel_claimants.set(channel.id, message.author.id) + + self.bot.stats.incr("help.claimed") + + # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. + timestamp = datetime.now(timezone.utc).timestamp() + await self.claim_times.set(channel.id, timestamp) + + await self.unanswered.set(channel.id, True) + + log.trace(f"Releasing on_message lock for {message.id}.") + + # Move a dormant channel to the Available category to fill in the gap. + # This is done last and outside the lock because it may wait indefinitely for a channel to + # be put in the queue. + await self.move_to_available() + + @commands.Cog.listener() + async def on_message_delete(self, msg: discord.Message) -> None: + """ + Reschedule an in-use channel to become dormant sooner if the channel is empty. + + The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. + """ + if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): + return + + if not await self.is_empty(msg.channel): + return + + log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") + + # Cancel existing dormant task before scheduling new. + self.scheduler.cancel(msg.channel.id) + + delay = constants.HelpChannels.deleted_idle_minutes * 60 + self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) + + async def is_empty(self, channel: discord.TextChannel) -> bool: + """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + log.trace(f"Checking if #{channel} ({channel.id}) is empty.") + + # A limit of 100 results in a single API call. + # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. + # Not gonna do an extensive search for it cause it's too expensive. + async for msg in channel.history(limit=100): + if not msg.author.bot: + log.trace(f"#{channel} ({channel.id}) has a non-bot message.") + return False + + if self.match_bot_embed(msg, AVAILABLE_MSG): + log.trace(f"#{channel} ({channel.id}) has the available message embed.") + return True + + return False + + async def check_cooldowns(self) -> None: + """Remove expired cooldowns and re-schedule active ones.""" + log.trace("Checking all cooldowns to remove or re-schedule them.") + guild = self.bot.get_guild(constants.Guild.id) + cooldown = constants.HelpChannels.claim_minutes * 60 + + for channel_id, member_id in await self.help_channel_claimants.items(): + member = guild.get_member(member_id) + if not member: + continue # Member probably left the guild. + + in_use_time = await self.get_in_use_time(channel_id) + + if not in_use_time or in_use_time.seconds > cooldown: + # Remove the role if no claim time could be retrieved or if the cooldown expired. + # Since the channel is in the claimants cache, it is definitely strange for a time + # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. + await self.remove_cooldown_role(member) + else: + # The member is still on a cooldown; re-schedule it for the remaining time. + delay = cooldown - in_use_time.seconds + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) + + async def add_cooldown_role(self, member: discord.Member) -> None: + """Add the help cooldown role to `member`.""" + log.trace(f"Adding cooldown role for {member} ({member.id}).") + await self._change_cooldown_role(member, member.add_roles) + + async def remove_cooldown_role(self, member: discord.Member) -> None: + """Remove the help cooldown role from `member`.""" + log.trace(f"Removing cooldown role for {member} ({member.id}).") + await self._change_cooldown_role(member, member.remove_roles) + + async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: + """ + Change `member`'s cooldown role via awaiting `coro_func` and handle errors. + + `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + guild = self.bot.get_guild(constants.Guild.id) + role = guild.get_role(constants.Roles.help_cooldown) + if role is None: + log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") + return + + try: + await coro_func(role) + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") + + async def revoke_send_permissions(self, member: discord.Member) -> None: + """ + Disallow `member` to send messages in the Available category for a certain time. + + The time until permissions are reinstated can be configured with + `HelpChannels.claim_minutes`. + """ + log.trace( + f"Revoking {member}'s ({member.id}) send message permissions in the Available category." + ) + + await self.add_cooldown_role(member) + + # Cancel the existing task, if any. + # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). + if member.id in self.scheduler: + self.scheduler.cancel(member.id) + + delay = constants.HelpChannels.claim_minutes * 60 + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) + + async def send_available_message(self, channel: discord.TextChannel) -> None: + """Send the available message by editing a dormant message or sending a new message.""" + channel_info = f"#{channel} ({channel.id})" + log.trace(f"Sending available message in {channel_info}.") + + embed = discord.Embed( + color=constants.Colours.bright_green, + description=AVAILABLE_MSG, + ) + embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) + embed.set_footer(text=AVAILABLE_FOOTER) + + msg = await self.get_last_message(channel) + if self.match_bot_embed(msg, DORMANT_MSG): + log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") + await msg.edit(embed=embed) + else: + log.trace(f"Dormant message not found in {channel_info}; sending a new message.") + await channel.send(embed=embed) + + async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: + """ + Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. + + Return True if successful and False otherwise. + """ + channel_str = f"#{channel} ({channel.id})" + if pin: + func = self.bot.http.pin_message + verb = "pin" + else: + func = self.bot.http.unpin_message + verb = "unpin" + + try: + await func(channel.id, msg_id) + except discord.HTTPException as e: + if e.code == 10008: + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") + else: + log.exception( + f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" + ) + return False + else: + log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") + return True + + async def pin(self, message: discord.Message) -> None: + """Pin an initial question `message` and store it in a cache.""" + if await self.pin_wrapper(message.id, message.channel, pin=True): + await self.question_messages.set(message.channel.id, message.id) + + async def unpin(self, channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await self.question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") + else: + await self.pin_wrapper(msg_id, channel, pin=False) + + async def wait_for_dormant_channel(self) -> discord.TextChannel: + """Wait for a dormant channel to become available in the queue and return it.""" + log.trace("Waiting for a dormant channel.") + + task = asyncio.create_task(self.channel_queue.get()) + self.queue_tasks.append(task) + channel = await task + + log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") + self.queue_tasks.remove(task) + + return channel + + +def validate_config() -> None: + """Raise a ValueError if the cog's config is invalid.""" + log.trace("Validating config.") + total = constants.HelpChannels.max_total_channels + available = constants.HelpChannels.max_available + + if total == 0 or available == 0: + raise ValueError("max_total_channels and max_available and must be greater than 0.") + + if total < available: + raise ValueError( + f"max_total_channels ({total}) must be greater than or equal to max_available " + f"({available})." + ) + + if total > MAX_CHANNELS_PER_CATEGORY: + raise ValueError( + f"max_total_channels ({total}) must be less than or equal to " + f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." + ) -- cgit v1.2.3 From a1b2e8e9bff23ad5c9c8db8fa35116e7ce6a4965 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 11:44:23 -0700 Subject: Help channels: remove get_clean_channel_name Emoji are no longer used in channel names due to harsher rate limits for renaming channels. Therefore, the function is obsolete. --- bot/exts/help_channels/_cog.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5e2a7dd71..00dd36304 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -251,21 +251,6 @@ class HelpChannels(commands.Cog): return channel - @staticmethod - def get_clean_channel_name(channel: discord.TextChannel) -> str: - """Return a clean channel name without status emojis prefix.""" - prefix = constants.HelpChannels.name_prefix - try: - # Try to remove the status prefix using the index of the channel prefix - name = channel.name[channel.name.index(prefix):] - log.trace(f"The clean name for `{channel}` is `{name}`") - except ValueError: - # If, for some reason, the channel name does not contain "help-" fall back gracefully - log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") - name = channel.name - - return name - @staticmethod def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: """Check if a channel should be excluded from the help channel system.""" @@ -317,7 +302,7 @@ class HelpChannels(commands.Cog): names = set() for cat in (self.available_category, self.in_use_category, self.dormant_category): for channel in self.get_category_channels(cat): - names.add(self.get_clean_channel_name(channel)) + names.add(channel.name) if len(names) > MAX_CHANNELS_PER_CATEGORY: log.warning( -- cgit v1.2.3 From abd6953a0f1567b6ff25d971a97eed966f469743 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:10:46 -0700 Subject: Help channels: move name and channel funcs to separate modules --- bot/exts/help_channels/_channels.py | 26 +++++++++++ bot/exts/help_channels/_cog.py | 93 ++++++------------------------------- bot/exts/help_channels/_names.py | 69 +++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 79 deletions(-) create mode 100644 bot/exts/help_channels/_channels.py create mode 100644 bot/exts/help_channels/_names.py diff --git a/bot/exts/help_channels/_channels.py b/bot/exts/help_channels/_channels.py new file mode 100644 index 000000000..047f41e89 --- /dev/null +++ b/bot/exts/help_channels/_channels.py @@ -0,0 +1,26 @@ +import logging +import typing as t + +import discord + +from bot import constants + +log = logging.getLogger(__name__) + +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) + + +def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + """Yield the text channels of the `category` in an unsorted manner.""" + log.trace(f"Getting text channels in the category '{category}' ({category.id}).") + + # This is faster than using category.channels because the latter sorts them. + for channel in category.guild.channels: + if channel.category_id == category.id and not is_excluded_channel(channel): + yield channel + + +def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: + """Check if a channel should be excluded from the help channel system.""" + return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 00dd36304..1db597e6c 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -1,11 +1,8 @@ import asyncio -import json import logging import random import typing as t -from collections import deque from datetime import datetime, timedelta, timezone -from pathlib import Path import discord import discord.abc @@ -14,14 +11,14 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.exts.help_channels import _channels +from bot.exts.help_channels._names import MAX_CHANNELS_PER_CATEGORY, create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. @@ -123,7 +120,6 @@ class HelpChannels(commands.Cog): self.channel_queue: asyncio.Queue[discord.TextChannel] = None self.name_queue: t.Deque[str] = None - self.name_positions = self.get_names() self.last_notification: t.Optional[datetime] = None # Asyncio stuff @@ -151,7 +147,7 @@ class HelpChannels(commands.Cog): """ log.trace("Creating the channel queue.") - channels = list(self.get_category_channels(self.dormant_category)) + channels = list(_channels.get_category_channels(self.dormant_category)) random.shuffle(channels) log.trace("Populating the channel queue with channels.") @@ -180,18 +176,6 @@ class HelpChannels(commands.Cog): log.debug(f"Creating a new dormant channel named {name}.") return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) - def create_name_queue(self) -> deque: - """Return a queue of element names to use for creating new channels.""" - log.trace("Creating the chemical element name queue.") - - used_names = self.get_used_names() - - log.trace("Determining the available names.") - available_names = (name for name in self.name_positions if name not in used_names) - - log.trace("Populating the name queue with names.") - return deque(available_names) - async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user is the help channel claimant or passes the role check.""" if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: @@ -251,20 +235,6 @@ class HelpChannels(commands.Cog): return channel - @staticmethod - def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: - """Check if a channel should be excluded from the help channel system.""" - return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS - - def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - - # This is faster than using category.channels because the latter sorts them. - for channel in self.bot.get_guild(constants.Guild.id).channels: - if channel.category_id == category.id and not self.is_excluded_channel(channel): - yield channel - async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: """Return the duration `channel_id` has been in use. Return None if it's not in use.""" log.trace(f"Calculating in use time for channel {channel_id}.") @@ -274,45 +244,6 @@ class HelpChannels(commands.Cog): claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed - @staticmethod - def get_names() -> t.List[str]: - """ - Return a truncated list of prefixed element names. - - The amount of names is configured with `HelpChannels.max_total_channels`. - The prefix is configured with `HelpChannels.name_prefix`. - """ - count = constants.HelpChannels.max_total_channels - prefix = constants.HelpChannels.name_prefix - - log.trace(f"Getting the first {count} element names from JSON.") - - with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - all_names = json.load(elements_file) - - if prefix: - return [prefix + name for name in all_names[:count]] - else: - return all_names[:count] - - def get_used_names(self) -> t.Set[str]: - """Return channel names which are already being used.""" - log.trace("Getting channel names which are already being used.") - - names = set() - for cat in (self.available_category, self.in_use_category, self.dormant_category): - for channel in self.get_category_channels(cat): - names.add(channel.name) - - if len(names) > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"Too many help channels ({len(names)}) already exist! " - f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." - ) - - log.trace(f"Got {len(names)} used names: {names}") - return names - @classmethod async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: """ @@ -347,7 +278,7 @@ class HelpChannels(commands.Cog): """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") - channels = list(self.get_category_channels(self.available_category)) + channels = list(_channels.get_category_channels(self.available_category)) missing = constants.HelpChannels.max_available - len(channels) # If we've got less than `max_available` channel available, we should add some. @@ -391,10 +322,14 @@ class HelpChannels(commands.Cog): await self.check_cooldowns() self.channel_queue = self.create_channel_queue() - self.name_queue = self.create_name_queue() + self.name_queue = create_name_queue( + self.available_category, + self.in_use_category, + self.dormant_category, + ) log.trace("Moving or rescheduling in-use channels.") - for channel in self.get_category_channels(self.in_use_category): + for channel in _channels.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) # Prevent the command from being used until ready. @@ -412,9 +347,9 @@ class HelpChannels(commands.Cog): def report_stats(self) -> None: """Report the channel count stats.""" - total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in self.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) + total_in_use = sum(1 for _ in _channels.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in _channels.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in _channels.get_category_channels(self.dormant_category)) self.bot.stats.gauge("help.total.in_use", total_in_use) self.bot.stats.gauge("help.total.available", total_available) @@ -660,7 +595,7 @@ class HelpChannels(commands.Cog): await self.check_for_answer(message) is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or self.is_excluded_channel(channel): + if not is_available or _channels.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") diff --git a/bot/exts/help_channels/_names.py b/bot/exts/help_channels/_names.py new file mode 100644 index 000000000..9959c105e --- /dev/null +++ b/bot/exts/help_channels/_names.py @@ -0,0 +1,69 @@ +import json +import logging +import typing as t +from collections import deque +from pathlib import Path + +import discord + +from bot import constants +from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY, get_category_channels + +log = logging.getLogger(__name__) + + +def create_name_queue(*categories: discord.CategoryChannel) -> deque: + """ + Return a queue of element names to use for creating new channels. + + Skip names that are already in use by channels in `categories`. + """ + log.trace("Creating the chemical element name queue.") + + used_names = _get_used_names(*categories) + + log.trace("Determining the available names.") + available_names = (name for name in _get_names() if name not in used_names) + + log.trace("Populating the name queue with names.") + return deque(available_names) + + +def _get_names() -> t.List[str]: + """ + Return a truncated list of prefixed element names. + + The amount of names is configured with `HelpChannels.max_total_channels`. + The prefix is configured with `HelpChannels.name_prefix`. + """ + count = constants.HelpChannels.max_total_channels + prefix = constants.HelpChannels.name_prefix + + log.trace(f"Getting the first {count} element names from JSON.") + + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + all_names = json.load(elements_file) + + if prefix: + return [prefix + name for name in all_names[:count]] + else: + return all_names[:count] + + +def _get_used_names(*categories: discord.CategoryChannel) -> t.Set[str]: + """Return names which are already being used by channels in `categories`.""" + log.trace("Getting channel names which are already being used.") + + names = set() + for cat in categories: + for channel in get_category_channels(cat): + names.add(channel.name) + + if len(names) > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"Too many help channels ({len(names)}) already exist! " + f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." + ) + + log.trace(f"Got {len(names)} used names: {names}") + return names -- cgit v1.2.3 From 0cc221a6169bd12ddcc605eb02f2785716d2446e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:14:19 -0700 Subject: Help channels: move validation code to __init__.py --- bot/exts/help_channels/__init__.py | 32 ++++++++++++++++++++++++++++---- bot/exts/help_channels/_cog.py | 24 +----------------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 38444b707..6ed94ebda 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -1,17 +1,41 @@ import logging +from bot import constants from bot.bot import Bot +from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY log = logging.getLogger(__name__) +def validate_config() -> None: + """Raise a ValueError if the cog's config is invalid.""" + log.trace("Validating config.") + total = constants.HelpChannels.max_total_channels + available = constants.HelpChannels.max_available + + if total == 0 or available == 0: + raise ValueError("max_total_channels and max_available and must be greater than 0.") + + if total < available: + raise ValueError( + f"max_total_channels ({total}) must be greater than or equal to max_available " + f"({available})." + ) + + if total > MAX_CHANNELS_PER_CATEGORY: + raise ValueError( + f"max_total_channels ({total}) must be less than or equal to " + f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." + ) + + def setup(bot: Bot) -> None: """Load the HelpChannels cog.""" - # Defer import to reduce side effects from importing the sync package. - from bot.exts.help_channels import _cog + # Defer import to reduce side effects from importing the help_channels package. + from bot.exts.help_channels._cog import HelpChannels try: - _cog.validate_config() + validate_config() except ValueError as e: log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") else: - bot.add_cog(_cog.HelpChannels(bot)) + bot.add_cog(HelpChannels(bot)) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 1db597e6c..d8fb3b830 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,7 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _channels -from bot.exts.help_channels._names import MAX_CHANNELS_PER_CATEGORY, create_name_queue +from bot.exts.help_channels._names import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -826,25 +826,3 @@ class HelpChannels(commands.Cog): self.queue_tasks.remove(task) return channel - - -def validate_config() -> None: - """Raise a ValueError if the cog's config is invalid.""" - log.trace("Validating config.") - total = constants.HelpChannels.max_total_channels - available = constants.HelpChannels.max_available - - if total == 0 or available == 0: - raise ValueError("max_total_channels and max_available and must be greater than 0.") - - if total < available: - raise ValueError( - f"max_total_channels ({total}) must be greater than or equal to max_available " - f"({available})." - ) - - if total > MAX_CHANNELS_PER_CATEGORY: - raise ValueError( - f"max_total_channels ({total}) must be less than or equal to " - f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." - ) -- cgit v1.2.3 From aa7eff22759e18bcbe0373e5397eb225f563e001 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 12:18:19 -0700 Subject: Help channels: rename modules to use singular tense Plural names imply the modules contain homogenous content. For example, "channels" implies the module contains multiple kinds of channels. --- bot/exts/help_channels/__init__.py | 2 +- bot/exts/help_channels/_channel.py | 26 ++++++++++++++ bot/exts/help_channels/_channels.py | 26 -------------- bot/exts/help_channels/_cog.py | 18 +++++----- bot/exts/help_channels/_name.py | 69 +++++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_names.py | 69 ------------------------------------- 6 files changed, 105 insertions(+), 105 deletions(-) create mode 100644 bot/exts/help_channels/_channel.py delete mode 100644 bot/exts/help_channels/_channels.py create mode 100644 bot/exts/help_channels/_name.py delete mode 100644 bot/exts/help_channels/_names.py diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 6ed94ebda..781f40449 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -2,7 +2,7 @@ import logging from bot import constants from bot.bot import Bot -from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY +from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY log = logging.getLogger(__name__) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py new file mode 100644 index 000000000..047f41e89 --- /dev/null +++ b/bot/exts/help_channels/_channel.py @@ -0,0 +1,26 @@ +import logging +import typing as t + +import discord + +from bot import constants + +log = logging.getLogger(__name__) + +MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) + + +def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + """Yield the text channels of the `category` in an unsorted manner.""" + log.trace(f"Getting text channels in the category '{category}' ({category.id}).") + + # This is faster than using category.channels because the latter sorts them. + for channel in category.guild.channels: + if channel.category_id == category.id and not is_excluded_channel(channel): + yield channel + + +def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: + """Check if a channel should be excluded from the help channel system.""" + return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS diff --git a/bot/exts/help_channels/_channels.py b/bot/exts/help_channels/_channels.py deleted file mode 100644 index 047f41e89..000000000 --- a/bot/exts/help_channels/_channels.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging -import typing as t - -import discord - -from bot import constants - -log = logging.getLogger(__name__) - -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) - - -def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - - # This is faster than using category.channels because the latter sorts them. - for channel in category.guild.channels: - if channel.category_id == category.id and not is_excluded_channel(channel): - yield channel - - -def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: - """Check if a channel should be excluded from the help channel system.""" - return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index d8fb3b830..e58660af8 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,8 +11,8 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channels -from bot.exts.help_channels._names import create_name_queue +from bot.exts.help_channels import _channel +from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -147,7 +147,7 @@ class HelpChannels(commands.Cog): """ log.trace("Creating the channel queue.") - channels = list(_channels.get_category_channels(self.dormant_category)) + channels = list(_channel.get_category_channels(self.dormant_category)) random.shuffle(channels) log.trace("Populating the channel queue with channels.") @@ -278,7 +278,7 @@ class HelpChannels(commands.Cog): """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") - channels = list(_channels.get_category_channels(self.available_category)) + channels = list(_channel.get_category_channels(self.available_category)) missing = constants.HelpChannels.max_available - len(channels) # If we've got less than `max_available` channel available, we should add some. @@ -329,7 +329,7 @@ class HelpChannels(commands.Cog): ) log.trace("Moving or rescheduling in-use channels.") - for channel in _channels.get_category_channels(self.in_use_category): + for channel in _channel.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) # Prevent the command from being used until ready. @@ -347,9 +347,9 @@ class HelpChannels(commands.Cog): def report_stats(self) -> None: """Report the channel count stats.""" - total_in_use = sum(1 for _ in _channels.get_category_channels(self.in_use_category)) - total_available = sum(1 for _ in _channels.get_category_channels(self.available_category)) - total_dormant = sum(1 for _ in _channels.get_category_channels(self.dormant_category)) + total_in_use = sum(1 for _ in _channel.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in _channel.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in _channel.get_category_channels(self.dormant_category)) self.bot.stats.gauge("help.total.in_use", total_in_use) self.bot.stats.gauge("help.total.available", total_available) @@ -595,7 +595,7 @@ class HelpChannels(commands.Cog): await self.check_for_answer(message) is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) - if not is_available or _channels.is_excluded_channel(channel): + if not is_available or _channel.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") diff --git a/bot/exts/help_channels/_name.py b/bot/exts/help_channels/_name.py new file mode 100644 index 000000000..728234b1e --- /dev/null +++ b/bot/exts/help_channels/_name.py @@ -0,0 +1,69 @@ +import json +import logging +import typing as t +from collections import deque +from pathlib import Path + +import discord + +from bot import constants +from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY, get_category_channels + +log = logging.getLogger(__name__) + + +def create_name_queue(*categories: discord.CategoryChannel) -> deque: + """ + Return a queue of element names to use for creating new channels. + + Skip names that are already in use by channels in `categories`. + """ + log.trace("Creating the chemical element name queue.") + + used_names = _get_used_names(*categories) + + log.trace("Determining the available names.") + available_names = (name for name in _get_names() if name not in used_names) + + log.trace("Populating the name queue with names.") + return deque(available_names) + + +def _get_names() -> t.List[str]: + """ + Return a truncated list of prefixed element names. + + The amount of names is configured with `HelpChannels.max_total_channels`. + The prefix is configured with `HelpChannels.name_prefix`. + """ + count = constants.HelpChannels.max_total_channels + prefix = constants.HelpChannels.name_prefix + + log.trace(f"Getting the first {count} element names from JSON.") + + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + all_names = json.load(elements_file) + + if prefix: + return [prefix + name for name in all_names[:count]] + else: + return all_names[:count] + + +def _get_used_names(*categories: discord.CategoryChannel) -> t.Set[str]: + """Return names which are already being used by channels in `categories`.""" + log.trace("Getting channel names which are already being used.") + + names = set() + for cat in categories: + for channel in get_category_channels(cat): + names.add(channel.name) + + if len(names) > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"Too many help channels ({len(names)}) already exist! " + f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." + ) + + log.trace(f"Got {len(names)} used names: {names}") + return names diff --git a/bot/exts/help_channels/_names.py b/bot/exts/help_channels/_names.py deleted file mode 100644 index 9959c105e..000000000 --- a/bot/exts/help_channels/_names.py +++ /dev/null @@ -1,69 +0,0 @@ -import json -import logging -import typing as t -from collections import deque -from pathlib import Path - -import discord - -from bot import constants -from bot.exts.help_channels._channels import MAX_CHANNELS_PER_CATEGORY, get_category_channels - -log = logging.getLogger(__name__) - - -def create_name_queue(*categories: discord.CategoryChannel) -> deque: - """ - Return a queue of element names to use for creating new channels. - - Skip names that are already in use by channels in `categories`. - """ - log.trace("Creating the chemical element name queue.") - - used_names = _get_used_names(*categories) - - log.trace("Determining the available names.") - available_names = (name for name in _get_names() if name not in used_names) - - log.trace("Populating the name queue with names.") - return deque(available_names) - - -def _get_names() -> t.List[str]: - """ - Return a truncated list of prefixed element names. - - The amount of names is configured with `HelpChannels.max_total_channels`. - The prefix is configured with `HelpChannels.name_prefix`. - """ - count = constants.HelpChannels.max_total_channels - prefix = constants.HelpChannels.name_prefix - - log.trace(f"Getting the first {count} element names from JSON.") - - with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - all_names = json.load(elements_file) - - if prefix: - return [prefix + name for name in all_names[:count]] - else: - return all_names[:count] - - -def _get_used_names(*categories: discord.CategoryChannel) -> t.Set[str]: - """Return names which are already being used by channels in `categories`.""" - log.trace("Getting channel names which are already being used.") - - names = set() - for cat in categories: - for channel in get_category_channels(cat): - names.add(channel.name) - - if len(names) > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"Too many help channels ({len(names)}) already exist! " - f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." - ) - - log.trace(f"Got {len(names)} used names: {names}") - return names -- cgit v1.2.3 From e7d6b2ba81e3609bd52e2d0c4c9d999e7deb14e8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 13:31:13 -0700 Subject: Help channels: move pin functions to a separate module --- bot/exts/help_channels/_cog.py | 50 ++-------------------------------- bot/exts/help_channels/_message.py | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 47 deletions(-) create mode 100644 bot/exts/help_channels/_message.py diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e58660af8..b3d720b24 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,6 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot from bot.exts.help_channels import _channel +from bot.exts.help_channels._message import pin, unpin from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -103,10 +104,6 @@ class HelpChannels(commands.Cog): # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] claim_times = RedisCache() - # This cache maps a help channel to original question message in same channel. - # RedisCache[discord.TextChannel.id, discord.Message.id] - question_messages = RedisCache() - def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) @@ -495,7 +492,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) - await self.unpin(channel) + await unpin(channel) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) @@ -616,7 +613,7 @@ class HelpChannels(commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - await self.pin(message) + await pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) @@ -773,47 +770,6 @@ class HelpChannels(commands.Cog): log.trace(f"Dormant message not found in {channel_info}; sending a new message.") await channel.send(embed=embed) - async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: - """ - Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - - Return True if successful and False otherwise. - """ - channel_str = f"#{channel} ({channel.id})" - if pin: - func = self.bot.http.pin_message - verb = "pin" - else: - func = self.bot.http.unpin_message - verb = "unpin" - - try: - await func(channel.id, msg_id) - except discord.HTTPException as e: - if e.code == 10008: - log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") - else: - log.exception( - f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" - ) - return False - else: - log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") - return True - - async def pin(self, message: discord.Message) -> None: - """Pin an initial question `message` and store it in a cache.""" - if await self.pin_wrapper(message.id, message.channel, pin=True): - await self.question_messages.set(message.channel.id, message.id) - - async def unpin(self, channel: discord.TextChannel) -> None: - """Unpin the initial question message sent in `channel`.""" - msg_id = await self.question_messages.pop(channel.id) - if msg_id is None: - log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") - else: - await self.pin_wrapper(msg_id, channel, pin=False) - async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py new file mode 100644 index 000000000..e593aacc9 --- /dev/null +++ b/bot/exts/help_channels/_message.py @@ -0,0 +1,56 @@ +import logging + +import discord +from async_rediscache import RedisCache + +import bot + +log = logging.getLogger(__name__) + +# This cache maps a help channel to original question message in same channel. +# RedisCache[discord.TextChannel.id, discord.Message.id] +_question_messages = RedisCache(namespace="HelpChannels.question_messages") + + +async def pin(message: discord.Message) -> None: + """Pin an initial question `message` and store it in a cache.""" + if await _pin_wrapper(message.id, message.channel, pin=True): + await _question_messages.set(message.channel.id, message.id) + + +async def unpin(channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await _question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") + else: + await _pin_wrapper(msg_id, channel, pin=False) + + +async def _pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: + """ + Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. + + Return True if successful and False otherwise. + """ + channel_str = f"#{channel} ({channel.id})" + if pin: + func = bot.instance.http.pin_message + verb = "pin" + else: + func = bot.instance.http.unpin_message + verb = "unpin" + + try: + await func(channel.id, msg_id) + except discord.HTTPException as e: + if e.code == 10008: + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") + else: + log.exception( + f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" + ) + return False + else: + log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") + return True -- cgit v1.2.3 From e30d69f18dbfefefbc4034d744d0fad5198567c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Oct 2020 14:01:20 -0700 Subject: Help channels: move message functions to message module --- bot/exts/help_channels/_cog.py | 197 ++++--------------------------------- bot/exts/help_channels/_message.py | 172 ++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 178 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index b3d720b24..174c40096 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,47 +11,17 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channel -from bot.exts.help_channels._message import pin, unpin +from bot.exts.help_channels import _channel, _message from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" - HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ -AVAILABLE_MSG = f""" -**Send your question here to claim the channel** -This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. - -**Keep in mind:** -• It's always ok to just ask your question. You don't need permission. -• Explain what you expect to happen and what actually happens. -• Include a code sample and error message, if you got any. - -For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. -""" - -AVAILABLE_TITLE = "Available help channel" - -AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." - -DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ -category at the bottom of the channel list. It is no longer possible to send messages in this \ -channel until it becomes available again. - -If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ -question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. -""" - CoroutineFunc = t.Callable[..., t.Coroutine] @@ -94,12 +64,6 @@ class HelpChannels(commands.Cog): # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] help_channel_claimants = RedisCache() - # This cache maps a help channel to whether it has had any - # activity other than the original claimant. True being no other - # activity and False being other activity. - # RedisCache[discord.TextChannel.id, bool] - unanswered = RedisCache() - # This dictionary maps a help channel to the time it was claimed # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] claim_times = RedisCache() @@ -227,7 +191,12 @@ class HelpChannels(commands.Cog): if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - await self.notify() + notify_channel = self.bot.get_channel(constants.HelpChannels.notify_channel) + last_notification = await _message.notify(notify_channel, self.last_notification) + if last_notification: + self.last_notification = last_notification + self.bot.stats.incr("help.out_of_channel_alerts") + channel = await self.wait_for_dormant_channel() return channel @@ -241,8 +210,8 @@ class HelpChannels(commands.Cog): claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed - @classmethod - async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: + @staticmethod + async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: """ Return the time elapsed, in seconds, since the last message sent in the `channel`. @@ -250,7 +219,7 @@ class HelpChannels(commands.Cog): """ log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - msg = await cls.get_last_message(channel) + msg = await _message.get_last_message(channel) if not msg: log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") return None @@ -260,17 +229,6 @@ class HelpChannels(commands.Cog): log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") return idle_time - @staticmethod - async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: - """Return the last message sent in the channel or None if no messages exist.""" - log.trace(f"Getting the last message in #{channel} ({channel.id}).") - - try: - return await channel.history(limit=1).next() # noqa: B305 - except discord.NoMoreItems: - log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") - return None - async def init_available(self) -> None: """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") @@ -357,17 +315,6 @@ class HelpChannels(commands.Cog): """Return True if `member` has the 'Help Cooldown' role.""" return any(constants.Roles.help_cooldown == role.id for role in member.roles) - def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: - """Return `True` if the bot's `message`'s embed description matches `description`.""" - if not message or not message.embeds: - return False - - bot_msg_desc = message.embeds[0].description - if bot_msg_desc is discord.Embed.Empty: - log.trace("Last message was a bot embed but it was empty.") - return False - return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -377,7 +324,7 @@ class HelpChannels(commands.Cog): """ log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - if not await self.is_empty(channel): + if not await _message.is_empty(channel): idle_seconds = constants.HelpChannels.idle_minutes * 60 else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 @@ -450,7 +397,7 @@ class HelpChannels(commands.Cog): channel = await self.get_available_candidate() log.info(f"Making #{channel} ({channel.id}) available.") - await self.send_available_message(channel) + await _message.send_available_message(channel) log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") @@ -481,7 +428,7 @@ class HelpChannels(commands.Cog): if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) - unanswered = await self.unanswered.get(channel.id) + unanswered = await _message.unanswered.get(channel.id) if unanswered: self.bot.stats.incr("help.sessions.unanswered") elif unanswered is not None: @@ -489,10 +436,10 @@ class HelpChannels(commands.Cog): log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed(description=DORMANT_MSG) + embed = discord.Embed(description=_message.DORMANT_MSG) await channel.send(embed=embed) - await unpin(channel) + await _message.unpin(channel) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) @@ -513,74 +460,6 @@ class HelpChannels(commands.Cog): self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) self.report_stats() - async def notify(self) -> None: - """ - Send a message notifying about a lack of available help channels. - - Configuration: - - * `HelpChannels.notify` - toggle notifications - * `HelpChannels.notify_channel` - destination channel for notifications - * `HelpChannels.notify_minutes` - minimum interval between notifications - * `HelpChannels.notify_roles` - roles mentioned in notifications - """ - if not constants.HelpChannels.notify: - return - - log.trace("Notifying about lack of channels.") - - if self.last_notification: - elapsed = (datetime.utcnow() - self.last_notification).seconds - minimum_interval = constants.HelpChannels.notify_minutes * 60 - should_send = elapsed >= minimum_interval - else: - should_send = True - - if not should_send: - log.trace("Notification not sent because it's too recent since the previous one.") - return - - try: - log.trace("Sending notification message.") - - channel = self.bot.get_channel(constants.HelpChannels.notify_channel) - mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) - allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] - - message = await channel.send( - f"{mentions} A new available help channel is needed but there " - f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels.", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - - self.bot.stats.incr("help.out_of_channel_alerts") - - self.last_notification = message.created_at - except Exception: - # Handle it here cause this feature isn't critical for the functionality of the system. - log.exception("Failed to send notification about lack of dormant channels!") - - async def check_for_answer(self, message: discord.Message) -> None: - """Checks for whether new content in a help channel comes from non-claimants.""" - channel = message.channel - - # Confirm the channel is an in use help channel - if channel_utils.is_in_category(channel, constants.Categories.help_in_use): - log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") - - # Check if there is an entry in unanswered - if await self.unanswered.contains(channel.id): - claimant_id = await self.help_channel_claimants.get(channel.id) - if not claimant_id: - # The mapping for this channel doesn't exist, we can't do anything. - return - - # Check the message did not come from the claimant - if claimant_id != message.author.id: - # Mark the channel as answered - await self.unanswered.set(channel.id, False) - @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" @@ -589,7 +468,7 @@ class HelpChannels(commands.Cog): channel = message.channel - await self.check_for_answer(message) + await _message.check_for_answer(message) is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) if not is_available or _channel.is_excluded_channel(channel): @@ -613,7 +492,7 @@ class HelpChannels(commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - await pin(message) + await _message.pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) @@ -624,7 +503,7 @@ class HelpChannels(commands.Cog): timestamp = datetime.now(timezone.utc).timestamp() await self.claim_times.set(channel.id, timestamp) - await self.unanswered.set(channel.id, True) + await _message.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") @@ -643,7 +522,7 @@ class HelpChannels(commands.Cog): if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return - if not await self.is_empty(msg.channel): + if not await _message.is_empty(msg.channel): return log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") @@ -654,24 +533,6 @@ class HelpChannels(commands.Cog): delay = constants.HelpChannels.deleted_idle_minutes * 60 self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - async def is_empty(self, channel: discord.TextChannel) -> bool: - """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" - log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - - # A limit of 100 results in a single API call. - # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. - # Not gonna do an extensive search for it cause it's too expensive. - async for msg in channel.history(limit=100): - if not msg.author.bot: - log.trace(f"#{channel} ({channel.id}) has a non-bot message.") - return False - - if self.match_bot_embed(msg, AVAILABLE_MSG): - log.trace(f"#{channel} ({channel.id}) has the available message embed.") - return True - - return False - async def check_cooldowns(self) -> None: """Remove expired cooldowns and re-schedule active ones.""" log.trace("Checking all cooldowns to remove or re-schedule them.") @@ -750,26 +611,6 @@ class HelpChannels(commands.Cog): delay = constants.HelpChannels.claim_minutes * 60 self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - async def send_available_message(self, channel: discord.TextChannel) -> None: - """Send the available message by editing a dormant message or sending a new message.""" - channel_info = f"#{channel} ({channel.id})" - log.trace(f"Sending available message in {channel_info}.") - - embed = discord.Embed( - color=constants.Colours.bright_green, - description=AVAILABLE_MSG, - ) - embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) - embed.set_footer(text=AVAILABLE_FOOTER) - - msg = await self.get_last_message(channel) - if self.match_bot_embed(msg, DORMANT_MSG): - log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") - await msg.edit(embed=embed) - else: - log.trace(f"Dormant message not found in {channel_info}; sending a new message.") - await channel.send(embed=embed) - async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index e593aacc9..eaf8b0ab5 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -1,23 +1,183 @@ import logging +import typing as t +from datetime import datetime import discord from async_rediscache import RedisCache import bot +from bot import constants +from bot.utils.channel import is_in_category log = logging.getLogger(__name__) +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" + +AVAILABLE_MSG = f""" +**Send your question here to claim the channel** +This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. + +**Keep in mind:** +• It's always ok to just ask your question. You don't need permission. +• Explain what you expect to happen and what actually happens. +• Include a code sample and error message, if you got any. + +For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. +""" + +AVAILABLE_TITLE = "Available help channel" + +AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close." + +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +category at the bottom of the channel list. It is no longer possible to send messages in this \ +channel until it becomes available again. + +If your question wasn't answered yet, you can claim a new help channel from the \ +**Help: Available** category by simply asking your question again. Consider rephrasing the \ +question to maximize your chance of getting a good answer. If you're not sure how, have a look \ +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. +""" + +# This cache maps a help channel to whether it has had any +# activity other than the original claimant. True being no other +# activity and False being other activity. +# RedisCache[discord.TextChannel.id, bool] +unanswered = RedisCache(namespace="HelpChannels.unanswered") + +# This cache tracks which channels are claimed by which members. +# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] +_help_channel_claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") + # This cache maps a help channel to original question message in same channel. # RedisCache[discord.TextChannel.id, discord.Message.id] _question_messages = RedisCache(namespace="HelpChannels.question_messages") +async def check_for_answer(message: discord.Message) -> None: + """Checks for whether new content in a help channel comes from non-claimants.""" + channel = message.channel + + # Confirm the channel is an in use help channel + if is_in_category(channel, constants.Categories.help_in_use): + log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") + + # Check if there is an entry in unanswered + if await unanswered.contains(channel.id): + claimant_id = await _help_channel_claimants.get(channel.id) + if not claimant_id: + # The mapping for this channel doesn't exist, we can't do anything. + return + + # Check the message did not come from the claimant + if claimant_id != message.author.id: + # Mark the channel as answered + await unanswered.set(channel.id, False) + + +async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: + """Return the last message sent in the channel or None if no messages exist.""" + log.trace(f"Getting the last message in #{channel} ({channel.id}).") + + try: + return await channel.history(limit=1).next() # noqa: B305 + except discord.NoMoreItems: + log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") + return None + + +async def is_empty(channel: discord.TextChannel) -> bool: + """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + log.trace(f"Checking if #{channel} ({channel.id}) is empty.") + + # A limit of 100 results in a single API call. + # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. + # Not gonna do an extensive search for it cause it's too expensive. + async for msg in channel.history(limit=100): + if not msg.author.bot: + log.trace(f"#{channel} ({channel.id}) has a non-bot message.") + return False + + if _match_bot_embed(msg, AVAILABLE_MSG): + log.trace(f"#{channel} ({channel.id}) has the available message embed.") + return True + + return False + + +async def notify(channel: discord.TextChannel, last_notification: t.Optional[datetime]) -> t.Optional[datetime]: + """ + Send a message in `channel` notifying about a lack of available help channels. + + Configuration: + + * `HelpChannels.notify` - toggle notifications + * `HelpChannels.notify_minutes` - minimum interval between notifications + * `HelpChannels.notify_roles` - roles mentioned in notifications + """ + if not constants.HelpChannels.notify: + return + + log.trace("Notifying about lack of channels.") + + if last_notification: + elapsed = (datetime.utcnow() - last_notification).seconds + minimum_interval = constants.HelpChannels.notify_minutes * 60 + should_send = elapsed >= minimum_interval + else: + should_send = True + + if not should_send: + log.trace("Notification not sent because it's too recent since the previous one.") + return + + try: + log.trace("Sending notification message.") + + mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] + + message = await channel.send( + f"{mentions} A new available help channel is needed but there " + f"are no more dormant ones. Consider freeing up some in-use channels manually by " + f"using the `{constants.Bot.prefix}dormant` command within the channels.", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) + + return message.created_at + except Exception: + # Handle it here cause this feature isn't critical for the functionality of the system. + log.exception("Failed to send notification about lack of dormant channels!") + + async def pin(message: discord.Message) -> None: """Pin an initial question `message` and store it in a cache.""" if await _pin_wrapper(message.id, message.channel, pin=True): await _question_messages.set(message.channel.id, message.id) +async def send_available_message(channel: discord.TextChannel) -> None: + """Send the available message by editing a dormant message or sending a new message.""" + channel_info = f"#{channel} ({channel.id})" + log.trace(f"Sending available message in {channel_info}.") + + embed = discord.Embed( + color=constants.Colours.bright_green, + description=AVAILABLE_MSG, + ) + embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) + embed.set_footer(text=AVAILABLE_FOOTER) + + msg = await get_last_message(channel) + if _match_bot_embed(msg, DORMANT_MSG): + log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") + await msg.edit(embed=embed) + else: + log.trace(f"Dormant message not found in {channel_info}; sending a new message.") + await channel.send(embed=embed) + + async def unpin(channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" msg_id = await _question_messages.pop(channel.id) @@ -27,6 +187,18 @@ async def unpin(channel: discord.TextChannel) -> None: await _pin_wrapper(msg_id, channel, pin=False) +def _match_bot_embed(message: t.Optional[discord.Message], description: str) -> bool: + """Return `True` if the bot's `message`'s embed description matches `description`.""" + if not message or not message.embeds: + return False + + bot_msg_desc = message.embeds[0].description + if bot_msg_desc is discord.Embed.Empty: + log.trace("Last message was a bot embed but it was empty.") + return False + return message.author == bot.instance.user and bot_msg_desc.strip() == description.strip() + + async def _pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: """ Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. -- cgit v1.2.3 From 44fe885de67135dd16a44539fc97d8e7fc543400 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:56:08 -0700 Subject: Help channels: move time functions to channel module --- bot/exts/help_channels/_channel.py | 36 ++++++++++++++++++++++++++++++++++++ bot/exts/help_channels/_cog.py | 34 +++------------------------------- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 047f41e89..93c0c7fc9 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -1,15 +1,22 @@ import logging import typing as t +from datetime import datetime, timedelta import discord +from async_rediscache import RedisCache from bot import constants +from bot.exts.help_channels import _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) +# This dictionary maps a help channel to the time it was claimed +# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] +_claim_times = RedisCache(namespace="HelpChannels.claim_times") + def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" @@ -21,6 +28,35 @@ def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[disco yield channel +async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: + """ + Return the time elapsed, in seconds, since the last message sent in the `channel`. + + Return None if the channel has no messages. + """ + log.trace(f"Getting the idle time for #{channel} ({channel.id}).") + + msg = await _message.get_last_message(channel) + if not msg: + log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") + return None + + idle_time = (datetime.utcnow() - msg.created_at).seconds + + log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") + return idle_time + + +async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: + """Return the duration `channel_id` has been in use. Return None if it's not in use.""" + log.trace(f"Calculating in use time for channel {channel_id}.") + + claimed_timestamp = await _claim_times.get(channel_id) + if claimed_timestamp: + claimed = datetime.utcfromtimestamp(claimed_timestamp) + return datetime.utcnow() - claimed + + def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: """Check if a channel should be excluded from the help channel system.""" return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 174c40096..390528fde 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -2,7 +2,7 @@ import asyncio import logging import random import typing as t -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import discord import discord.abc @@ -201,34 +201,6 @@ class HelpChannels(commands.Cog): return channel - async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: - """Return the duration `channel_id` has been in use. Return None if it's not in use.""" - log.trace(f"Calculating in use time for channel {channel_id}.") - - claimed_timestamp = await self.claim_times.get(channel_id) - if claimed_timestamp: - claimed = datetime.utcfromtimestamp(claimed_timestamp) - return datetime.utcnow() - claimed - - @staticmethod - async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: - """ - Return the time elapsed, in seconds, since the last message sent in the `channel`. - - Return None if the channel has no messages. - """ - log.trace(f"Getting the idle time for #{channel} ({channel.id}).") - - msg = await _message.get_last_message(channel) - if not msg: - log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") - return None - - idle_time = (datetime.utcnow() - msg.created_at).seconds - - log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") - return idle_time - async def init_available(self) -> None: """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") @@ -329,7 +301,7 @@ class HelpChannels(commands.Cog): else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 - time_elapsed = await self.get_idle_time(channel) + time_elapsed = await _channel.get_idle_time(channel) if time_elapsed is None or time_elapsed >= idle_seconds: log.info( @@ -424,7 +396,7 @@ class HelpChannels(commands.Cog): self.bot.stats.incr(f"help.dormant_calls.{caller}") - in_use_time = await self.get_in_use_time(channel.id) + in_use_time = await _channel.get_in_use_time(channel.id) if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) -- cgit v1.2.3 From 3843ae1546a1a1cbf7ca05756eee223bf7c2f317 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 10:40:17 -0700 Subject: Help channels: move cooldown/role functions to cooldown module --- bot/exts/help_channels/_cog.py | 88 ++----------------------------- bot/exts/help_channels/_cooldown.py | 100 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 84 deletions(-) create mode 100644 bot/exts/help_channels/_cooldown.py diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 390528fde..169238937 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -11,7 +11,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channel, _message +from bot.exts.help_channels import _channel, _cooldown, _message from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -22,8 +22,6 @@ HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. """ -CoroutineFunc = t.Callable[..., t.Coroutine] - class HelpChannels(commands.Cog): """ @@ -164,7 +162,7 @@ class HelpChannels(commands.Cog): log.trace("close command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): - await self.remove_cooldown_role(ctx.author) + await _cooldown.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. if ctx.author.id in self.scheduler: @@ -246,7 +244,7 @@ class HelpChannels(commands.Cog): log.trace("Initialising the cog.") await self.init_categories() - await self.check_cooldowns() + await _cooldown.check_cooldowns(self.scheduler) self.channel_queue = self.create_channel_queue() self.name_queue = create_name_queue( @@ -462,7 +460,7 @@ class HelpChannels(commands.Cog): log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) - await self.revoke_send_permissions(message.author) + await _cooldown.revoke_send_permissions(message.author, self.scheduler) await _message.pin(message) @@ -505,84 +503,6 @@ class HelpChannels(commands.Cog): delay = constants.HelpChannels.deleted_idle_minutes * 60 self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - async def check_cooldowns(self) -> None: - """Remove expired cooldowns and re-schedule active ones.""" - log.trace("Checking all cooldowns to remove or re-schedule them.") - guild = self.bot.get_guild(constants.Guild.id) - cooldown = constants.HelpChannels.claim_minutes * 60 - - for channel_id, member_id in await self.help_channel_claimants.items(): - member = guild.get_member(member_id) - if not member: - continue # Member probably left the guild. - - in_use_time = await self.get_in_use_time(channel_id) - - if not in_use_time or in_use_time.seconds > cooldown: - # Remove the role if no claim time could be retrieved or if the cooldown expired. - # Since the channel is in the claimants cache, it is definitely strange for a time - # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. - await self.remove_cooldown_role(member) - else: - # The member is still on a cooldown; re-schedule it for the remaining time. - delay = cooldown - in_use_time.seconds - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - - async def add_cooldown_role(self, member: discord.Member) -> None: - """Add the help cooldown role to `member`.""" - log.trace(f"Adding cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.add_roles) - - async def remove_cooldown_role(self, member: discord.Member) -> None: - """Remove the help cooldown role from `member`.""" - log.trace(f"Removing cooldown role for {member} ({member.id}).") - await self._change_cooldown_role(member, member.remove_roles) - - async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: - """ - Change `member`'s cooldown role via awaiting `coro_func` and handle errors. - - `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - guild = self.bot.get_guild(constants.Guild.id) - role = guild.get_role(constants.Roles.help_cooldown) - if role is None: - log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") - return - - try: - await coro_func(role) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - - async def revoke_send_permissions(self, member: discord.Member) -> None: - """ - Disallow `member` to send messages in the Available category for a certain time. - - The time until permissions are reinstated can be configured with - `HelpChannels.claim_minutes`. - """ - log.trace( - f"Revoking {member}'s ({member.id}) send message permissions in the Available category." - ) - - await self.add_cooldown_role(member) - - # Cancel the existing task, if any. - # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - if member.id in self.scheduler: - self.scheduler.cancel(member.id) - - delay = constants.HelpChannels.claim_minutes * 60 - self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) - async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py new file mode 100644 index 000000000..c4fd4b662 --- /dev/null +++ b/bot/exts/help_channels/_cooldown.py @@ -0,0 +1,100 @@ +import logging +from typing import Callable, Coroutine + +import discord +from async_rediscache import RedisCache + +import bot +from bot import constants +from bot.exts.help_channels import _channel +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) +CoroutineFunc = Callable[..., Coroutine] + +# This cache tracks which channels are claimed by which members. +# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] +_help_channel_claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") + + +async def add_cooldown_role(member: discord.Member) -> None: + """Add the help cooldown role to `member`.""" + log.trace(f"Adding cooldown role for {member} ({member.id}).") + await _change_cooldown_role(member, member.add_roles) + + +async def check_cooldowns(scheduler: Scheduler) -> None: + """Remove expired cooldowns and re-schedule active ones.""" + log.trace("Checking all cooldowns to remove or re-schedule them.") + guild = bot.instance.get_guild(constants.Guild.id) + cooldown = constants.HelpChannels.claim_minutes * 60 + + for channel_id, member_id in await _help_channel_claimants.items(): + member = guild.get_member(member_id) + if not member: + continue # Member probably left the guild. + + in_use_time = await _channel.get_in_use_time(channel_id) + + if not in_use_time or in_use_time.seconds > cooldown: + # Remove the role if no claim time could be retrieved or if the cooldown expired. + # Since the channel is in the claimants cache, it is definitely strange for a time + # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. + await remove_cooldown_role(member) + else: + # The member is still on a cooldown; re-schedule it for the remaining time. + delay = cooldown - in_use_time.seconds + scheduler.schedule_later(delay, member.id, remove_cooldown_role(member)) + + +async def remove_cooldown_role(member: discord.Member) -> None: + """Remove the help cooldown role from `member`.""" + log.trace(f"Removing cooldown role for {member} ({member.id}).") + await _change_cooldown_role(member, member.remove_roles) + + +async def revoke_send_permissions(member: discord.Member, scheduler: Scheduler) -> None: + """ + Disallow `member` to send messages in the Available category for a certain time. + + The time until permissions are reinstated can be configured with + `HelpChannels.claim_minutes`. + """ + log.trace( + f"Revoking {member}'s ({member.id}) send message permissions in the Available category." + ) + + await add_cooldown_role(member) + + # Cancel the existing task, if any. + # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). + if member.id in scheduler: + scheduler.cancel(member.id) + + delay = constants.HelpChannels.claim_minutes * 60 + scheduler.schedule_later(delay, member.id, remove_cooldown_role(member)) + + +async def _change_cooldown_role(member: discord.Member, coro_func: CoroutineFunc) -> None: + """ + Change `member`'s cooldown role via awaiting `coro_func` and handle errors. + + `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + guild = bot.instance.get_guild(constants.Guild.id) + role = guild.get_role(constants.Roles.help_cooldown) + if role is None: + log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") + return + + try: + await coro_func(role) + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") -- cgit v1.2.3 From b9593039a1663c983b84970dc8832900ce9e4cbb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:58:43 -0700 Subject: Help channels: remove obsolete function --- bot/exts/help_channels/_cog.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 169238937..5c4a0d972 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -280,11 +280,6 @@ class HelpChannels(commands.Cog): self.bot.stats.gauge("help.total.available", total_available) self.bot.stats.gauge("help.total.dormant", total_dormant) - @staticmethod - def is_claimant(member: discord.Member) -> bool: - """Return True if `member` has the 'Help Cooldown' role.""" - return any(constants.Roles.help_cooldown == role.id for role in member.roles) - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. -- cgit v1.2.3 From 2eb56e4f863475307eafa070d22e71d061641f6a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 19 Oct 2020 13:08:05 -0700 Subject: Help channels: replace ready event with awaiting the init task The event is redundant since awaiting the task accomplishes the same thing. If the task is already done, the await will finish immediately. If the task gets cancelled, the error is raised but discord.py suppress it in both commands and event listeners. --- bot/exts/help_channels/_cog.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5c4a0d972..bea1f65e6 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -83,7 +83,6 @@ class HelpChannels(commands.Cog): # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] - self.ready = asyncio.Event() self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) @@ -264,11 +263,9 @@ class HelpChannels(commands.Cog): self.close_command.enabled = True await self.init_available() + self.report_stats() log.info("Cog is ready!") - self.ready.set() - - self.report_stats() def report_stats(self) -> None: """Report the channel count stats.""" @@ -439,8 +436,9 @@ class HelpChannels(commands.Cog): if not is_available or _channel.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. - log.trace("Waiting for the cog to be ready before processing messages.") - await self.ready.wait() + if not self.init_task.done(): + log.trace("Waiting for the cog to be ready before processing messages.") + await self.init_task log.trace("Acquiring lock to prevent a channel from being processed twice...") async with self.on_message_lock: -- cgit v1.2.3 From 9debf8d649e0f63753f0f486cd8b7490d90c324c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 19 Oct 2020 13:08:29 -0700 Subject: Help channels: wait for cog to be ready in deleted msg listener --- bot/exts/help_channels/_cog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index bea1f65e6..29570bab3 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -488,6 +488,10 @@ class HelpChannels(commands.Cog): if not await _message.is_empty(msg.channel): return + if not self.init_task.done(): + log.trace("Waiting for the cog to be ready before processing deleted messages.") + await self.init_task + log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") # Cancel existing dormant task before scheduling new. -- cgit v1.2.3 From 21f6a9a5f799c88f746b2bbda250ec1a6bb8f845 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 20 Oct 2020 12:31:40 -0700 Subject: Help channels: move all caches to a separate module Some need to be shared among modules, so it became redundant to redefine them in each module. --- bot/exts/help_channels/_caches.py | 19 +++++++++++++++++++ bot/exts/help_channels/_channel.py | 9 ++------- bot/exts/help_channels/_cog.py | 23 +++++++---------------- bot/exts/help_channels/_cooldown.py | 9 ++------- bot/exts/help_channels/_message.py | 26 ++++++-------------------- 5 files changed, 36 insertions(+), 50 deletions(-) create mode 100644 bot/exts/help_channels/_caches.py diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py new file mode 100644 index 000000000..4cea385b7 --- /dev/null +++ b/bot/exts/help_channels/_caches.py @@ -0,0 +1,19 @@ +from async_rediscache import RedisCache + +# This dictionary maps a help channel to the time it was claimed +# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] +claim_times = RedisCache(namespace="HelpChannels.claim_times") + +# This cache tracks which channels are claimed by which members. +# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] +claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") + +# This cache maps a help channel to original question message in same channel. +# RedisCache[discord.TextChannel.id, discord.Message.id] +question_messages = RedisCache(namespace="HelpChannels.question_messages") + +# This cache maps a help channel to whether it has had any +# activity other than the original claimant. True being no other +# activity and False being other activity. +# RedisCache[discord.TextChannel.id, bool] +unanswered = RedisCache(namespace="HelpChannels.unanswered") diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 93c0c7fc9..d6d6f1245 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -3,20 +3,15 @@ import typing as t from datetime import datetime, timedelta import discord -from async_rediscache import RedisCache from bot import constants -from bot.exts.help_channels import _message +from bot.exts.help_channels import _caches, _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) -# This dictionary maps a help channel to the time it was claimed -# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -_claim_times = RedisCache(namespace="HelpChannels.claim_times") - def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" @@ -51,7 +46,7 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: """Return the duration `channel_id` has been in use. Return None if it's not in use.""" log.trace(f"Calculating in use time for channel {channel_id}.") - claimed_timestamp = await _claim_times.get(channel_id) + claimed_timestamp = await _caches.claim_times.get(channel_id) if claimed_timestamp: claimed = datetime.utcfromtimestamp(claimed_timestamp) return datetime.utcnow() - claimed diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 29570bab3..a17213323 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -6,12 +6,11 @@ from datetime import datetime, timezone import discord import discord.abc -from async_rediscache import RedisCache from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _channel, _cooldown, _message +from bot.exts.help_channels import _caches, _channel, _cooldown, _message from bot.exts.help_channels._name import create_name_queue from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -58,14 +57,6 @@ class HelpChannels(commands.Cog): Help channels are named after the chemical elements in `bot/resources/elements.json`. """ - # This cache tracks which channels are claimed by which members. - # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] - help_channel_claimants = RedisCache() - - # This dictionary maps a help channel to the time it was claimed - # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] - claim_times = RedisCache() - def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) @@ -136,7 +127,7 @@ class HelpChannels(commands.Cog): async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user is the help channel claimant or passes the role check.""" - if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: + if await _caches.claimants.get(ctx.channel.id) == ctx.author.id: log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") self.bot.stats.incr("help.dormant_invoke.claimant") return True @@ -378,7 +369,7 @@ class HelpChannels(commands.Cog): """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await self.help_channel_claimants.delete(channel.id) + await _caches.claimants.delete(channel.id) await self.move_to_bottom_position( channel=channel, category_id=constants.Categories.help_dormant, @@ -390,7 +381,7 @@ class HelpChannels(commands.Cog): if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) - unanswered = await _message.unanswered.get(channel.id) + unanswered = await _caches.unanswered.get(channel.id) if unanswered: self.bot.stats.incr("help.sessions.unanswered") elif unanswered is not None: @@ -458,15 +449,15 @@ class HelpChannels(commands.Cog): await _message.pin(message) # Add user with channel for dormant check. - await self.help_channel_claimants.set(channel.id, message.author.id) + await _caches.claimants.set(channel.id, message.author.id) self.bot.stats.incr("help.claimed") # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. timestamp = datetime.now(timezone.utc).timestamp() - await self.claim_times.set(channel.id, timestamp) + await _caches.claim_times.set(channel.id, timestamp) - await _message.unanswered.set(channel.id, True) + await _caches.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py index c4fd4b662..c5c39297f 100644 --- a/bot/exts/help_channels/_cooldown.py +++ b/bot/exts/help_channels/_cooldown.py @@ -2,20 +2,15 @@ import logging from typing import Callable, Coroutine import discord -from async_rediscache import RedisCache import bot from bot import constants -from bot.exts.help_channels import _channel +from bot.exts.help_channels import _caches, _channel from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) CoroutineFunc = Callable[..., Coroutine] -# This cache tracks which channels are claimed by which members. -# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] -_help_channel_claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") - async def add_cooldown_role(member: discord.Member) -> None: """Add the help cooldown role to `member`.""" @@ -29,7 +24,7 @@ async def check_cooldowns(scheduler: Scheduler) -> None: guild = bot.instance.get_guild(constants.Guild.id) cooldown = constants.HelpChannels.claim_minutes * 60 - for channel_id, member_id in await _help_channel_claimants.items(): + for channel_id, member_id in await _caches.claimants.items(): member = guild.get_member(member_id) if not member: continue # Member probably left the guild. diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index eaf8b0ab5..8b058d5aa 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -3,10 +3,10 @@ import typing as t from datetime import datetime import discord -from async_rediscache import RedisCache import bot from bot import constants +from bot.exts.help_channels import _caches from bot.utils.channel import is_in_category log = logging.getLogger(__name__) @@ -40,20 +40,6 @@ question to maximize your chance of getting a good answer. If you're not sure ho through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. """ -# This cache maps a help channel to whether it has had any -# activity other than the original claimant. True being no other -# activity and False being other activity. -# RedisCache[discord.TextChannel.id, bool] -unanswered = RedisCache(namespace="HelpChannels.unanswered") - -# This cache tracks which channels are claimed by which members. -# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] -_help_channel_claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") - -# This cache maps a help channel to original question message in same channel. -# RedisCache[discord.TextChannel.id, discord.Message.id] -_question_messages = RedisCache(namespace="HelpChannels.question_messages") - async def check_for_answer(message: discord.Message) -> None: """Checks for whether new content in a help channel comes from non-claimants.""" @@ -64,8 +50,8 @@ async def check_for_answer(message: discord.Message) -> None: log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Check if there is an entry in unanswered - if await unanswered.contains(channel.id): - claimant_id = await _help_channel_claimants.get(channel.id) + if await _caches.unanswered.contains(channel.id): + claimant_id = await _caches.claimants.get(channel.id) if not claimant_id: # The mapping for this channel doesn't exist, we can't do anything. return @@ -73,7 +59,7 @@ async def check_for_answer(message: discord.Message) -> None: # Check the message did not come from the claimant if claimant_id != message.author.id: # Mark the channel as answered - await unanswered.set(channel.id, False) + await _caches.unanswered.set(channel.id, False) async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: @@ -154,7 +140,7 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[dat async def pin(message: discord.Message) -> None: """Pin an initial question `message` and store it in a cache.""" if await _pin_wrapper(message.id, message.channel, pin=True): - await _question_messages.set(message.channel.id, message.id) + await _caches.question_messages.set(message.channel.id, message.id) async def send_available_message(channel: discord.TextChannel) -> None: @@ -180,7 +166,7 @@ async def send_available_message(channel: discord.TextChannel) -> None: async def unpin(channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" - msg_id = await _question_messages.pop(channel.id) + msg_id = await _caches.question_messages.pop(channel.id) if msg_id is None: log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") else: -- cgit v1.2.3 From 47d65fca599d138098d5021d403db78e4abc0e7a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Nov 2020 16:42:24 -0800 Subject: Help channels: merge 2 imports into 1 The import was an outlier compared to how the other modules were imported. It's nicer to keep the imports consistent. --- bot/exts/help_channels/_cog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a17213323..638d00e4a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -10,8 +10,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _cooldown, _message -from bot.exts.help_channels._name import create_name_queue +from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name from bot.utils import channel as channel_utils from bot.utils.scheduling import Scheduler @@ -237,7 +236,7 @@ class HelpChannels(commands.Cog): await _cooldown.check_cooldowns(self.scheduler) self.channel_queue = self.create_channel_queue() - self.name_queue = create_name_queue( + self.name_queue = _name.create_name_queue( self.available_category, self.in_use_category, self.dormant_category, -- cgit v1.2.3 From 94bd6133be0eb284f20af5ae1da4f477cab59975 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Tue, 24 Nov 2020 15:18:38 -0500 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cf343e5f0..1707c0244 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ -# Request Joe for any PR -* @jb3 +# Request Joe and Dennis for any PR +* @jb3 @Den4200 -- cgit v1.2.3 From 851f5a5ccc65a3f4d51cbfbc59472e8a3b6cdd8e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 24 Nov 2020 13:35:33 -0800 Subject: Specify code ownership for Mark --- .github/CODEOWNERS | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1707c0244..5cdbc76bd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,21 @@ # Request Joe and Dennis for any PR * @jb3 @Den4200 + +# Extensions +**/bot/exts/backend/sync/** @MarkKoz +**/bot/exts/filters/*token_remover.py @MarkKoz +**/bot/exts/moderation/*silence.py @MarkKoz +bot/exts/info/codeblock/** @MarkKoz +bot/exts/utils/extensions.py @MarkKoz +bot/exts/utils/snekbox.py @MarkKoz +bot/exts/help_channels.py @MarkKoz + +# Utils +bot/utils/extensions.py @MarkKoz +bot/utils/function.py @MarkKoz +bot/utils/lock.py @MarkKoz +bot/utils/scheduling.py @MarkKoz + +# Tests +tests/_autospec.py @MarkKoz +tests/bot/exts/test_cogs.py @MarkKoz -- cgit v1.2.3 From 0a49cb61085c2ceb8974decc69b200905bcf14e7 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 24 Nov 2020 13:46:09 -0800 Subject: Add Mark as a code owner of CI and Docker files --- .github/CODEOWNERS | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5cdbc76bd..5f5386222 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,3 +19,8 @@ bot/utils/scheduling.py @MarkKoz # Tests tests/_autospec.py @MarkKoz tests/bot/exts/test_cogs.py @MarkKoz + +# CI & Docker +.github/workflows/** @MarkKoz +Dockerfile @MarkKoz +docker-compose.yml @MarkKoz -- cgit v1.2.3 From 8c2e7ed9026219ecc6fdd528c1bfe55b5dc7700f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 26 Nov 2020 17:16:55 +0100 Subject: Add voice_ban to supported types of the scheduler The `voice_ban` infraction was not listed as a supported type for the infraction scheduler. This meant that the scheduler did not schedule the expiry of `voice_ban` infractions after a restart. Those unlucky users were voice-banned perpetually. --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 746d4e154..6056df1d2 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -27,7 +27,7 @@ class Infractions(InfractionScheduler, commands.Cog): category_description = "Server moderation tools." def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"}) + super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_ban"}) self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) -- cgit v1.2.3 From 0242b4ed4145edf3e3e6ea6ebcef62aaff77d7ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 13:57:55 -0800 Subject: Help channels: remove how_to_get_help from excluded channels The channel as moved out of this category. Delete the constant too since it isn't used anywhere else. Keep the excluded channels a tuple to conveniently support excluding multiple channels in the future. --- bot/constants.py | 1 - bot/exts/help_channels/_channel.py | 2 +- config-default.yml | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6bb6aacd2..fb280b042 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -406,7 +406,6 @@ class Channels(metaclass=YAMLGetter): dm_log: int esoteric: int helpers: int - how_to_get_help: int incidents: int incidents_archive: int mailing_lists: int diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index d6d6f1245..e717d7af8 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -10,7 +10,7 @@ from bot.exts.help_channels import _caches, _message log = logging.getLogger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) +EXCLUDED_CHANNELS = (constants.Channels.cooldown,) def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: diff --git a/config-default.yml b/config-default.yml index 60eb437af..82023aae1 100644 --- a/config-default.yml +++ b/config-default.yml @@ -155,7 +155,6 @@ guild: python_discussion: &PY_DISCUSSION 267624335836053506 # Python Help: Available - how_to_get_help: 704250143020417084 cooldown: 720603994149486673 # Logs -- cgit v1.2.3 From d9f87cc867a93d5f2d14d16eaac86ccb5adef447 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:07:29 -0800 Subject: Help channels: don't check if task is done before awaiting Awaiting a done task is effectively a no-op, so it's redundant to check if the task is done before awaiting it. Furthermore, a task is also considered done if it was cancelled or an exception was raised. Therefore, avoiding awaiting in such cases doesn't allow the errors to be propagated and incorrectly allows the awaiter to keep executing. --- bot/exts/help_channels/_cog.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 638d00e4a..e22d4663e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -426,9 +426,8 @@ class HelpChannels(commands.Cog): if not is_available or _channel.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. - if not self.init_task.done(): - log.trace("Waiting for the cog to be ready before processing messages.") - await self.init_task + log.trace("Waiting for the cog to be ready before processing messages.") + await self.init_task log.trace("Acquiring lock to prevent a channel from being processed twice...") async with self.on_message_lock: @@ -478,9 +477,8 @@ class HelpChannels(commands.Cog): if not await _message.is_empty(msg.channel): return - if not self.init_task.done(): - log.trace("Waiting for the cog to be ready before processing deleted messages.") - await self.init_task + log.trace("Waiting for the cog to be ready before processing deleted messages.") + await self.init_task log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") -- cgit v1.2.3 From e5b073c5ee9c7c887a193ec05d813d259eca58ee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:11:40 -0800 Subject: Help channels: document the return value of notify() --- bot/exts/help_channels/_message.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 8b058d5aa..2bbd4bdd6 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -96,6 +96,9 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[dat """ Send a message in `channel` notifying about a lack of available help channels. + If a notification was sent, return the `datetime` at which the message was sent. Otherwise, + return None. + Configuration: * `HelpChannels.notify` - toggle notifications -- cgit v1.2.3 From bff4cd34bbacc7229a3fa2cf5421276c433c8b65 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Nov 2020 14:12:57 -0800 Subject: Update help channels directory for code owners --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5f5386222..843f86b71 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,7 @@ bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz bot/exts/utils/snekbox.py @MarkKoz -bot/exts/help_channels.py @MarkKoz +bot/exts/help_channels/** @MarkKoz # Utils bot/utils/extensions.py @MarkKoz -- cgit v1.2.3 From a8891d43484d602d49800ad5a8a39505758a86ba Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 26 Nov 2020 22:16:31 +0000 Subject: Update CODEOWNERS --- .github/CODEOWNERS | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5f5386222..0d7572e38 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,5 @@ -# Request Joe and Dennis for any PR -* @jb3 @Den4200 +# Request Dennis for any PR +* @Den4200 # Extensions **/bot/exts/backend/sync/** @MarkKoz @@ -24,3 +24,7 @@ tests/bot/exts/test_cogs.py @MarkKoz .github/workflows/** @MarkKoz Dockerfile @MarkKoz docker-compose.yml @MarkKoz + +# Statistics +bot/async_stats.py @jb3 +bot/exts/info/stats.py @jb3 -- cgit v1.2.3 From 63abf5cd0dfe831caaf0a854387fca11d0e13ae2 Mon Sep 17 00:00:00 2001 From: Mushinako <8977737+Mushinako@users.noreply.github.com> Date: Thu, 26 Nov 2020 18:50:20 -0800 Subject: Add `build-tools` tag Infoblock tag helping people install Microsoft Visual C++ Build Tools on Windows --- bot/resources/tags/build-tools.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/resources/tags/build-tools.md diff --git a/bot/resources/tags/build-tools.md b/bot/resources/tags/build-tools.md new file mode 100644 index 000000000..23ce15e8a --- /dev/null +++ b/bot/resources/tags/build-tools.md @@ -0,0 +1,15 @@ +**Microsoft Visual C++ Build Tools** + +When you install a library through `pip` on Windows, sometimes you may encounter this error: + +``` +error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/ +``` + +This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) + +1. Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +2. Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. +3. Run the downloaded file. If a **`User Account Control`** dialog pops up, click **`Yes`**. Click **`Continue`** to proceed. +4. Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. +5. Try installing the library via `pip` again. -- cgit v1.2.3 From c5b7f7b500aeefbb65ff943f8e3a918c9ed75193 Mon Sep 17 00:00:00 2001 From: Mushinako <8977737+Mushinako@users.noreply.github.com> Date: Thu, 26 Nov 2020 23:06:51 -0800 Subject: Bold numbering --- bot/resources/tags/build-tools.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/resources/tags/build-tools.md b/bot/resources/tags/build-tools.md index 23ce15e8a..db88098e0 100644 --- a/bot/resources/tags/build-tools.md +++ b/bot/resources/tags/build-tools.md @@ -8,8 +8,8 @@ error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) -1. Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). -2. Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. -3. Run the downloaded file. If a **`User Account Control`** dialog pops up, click **`Yes`**. Click **`Continue`** to proceed. -4. Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. -5. Try installing the library via `pip` again. +**1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +**2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. +**3.** Run the downloaded file. If a **`User Account Control`** dialog pops up, click **`Yes`**. Click **`Continue`** to proceed. +**4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. +**5.** Try installing the library via `pip` again. -- cgit v1.2.3 From c5d885da1e5094c9ee55f2d37dcc01db4ce08e9c Mon Sep 17 00:00:00 2001 From: Mushinako <8977737+Mushinako@users.noreply.github.com> Date: Thu, 26 Nov 2020 23:08:48 -0800 Subject: Remove UAC prompt info Assuming Windows users are familiar with UAC --- bot/resources/tags/build-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/build-tools.md b/bot/resources/tags/build-tools.md index db88098e0..7c702e296 100644 --- a/bot/resources/tags/build-tools.md +++ b/bot/resources/tags/build-tools.md @@ -10,6 +10,6 @@ This means the library you're installing has code written in other languages and **1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). **2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. -**3.** Run the downloaded file. If a **`User Account Control`** dialog pops up, click **`Yes`**. Click **`Continue`** to proceed. +**3.** Run the downloaded file. Click **`Continue`** to proceed. **4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. **5.** Try installing the library via `pip` again. -- cgit v1.2.3 From a7dd1c56dad3e2aa3c1304b6f9cc5bd63150ad91 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 27 Nov 2020 18:18:22 +0100 Subject: Add @Akarys42 to the codeowners --- .github/CODEOWNERS | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 86df4db8d..272fb2ffe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,24 +7,31 @@ **/bot/exts/moderation/*silence.py @MarkKoz bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz -bot/exts/utils/snekbox.py @MarkKoz -bot/exts/help_channels/** @MarkKoz +bot/exts/utils/snekbox.py @MarkKoz @Akarys42 +bot/exts/help_channels/** @MarkKoz @Akarys42 +bot/exts/moderation/** @Akarys42 +bot/exts/info/** @Akarys42 # Utils bot/utils/extensions.py @MarkKoz bot/utils/function.py @MarkKoz bot/utils/lock.py @MarkKoz +bot/utils/regex.py @Akarys42 bot/utils/scheduling.py @MarkKoz # Tests tests/_autospec.py @MarkKoz tests/bot/exts/test_cogs.py @MarkKoz +tests/** @Akarys42 # CI & Docker -.github/workflows/** @MarkKoz -Dockerfile @MarkKoz -docker-compose.yml @MarkKoz +.github/workflows/** @MarkKoz @Akarys42 +Dockerfile @MarkKoz @Akarys42 +docker-compose.yml @MarkKoz @Akarys42 + +# Tools +Pipfile* @Akarys42 # Statistics -bot/async_stats.py @jb3 -bot/exts/info/stats.py @jb3 +bot/async_stats.py @jb3 +bot/exts/info/stats.py @jb3 -- cgit v1.2.3 From 1cde79faa2675638ede0435fbf4cd2911b8a76ba Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 27 Nov 2020 23:39:13 +0100 Subject: Add myself to CODEOWNERS for CI files --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 272fb2ffe..495a6e9e2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,7 +25,7 @@ tests/bot/exts/test_cogs.py @MarkKoz tests/** @Akarys42 # CI & Docker -.github/workflows/** @MarkKoz @Akarys42 +.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ Dockerfile @MarkKoz @Akarys42 docker-compose.yml @MarkKoz @Akarys42 -- cgit v1.2.3 From cb59440d7c7bf5780545a1a31beadaaadd5700f9 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Nov 2020 01:37:27 +0200 Subject: Remove unnecessary shadow infractions --- bot/exts/moderation/infraction/infractions.py | 30 --------------------------- 1 file changed, 30 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 6056df1d2..8c3451c7a 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -180,11 +180,6 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) - @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: - """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True) - @command(hidden=True, aliases=['shadowban', 'sban']) async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" @@ -193,31 +188,6 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Temporary shadow infractions - @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute( - self, ctx: Context, - user: Member, - duration: Expiry, - *, - reason: t.Optional[str] = None - ) -> None: - """ - Temporarily mute a user for the given reason and duration without notifying the user. - - A unit of time should be appended to the duration. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True) - @command(hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( self, -- cgit v1.2.3 From f1bb099b90acc870995b5e1a1f02aa20c44e9b6d Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Nov 2020 15:38:08 +0200 Subject: Add default mute duration --- bot/exts/moderation/infraction/infractions.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 8c3451c7a..18e937e87 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Expiry, FetchedMember +from bot.converters import Duration, Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -98,7 +98,13 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary infractions @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: + async def tempmute( + self, ctx: Context, + user: Member, + duration: t.Optional[Expiry] = None, + *, + reason: t.Optional[str] = None + ) -> None: """ Temporarily mute a user for the given reason and duration. @@ -113,7 +119,11 @@ class Infractions(InfractionScheduler, commands.Cog): \u2003`s` - seconds Alternatively, an ISO 8601 timestamp can be provided for the duration. + + If no duration is given, a one hour duration is used by default. """ + if duration is None: + duration = await Duration().convert(ctx, "1h") await self.apply_mute(ctx, user, reason, expires_at=duration) @command() -- cgit v1.2.3 From 7b3d8ed6e655b787c2538541ca35301f2fadb508 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Nov 2020 15:47:50 +0200 Subject: Added infraction edit aliases --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 394f63da3..4cd7d15bf 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -40,12 +40,12 @@ class ModManagement(commands.Cog): # region: Edit infraction commands - @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) + @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True) async def infraction_group(self, ctx: Context) -> None: """Infraction manipulation commands.""" await ctx.send_help(ctx.command) - @infraction_group.command(name='edit') + @infraction_group.command(name='edit', aliases=('e',)) async def infraction_edit( self, ctx: Context, -- cgit v1.2.3 From 67fbb71f7954f4edc2f43b8f1069e3c366dcca4d Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sat, 28 Nov 2020 16:58:58 +0200 Subject: Add myself to CODEOWNERS --- .github/CODEOWNERS | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 495a6e9e2..642676078 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,8 +9,9 @@ bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz bot/exts/utils/snekbox.py @MarkKoz @Akarys42 bot/exts/help_channels/** @MarkKoz @Akarys42 -bot/exts/moderation/** @Akarys42 -bot/exts/info/** @Akarys42 +bot/exts/moderation/** @Akarys42 @mbaruh +bot/exts/info/** @Akarys42 @mbaruh +bot/exts/filters/** @mbaruh # Utils bot/utils/extensions.py @MarkKoz -- cgit v1.2.3 From 54540bf9dcf88f7d3e2e0f389a2127456d6c877f Mon Sep 17 00:00:00 2001 From: Mushinako <8977737+Mushinako@users.noreply.github.com> Date: Sat, 28 Nov 2020 11:38:32 -0800 Subject: Rename `build-tools` to `microsoft-build-tools` --- bot/resources/tags/build-tools.md | 15 --------------- bot/resources/tags/microsoft-build-tools.md | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 15 deletions(-) delete mode 100644 bot/resources/tags/build-tools.md create mode 100644 bot/resources/tags/microsoft-build-tools.md diff --git a/bot/resources/tags/build-tools.md b/bot/resources/tags/build-tools.md deleted file mode 100644 index 7c702e296..000000000 --- a/bot/resources/tags/build-tools.md +++ /dev/null @@ -1,15 +0,0 @@ -**Microsoft Visual C++ Build Tools** - -When you install a library through `pip` on Windows, sometimes you may encounter this error: - -``` -error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/ -``` - -This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) - -**1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). -**2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. -**3.** Run the downloaded file. Click **`Continue`** to proceed. -**4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. -**5.** Try installing the library via `pip` again. diff --git a/bot/resources/tags/microsoft-build-tools.md b/bot/resources/tags/microsoft-build-tools.md new file mode 100644 index 000000000..7c702e296 --- /dev/null +++ b/bot/resources/tags/microsoft-build-tools.md @@ -0,0 +1,15 @@ +**Microsoft Visual C++ Build Tools** + +When you install a library through `pip` on Windows, sometimes you may encounter this error: + +``` +error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/ +``` + +This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) + +**1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +**2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. +**3.** Run the downloaded file. Click **`Continue`** to proceed. +**4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. +**5.** Try installing the library via `pip` again. -- cgit v1.2.3 From 65366ead766fa6533fe6c59310a39ca3ee3a06b2 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Mon, 30 Nov 2020 00:47:34 +0800 Subject: Fix unawaited coroutine in Infraction --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index f350e863e..d0a9731d6 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -575,7 +575,7 @@ class Infraction(Converter): return infractions[0] else: - return ctx.bot.api_client.get(f"bot/infractions/{arg}") + return await ctx.bot.api_client.get(f"bot/infractions/{arg}") Expiry = t.Union[Duration, ISODateTime] -- cgit v1.2.3 From db2136cd5bd1b86b05f249177e7a68a731404d16 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Mon, 30 Nov 2020 00:49:47 +0800 Subject: Refactor flow for automatically adding punctuation --- bot/exts/moderation/infraction/management.py | 11 +++++------ bot/utils/regex.py | 2 -- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index bb7a6737a..10bca5fc5 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -16,7 +16,6 @@ from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel -from bot.utils.regex import END_PUNCTUATION_RE log = logging.getLogger(__name__) @@ -77,13 +76,13 @@ class ModManagement(commands.Cog): If a previous infraction reason does not end with an ending punctuation mark, this automatically adds a period before the amended reason. """ - add_period = not END_PUNCTUATION_RE.match(infraction["reason"]) + old_reason = infraction["reason"] - new_reason = "".join(( - infraction["reason"], ". " if add_period else " ", reason, - )) + if old_reason is not None: + add_period = not old_reason.endswith((".", "!", "?")) + reason = old_reason + (". " if add_period else " ") + reason - await self.infraction_edit(ctx, infraction, duration, reason=new_reason) + await self.infraction_edit(ctx, infraction, duration, reason=reason) @infraction_group.command(name='edit') async def infraction_edit( diff --git a/bot/utils/regex.py b/bot/utils/regex.py index cfce52bb3..0d2068f90 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -10,5 +10,3 @@ INVITE_RE = re.compile( r"([a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE ) - -END_PUNCTUATION_RE = re.compile("^.+?[.?!]$") -- cgit v1.2.3 From ae976d56bd3ce89c1da69f42f59680f7b4763771 Mon Sep 17 00:00:00 2001 From: PureFunctor Date: Mon, 30 Nov 2020 17:52:22 +0800 Subject: Remove unused get_latest_infraction helper method --- bot/exts/moderation/infraction/management.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 10bca5fc5..3b0719ed2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -310,20 +310,6 @@ class ModManagement(commands.Cog): return lines.strip() - async def get_latest_infraction(self, actor: int) -> t.Optional[dict]: - """Obtains the latest infraction from an actor.""" - params = { - "actor__id": actor, - "ordering": "-inserted_at" - } - - infractions = await self.bot.api_client.get("bot/infractions", params=params) - - if infractions: - return infractions[0] - - return None - # endregion # This cannot be static (must have a __func__ attribute). -- cgit v1.2.3