From 6f880bbc40049948f71af14723f46533fb8c4f1f Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Wed, 10 Mar 2021 20:06:18 +0200 Subject: Moved talentpool to a new recruitment extension --- bot/exts/moderation/watchchannels/talentpool.py | 335 ------------------------ bot/exts/recruitment/talentpool/talentpool.py | 335 ++++++++++++++++++++++++ 2 files changed, 335 insertions(+), 335 deletions(-) delete mode 100644 bot/exts/moderation/watchchannels/talentpool.py create mode 100644 bot/exts/recruitment/talentpool/talentpool.py diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py deleted file mode 100644 index d75688fa6..000000000 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ /dev/null @@ -1,335 +0,0 @@ -import logging -import textwrap -from collections import ChainMap -from typing import Union - -from discord import Color, Embed, Member, User -from discord.ext.commands import Cog, Context, group, has_any_role - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.exts.moderation.watchchannels._watchchannel import WatchChannel -from bot.pagination import LinePaginator -from bot.utils import time - -REASON_MAX_CHARS = 1000 - -log = logging.getLogger(__name__) - - -class TalentPool(WatchChannel, Cog, name="Talentpool"): - """Relays messages of helper candidates to a watch channel to observe them.""" - - def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.talent_pool, - webhook_id=Webhooks.talent_pool, - api_endpoint='bot/nominations', - api_default_params={'active': 'true', 'ordering': '-inserted_at'}, - logger=log, - disable_header=True, - ) - - @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) - async def nomination_group(self, ctx: Context) -> None: - """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.send_help(ctx.command) - - @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) - @has_any_role(*MODERATION_ROLES) - async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Shows the users that are currently being monitored in the talent pool. - - The optional kwarg `oldest_first` can be used to order the list by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - - @nomination_group.command(name='oldest') - @has_any_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: - """ - Shows talent pool monitored users ordered by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - - @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) - @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: - """ - Relay messages sent by the given `user` to the `#talent-pool` channel. - - A `reason` for adding the user to the talent pool is optional. - If given, it will be displayed in the header when relaying messages of this user to the channel. - """ - if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") - return - - if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): - await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") - return - - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update the user cache; can't add {user}") - return - - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") - return - - # Manual request with `raise_for_status` as False because we want the actual response - session = self.bot.api_client.session - url = self.bot.api_client._url_for(self.api_endpoint) - kwargs = { - 'json': { - 'actor': ctx.author.id, - 'reason': reason, - 'user': user.id - }, - 'raise_for_status': False, - } - async with session.post(url, **kwargs) as resp: - response_data = await resp.json() - - if resp.status == 400: - if response_data.get('user', False): - await ctx.send(":x: The specified user can't be found in the database tables") - elif response_data.get('actor', False): - await ctx.send(":x: You have already nominated this user") - - return - else: - resp.raise_for_status() - - self.watched_users[user.id] = response_data - msg = f":white_check_mark: The nomination for {user} has been added to the talent pool" - - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - "ordering": "-inserted_at" - } - ) - - if history: - msg += f"\n\n({len(history)} previous nominations in total)" - - await ctx.send(msg) - - @nomination_group.command(name='history', aliases=('info', 'search')) - @has_any_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedMember) -> None: - """Shows the specified user's nomination history.""" - result = await self.bot.api_client.get( - self.api_endpoint, - params={ - 'user__id': str(user.id), - 'ordering': "-active,-inserted_at" - } - ) - if not result: - await ctx.send(":warning: This user has never been nominated") - return - - embed = Embed( - title=f"Nominations for {user.display_name} `({user.id})`", - color=Color.blue() - ) - lines = [self._nomination_to_string(nomination) for nomination in result] - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) - @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Ends the active nomination of the specified user with the given reason. - - Providing a `reason` is required. - """ - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") - return - - if await self.unwatch(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - else: - await ctx.send(":x: The specified user does not have an active nomination") - - @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) - async def nomination_edit_group(self, ctx: Context) -> None: - """Commands to edit nominations.""" - await ctx.send_help(ctx.command) - - @nomination_edit_group.command(name='reason') - @has_any_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None: - """Edits the reason of a specific nominator in a specific active nomination.""" - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") - return - - try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") - return - else: - raise - - if not nomination["active"]: - await ctx.send(":x: Can't edit the reason of an inactive nomination.") - return - - if not any(entry["actor"] == actor.id for entry in nomination["entries"]): - await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") - return - - self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={"actor": actor.id, "reason": reason} - ) - await self.fetch_user_cache() # Update cache - await ctx.send(":white_check_mark: Successfully updated nomination reason.") - - @nomination_edit_group.command(name='end_reason') - @has_any_role(*MODERATION_ROLES) - async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: - """Edits the unnominate reason for the nomination with the given `id`.""" - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") - return - - try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") - return - else: - raise - - if nomination["active"]: - await ctx.send(":x: Can't edit the end reason of an active nomination.") - return - - self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={"end_reason": reason} - ) - await self.fetch_user_cache() # Update cache. - await ctx.send(":white_check_mark: Updated the end reason of the nomination!") - - @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: - """Remove `user` from the talent pool after they are banned.""" - await self.unwatch(user.id, "User was banned.") - - async def unwatch(self, user_id: int, reason: str) -> bool: - """End the active nomination of a user with the given reason and return True on success.""" - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - {"user__id": str(user_id)}, - self.api_default_params, - ) - ) - - if not active_nomination: - log.debug(f"No active nominate exists for {user_id=}") - return False - - log.info(f"Ending nomination: {user_id=} {reason=}") - - nomination = active_nomination[0] - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - self._remove_user(user_id) - - return True - - def _nomination_to_string(self, nomination_object: dict) -> str: - """Creates a string representation of a nomination.""" - guild = self.bot.get_guild(Guild.id) - entries = [] - for site_entry in nomination_object["entries"]: - actor_id = site_entry["actor"] - actor = guild.get_member(actor_id) - - reason = site_entry["reason"] or "*None*" - created = time.format_infraction(site_entry["inserted_at"]) - entries.append( - f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}" - ) - - entries_string = "\n\n".join(entries) - - active = nomination_object["active"] - - start_date = time.format_infraction(nomination_object["inserted_at"]) - if active: - lines = textwrap.dedent( - f""" - =============== - Status: **Active** - Date: {start_date} - Nomination ID: `{nomination_object["id"]}` - - {entries_string} - =============== - """ - ) - else: - end_date = time.format_infraction(nomination_object["ended_at"]) - lines = textwrap.dedent( - f""" - =============== - Status: Inactive - Date: {start_date} - Nomination ID: `{nomination_object["id"]}` - - {entries_string} - - End date: {end_date} - Unwatch reason: {nomination_object["end_reason"]} - =============== - """ - ) - - return lines.strip() - - -def setup(bot: Bot) -> None: - """Load the TalentPool cog.""" - bot.add_cog(TalentPool(bot)) diff --git a/bot/exts/recruitment/talentpool/talentpool.py b/bot/exts/recruitment/talentpool/talentpool.py new file mode 100644 index 000000000..d75688fa6 --- /dev/null +++ b/bot/exts/recruitment/talentpool/talentpool.py @@ -0,0 +1,335 @@ +import logging +import textwrap +from collections import ChainMap +from typing import Union + +from discord import Color, Embed, Member, User +from discord.ext.commands import Cog, Context, group, has_any_role + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.converters import FetchedMember +from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.pagination import LinePaginator +from bot.utils import time + +REASON_MAX_CHARS = 1000 + +log = logging.getLogger(__name__) + + +class TalentPool(WatchChannel, Cog, name="Talentpool"): + """Relays messages of helper candidates to a watch channel to observe them.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.talent_pool, + webhook_id=Webhooks.talent_pool, + api_endpoint='bot/nominations', + api_default_params={'active': 'true', 'ordering': '-inserted_at'}, + logger=log, + disable_header=True, + ) + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) + async def nomination_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" + await ctx.send_help(ctx.command) + + @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) + @has_any_role(*MODERATION_ROLES) + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Shows the users that are currently being monitored in the talent pool. + + The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @nomination_group.command(name='oldest') + @has_any_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows talent pool monitored users ordered by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) + @has_any_role(*STAFF_ROLES) + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + """ + Relay messages sent by the given `user` to the `#talent-pool` channel. + + A `reason` for adding the user to the talent pool is optional. + If given, it will be displayed in the header when relaying messages of this user to the channel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): + await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update the user cache; can't add {user}") + return + + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") + return + + # Manual request with `raise_for_status` as False because we want the actual response + session = self.bot.api_client.session + url = self.bot.api_client._url_for(self.api_endpoint) + kwargs = { + 'json': { + 'actor': ctx.author.id, + 'reason': reason, + 'user': user.id + }, + 'raise_for_status': False, + } + async with session.post(url, **kwargs) as resp: + response_data = await resp.json() + + if resp.status == 400: + if response_data.get('user', False): + await ctx.send(":x: The specified user can't be found in the database tables") + elif response_data.get('actor', False): + await ctx.send(":x: You have already nominated this user") + + return + else: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + msg = f":white_check_mark: The nomination for {user} has been added to the talent pool" + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + "ordering": "-inserted_at" + } + ) + + if history: + msg += f"\n\n({len(history)} previous nominations in total)" + + await ctx.send(msg) + + @nomination_group.command(name='history', aliases=('info', 'search')) + @has_any_role(*MODERATION_ROLES) + async def history_command(self, ctx: Context, user: FetchedMember) -> None: + """Shows the specified user's nomination history.""" + result = await self.bot.api_client.get( + self.api_endpoint, + params={ + 'user__id': str(user.id), + 'ordering': "-active,-inserted_at" + } + ) + if not result: + await ctx.send(":warning: This user has never been nominated") + return + + embed = Embed( + title=f"Nominations for {user.display_name} `({user.id})`", + color=Color.blue() + ) + lines = [self._nomination_to_string(nomination) for nomination in result] + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) + @has_any_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + return + + if await self.unwatch(user.id, reason): + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + else: + await ctx.send(":x: The specified user does not have an active nomination") + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + await ctx.send_help(ctx.command) + + @nomination_edit_group.command(name='reason') + @has_any_role(*MODERATION_ROLES) + async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None: + """Edits the reason of a specific nominator in a specific active nomination.""" + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") + return + + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + if not nomination["active"]: + await ctx.send(":x: Can't edit the reason of an inactive nomination.") + return + + if not any(entry["actor"] == actor.id for entry in nomination["entries"]): + await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") + return + + self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={"actor": actor.id, "reason": reason} + ) + await self.fetch_user_cache() # Update cache + await ctx.send(":white_check_mark: Successfully updated nomination reason.") + + @nomination_edit_group.command(name='end_reason') + @has_any_role(*MODERATION_ROLES) + async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """Edits the unnominate reason for the nomination with the given `id`.""" + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + return + + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + if nomination["active"]: + await ctx.send(":x: Can't edit the end reason of an active nomination.") + return + + self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={"end_reason": reason} + ) + await self.fetch_user_cache() # Update cache. + await ctx.send(":white_check_mark: Updated the end reason of the nomination!") + + @Cog.listener() + async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + """Remove `user` from the talent pool after they are banned.""" + await self.unwatch(user.id, "User was banned.") + + async def unwatch(self, user_id: int, reason: str) -> bool: + """End the active nomination of a user with the given reason and return True on success.""" + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + {"user__id": str(user_id)}, + self.api_default_params, + ) + ) + + if not active_nomination: + log.debug(f"No active nominate exists for {user_id=}") + return False + + log.info(f"Ending nomination: {user_id=} {reason=}") + + nomination = active_nomination[0] + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + self._remove_user(user_id) + + return True + + def _nomination_to_string(self, nomination_object: dict) -> str: + """Creates a string representation of a nomination.""" + guild = self.bot.get_guild(Guild.id) + entries = [] + for site_entry in nomination_object["entries"]: + actor_id = site_entry["actor"] + actor = guild.get_member(actor_id) + + reason = site_entry["reason"] or "*None*" + created = time.format_infraction(site_entry["inserted_at"]) + entries.append( + f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}" + ) + + entries_string = "\n\n".join(entries) + + active = nomination_object["active"] + + start_date = time.format_infraction(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Nomination ID: `{nomination_object["id"]}` + + {entries_string} + =============== + """ + ) + else: + end_date = time.format_infraction(nomination_object["ended_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Nomination ID: `{nomination_object["id"]}` + + {entries_string} + + End date: {end_date} + Unwatch reason: {nomination_object["end_reason"]} + =============== + """ + ) + + return lines.strip() + + +def setup(bot: Bot) -> None: + """Load the TalentPool cog.""" + bot.add_cog(TalentPool(bot)) -- cgit v1.2.3 From 65f93df5388e4c90ddbc985305d14d5120b24863 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Wed, 10 Mar 2021 20:13:18 +0200 Subject: Rename talentpool.py to _cog.py This change is done in preparation to having the cog split across multiple files. --- bot/exts/recruitment/talentpool/_cog.py | 335 ++++++++++++++++++++++++++ bot/exts/recruitment/talentpool/talentpool.py | 335 -------------------------- 2 files changed, 335 insertions(+), 335 deletions(-) create mode 100644 bot/exts/recruitment/talentpool/_cog.py delete mode 100644 bot/exts/recruitment/talentpool/talentpool.py diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py new file mode 100644 index 000000000..d75688fa6 --- /dev/null +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -0,0 +1,335 @@ +import logging +import textwrap +from collections import ChainMap +from typing import Union + +from discord import Color, Embed, Member, User +from discord.ext.commands import Cog, Context, group, has_any_role + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.converters import FetchedMember +from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.pagination import LinePaginator +from bot.utils import time + +REASON_MAX_CHARS = 1000 + +log = logging.getLogger(__name__) + + +class TalentPool(WatchChannel, Cog, name="Talentpool"): + """Relays messages of helper candidates to a watch channel to observe them.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.talent_pool, + webhook_id=Webhooks.talent_pool, + api_endpoint='bot/nominations', + api_default_params={'active': 'true', 'ordering': '-inserted_at'}, + logger=log, + disable_header=True, + ) + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) + async def nomination_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" + await ctx.send_help(ctx.command) + + @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) + @has_any_role(*MODERATION_ROLES) + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Shows the users that are currently being monitored in the talent pool. + + The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @nomination_group.command(name='oldest') + @has_any_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows talent pool monitored users ordered by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) + @has_any_role(*STAFF_ROLES) + async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + """ + Relay messages sent by the given `user` to the `#talent-pool` channel. + + A `reason` for adding the user to the talent pool is optional. + If given, it will be displayed in the header when relaying messages of this user to the channel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): + await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update the user cache; can't add {user}") + return + + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") + return + + # Manual request with `raise_for_status` as False because we want the actual response + session = self.bot.api_client.session + url = self.bot.api_client._url_for(self.api_endpoint) + kwargs = { + 'json': { + 'actor': ctx.author.id, + 'reason': reason, + 'user': user.id + }, + 'raise_for_status': False, + } + async with session.post(url, **kwargs) as resp: + response_data = await resp.json() + + if resp.status == 400: + if response_data.get('user', False): + await ctx.send(":x: The specified user can't be found in the database tables") + elif response_data.get('actor', False): + await ctx.send(":x: You have already nominated this user") + + return + else: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + msg = f":white_check_mark: The nomination for {user} has been added to the talent pool" + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + "ordering": "-inserted_at" + } + ) + + if history: + msg += f"\n\n({len(history)} previous nominations in total)" + + await ctx.send(msg) + + @nomination_group.command(name='history', aliases=('info', 'search')) + @has_any_role(*MODERATION_ROLES) + async def history_command(self, ctx: Context, user: FetchedMember) -> None: + """Shows the specified user's nomination history.""" + result = await self.bot.api_client.get( + self.api_endpoint, + params={ + 'user__id': str(user.id), + 'ordering': "-active,-inserted_at" + } + ) + if not result: + await ctx.send(":warning: This user has never been nominated") + return + + embed = Embed( + title=f"Nominations for {user.display_name} `({user.id})`", + color=Color.blue() + ) + lines = [self._nomination_to_string(nomination) for nomination in result] + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) + @has_any_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + return + + if await self.unwatch(user.id, reason): + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + else: + await ctx.send(":x: The specified user does not have an active nomination") + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + await ctx.send_help(ctx.command) + + @nomination_edit_group.command(name='reason') + @has_any_role(*MODERATION_ROLES) + async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None: + """Edits the reason of a specific nominator in a specific active nomination.""" + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") + return + + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + if not nomination["active"]: + await ctx.send(":x: Can't edit the reason of an inactive nomination.") + return + + if not any(entry["actor"] == actor.id for entry in nomination["entries"]): + await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") + return + + self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={"actor": actor.id, "reason": reason} + ) + await self.fetch_user_cache() # Update cache + await ctx.send(":white_check_mark: Successfully updated nomination reason.") + + @nomination_edit_group.command(name='end_reason') + @has_any_role(*MODERATION_ROLES) + async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """Edits the unnominate reason for the nomination with the given `id`.""" + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + return + + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + if nomination["active"]: + await ctx.send(":x: Can't edit the end reason of an active nomination.") + return + + self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={"end_reason": reason} + ) + await self.fetch_user_cache() # Update cache. + await ctx.send(":white_check_mark: Updated the end reason of the nomination!") + + @Cog.listener() + async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + """Remove `user` from the talent pool after they are banned.""" + await self.unwatch(user.id, "User was banned.") + + async def unwatch(self, user_id: int, reason: str) -> bool: + """End the active nomination of a user with the given reason and return True on success.""" + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + {"user__id": str(user_id)}, + self.api_default_params, + ) + ) + + if not active_nomination: + log.debug(f"No active nominate exists for {user_id=}") + return False + + log.info(f"Ending nomination: {user_id=} {reason=}") + + nomination = active_nomination[0] + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + self._remove_user(user_id) + + return True + + def _nomination_to_string(self, nomination_object: dict) -> str: + """Creates a string representation of a nomination.""" + guild = self.bot.get_guild(Guild.id) + entries = [] + for site_entry in nomination_object["entries"]: + actor_id = site_entry["actor"] + actor = guild.get_member(actor_id) + + reason = site_entry["reason"] or "*None*" + created = time.format_infraction(site_entry["inserted_at"]) + entries.append( + f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}" + ) + + entries_string = "\n\n".join(entries) + + active = nomination_object["active"] + + start_date = time.format_infraction(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Nomination ID: `{nomination_object["id"]}` + + {entries_string} + =============== + """ + ) + else: + end_date = time.format_infraction(nomination_object["ended_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Nomination ID: `{nomination_object["id"]}` + + {entries_string} + + End date: {end_date} + Unwatch reason: {nomination_object["end_reason"]} + =============== + """ + ) + + return lines.strip() + + +def setup(bot: Bot) -> None: + """Load the TalentPool cog.""" + bot.add_cog(TalentPool(bot)) diff --git a/bot/exts/recruitment/talentpool/talentpool.py b/bot/exts/recruitment/talentpool/talentpool.py deleted file mode 100644 index d75688fa6..000000000 --- a/bot/exts/recruitment/talentpool/talentpool.py +++ /dev/null @@ -1,335 +0,0 @@ -import logging -import textwrap -from collections import ChainMap -from typing import Union - -from discord import Color, Embed, Member, User -from discord.ext.commands import Cog, Context, group, has_any_role - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.exts.moderation.watchchannels._watchchannel import WatchChannel -from bot.pagination import LinePaginator -from bot.utils import time - -REASON_MAX_CHARS = 1000 - -log = logging.getLogger(__name__) - - -class TalentPool(WatchChannel, Cog, name="Talentpool"): - """Relays messages of helper candidates to a watch channel to observe them.""" - - def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.talent_pool, - webhook_id=Webhooks.talent_pool, - api_endpoint='bot/nominations', - api_default_params={'active': 'true', 'ordering': '-inserted_at'}, - logger=log, - disable_header=True, - ) - - @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) - async def nomination_group(self, ctx: Context) -> None: - """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.send_help(ctx.command) - - @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) - @has_any_role(*MODERATION_ROLES) - async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> None: - """ - Shows the users that are currently being monitored in the talent pool. - - The optional kwarg `oldest_first` can be used to order the list by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - - @nomination_group.command(name='oldest') - @has_any_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: - """ - Shows talent pool monitored users ordered by oldest nomination. - - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. - """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - - @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) - @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: - """ - Relay messages sent by the given `user` to the `#talent-pool` channel. - - A `reason` for adding the user to the talent pool is optional. - If given, it will be displayed in the header when relaying messages of this user to the channel. - """ - if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") - return - - if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): - await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") - return - - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update the user cache; can't add {user}") - return - - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") - return - - # Manual request with `raise_for_status` as False because we want the actual response - session = self.bot.api_client.session - url = self.bot.api_client._url_for(self.api_endpoint) - kwargs = { - 'json': { - 'actor': ctx.author.id, - 'reason': reason, - 'user': user.id - }, - 'raise_for_status': False, - } - async with session.post(url, **kwargs) as resp: - response_data = await resp.json() - - if resp.status == 400: - if response_data.get('user', False): - await ctx.send(":x: The specified user can't be found in the database tables") - elif response_data.get('actor', False): - await ctx.send(":x: You have already nominated this user") - - return - else: - resp.raise_for_status() - - self.watched_users[user.id] = response_data - msg = f":white_check_mark: The nomination for {user} has been added to the talent pool" - - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - "ordering": "-inserted_at" - } - ) - - if history: - msg += f"\n\n({len(history)} previous nominations in total)" - - await ctx.send(msg) - - @nomination_group.command(name='history', aliases=('info', 'search')) - @has_any_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedMember) -> None: - """Shows the specified user's nomination history.""" - result = await self.bot.api_client.get( - self.api_endpoint, - params={ - 'user__id': str(user.id), - 'ordering': "-active,-inserted_at" - } - ) - if not result: - await ctx.send(":warning: This user has never been nominated") - return - - embed = Embed( - title=f"Nominations for {user.display_name} `({user.id})`", - color=Color.blue() - ) - lines = [self._nomination_to_string(nomination) for nomination in result] - await LinePaginator.paginate( - lines, - ctx=ctx, - embed=embed, - empty=True, - max_lines=3, - max_size=1000 - ) - - @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) - @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """ - Ends the active nomination of the specified user with the given reason. - - Providing a `reason` is required. - """ - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") - return - - if await self.unwatch(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - else: - await ctx.send(":x: The specified user does not have an active nomination") - - @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) - async def nomination_edit_group(self, ctx: Context) -> None: - """Commands to edit nominations.""" - await ctx.send_help(ctx.command) - - @nomination_edit_group.command(name='reason') - @has_any_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None: - """Edits the reason of a specific nominator in a specific active nomination.""" - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") - return - - try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") - return - else: - raise - - if not nomination["active"]: - await ctx.send(":x: Can't edit the reason of an inactive nomination.") - return - - if not any(entry["actor"] == actor.id for entry in nomination["entries"]): - await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") - return - - self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={"actor": actor.id, "reason": reason} - ) - await self.fetch_user_cache() # Update cache - await ctx.send(":white_check_mark: Successfully updated nomination reason.") - - @nomination_edit_group.command(name='end_reason') - @has_any_role(*MODERATION_ROLES) - async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: - """Edits the unnominate reason for the nomination with the given `id`.""" - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") - return - - try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") - return - else: - raise - - if nomination["active"]: - await ctx.send(":x: Can't edit the end reason of an active nomination.") - return - - self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") - - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={"end_reason": reason} - ) - await self.fetch_user_cache() # Update cache. - await ctx.send(":white_check_mark: Updated the end reason of the nomination!") - - @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: - """Remove `user` from the talent pool after they are banned.""" - await self.unwatch(user.id, "User was banned.") - - async def unwatch(self, user_id: int, reason: str) -> bool: - """End the active nomination of a user with the given reason and return True on success.""" - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - {"user__id": str(user_id)}, - self.api_default_params, - ) - ) - - if not active_nomination: - log.debug(f"No active nominate exists for {user_id=}") - return False - - log.info(f"Ending nomination: {user_id=} {reason=}") - - nomination = active_nomination[0] - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - self._remove_user(user_id) - - return True - - def _nomination_to_string(self, nomination_object: dict) -> str: - """Creates a string representation of a nomination.""" - guild = self.bot.get_guild(Guild.id) - entries = [] - for site_entry in nomination_object["entries"]: - actor_id = site_entry["actor"] - actor = guild.get_member(actor_id) - - reason = site_entry["reason"] or "*None*" - created = time.format_infraction(site_entry["inserted_at"]) - entries.append( - f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}" - ) - - entries_string = "\n\n".join(entries) - - active = nomination_object["active"] - - start_date = time.format_infraction(nomination_object["inserted_at"]) - if active: - lines = textwrap.dedent( - f""" - =============== - Status: **Active** - Date: {start_date} - Nomination ID: `{nomination_object["id"]}` - - {entries_string} - =============== - """ - ) - else: - end_date = time.format_infraction(nomination_object["ended_at"]) - lines = textwrap.dedent( - f""" - =============== - Status: Inactive - Date: {start_date} - Nomination ID: `{nomination_object["id"]}` - - {entries_string} - - End date: {end_date} - Unwatch reason: {nomination_object["end_reason"]} - =============== - """ - ) - - return lines.strip() - - -def setup(bot: Bot) -> None: - """Load the TalentPool cog.""" - bot.add_cog(TalentPool(bot)) -- cgit v1.2.3 From f7f38d30cd7c26f9941b77c155ed5876fc2c410a Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Wed, 10 Mar 2021 20:20:17 +0200 Subject: Make talentpool a package and move cog load to __init__.py --- bot/exts/recruitment/talentpool/__init__.py | 8 ++++++++ bot/exts/recruitment/talentpool/_cog.py | 5 ----- 2 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 bot/exts/recruitment/talentpool/__init__.py diff --git a/bot/exts/recruitment/talentpool/__init__.py b/bot/exts/recruitment/talentpool/__init__.py new file mode 100644 index 000000000..52d27eb99 --- /dev/null +++ b/bot/exts/recruitment/talentpool/__init__.py @@ -0,0 +1,8 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Load the TalentPool cog.""" + from bot.exts.recruitment.talentpool._cog import TalentPool + + bot.add_cog(TalentPool(bot)) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index d75688fa6..67513f386 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -328,8 +328,3 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) return lines.strip() - - -def setup(bot: Bot) -> None: - """Load the TalentPool cog.""" - bot.add_cog(TalentPool(bot)) -- cgit v1.2.3 From f6b608a977406810d95e4a1dfccbb915bf62268e Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Wed, 10 Mar 2021 20:36:06 +0200 Subject: Add __init__.py to recruitment Make it a package as well so that the talentpool actually loads. --- bot/exts/recruitment/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 bot/exts/recruitment/__init__.py diff --git a/bot/exts/recruitment/__init__.py b/bot/exts/recruitment/__init__.py new file mode 100644 index 000000000..e69de29bb -- cgit v1.2.3 From 4f08f041d03a130012d83c50999a18a39e75dbdc Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 11 Mar 2021 01:37:40 +0200 Subject: Added an auto-reviewer to the talentpool cog This commit adds the functionality to automatically review a nominee a set number of days after being nominated. This is implemented by subclassing the Scheduler and formatting a review after 30 days. The review contains details of the nominee, their nominations, the number of messages they have and the channels they're most active in, and statistics about their infractions and previous nominations. Lastly, the bot will add three emojis to the review: eyes to mark as seen, a thumbsup, and thumbsdown for the vote itself. The code accounts for the possibility of the review being too long for a single message but splitting it where necessary. --- bot/exts/moderation/watchchannels/_watchchannel.py | 78 ++++-- bot/exts/recruitment/talentpool/_cog.py | 71 +++++- bot/exts/recruitment/talentpool/_review.py | 273 +++++++++++++++++++++ bot/utils/time.py | 8 + 4 files changed, 404 insertions(+), 26 deletions(-) create mode 100644 bot/exts/recruitment/talentpool/_review.py diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 0793a66af..b121243ce 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -5,9 +5,8 @@ import textwrap from abc import abstractmethod from collections import defaultdict, deque from dataclasses import dataclass -from typing import Optional +from typing import Any, Dict, Optional -import dateutil.parser import discord from discord import Color, DMChannel, Embed, HTTPException, Message, errors from discord.ext.commands import Cog, Context @@ -20,7 +19,7 @@ from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import CogABCMeta, messages -from bot.utils.time import time_since +from bot.utils.time import get_time_delta log = logging.getLogger(__name__) @@ -136,7 +135,10 @@ class WatchChannel(metaclass=CogABCMeta): if not await self.fetch_user_cache(): await self.modlog.send_log_message( title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", - text="Could not retrieve the list of watched users from the API and messages will not be relayed.", + text=( + "Could not retrieve the list of watched users from the API. " + "Messages will not be relayed, and reviews not rescheduled." + ), ping_everyone=True, icon_url=Icons.token_removed, colour=Color.red() @@ -280,7 +282,7 @@ class WatchChannel(metaclass=CogABCMeta): actor = actor.display_name if actor else self.watched_users[user_id]['actor'] inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = self._get_time_delta(inserted_at) + time_delta = get_time_delta(inserted_at) reason = self.watched_users[user_id]['reason'] @@ -308,35 +310,61 @@ class WatchChannel(metaclass=CogABCMeta): The optional kwarg `update_cache` specifies whether the cache should be refreshed by polling the API. """ - if update_cache: - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") - update_cache = False + watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache) - lines = [] - for user_id, user_data in self.watched_users.items(): - inserted_at = user_data['inserted_at'] - time_delta = self._get_time_delta(inserted_at) - lines.append(f"• <@{user_id}> (added {time_delta})") - - if oldest_first: - lines.reverse() + if update_cache and not watched_data["updated"]: + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") - lines = lines or ("There's nothing here yet.",) + lines = watched_data["info"].values() or ("There's nothing here yet.",) embed = Embed( - title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + title=watched_data["title"], color=Color.blue() ) await LinePaginator.paginate(lines, ctx, embed, empty=False) - @staticmethod - def _get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) + async def prepare_watched_users_data( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> Dict[str, Any]: + """ + Prepare overview information of watched users to list. + + The optional kwarg `oldest_first` orders the list by oldest entry. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + + Returns a dictionary with a "title" key for the list's title, and a "info" key with + information about each user. + + The dictionary additionally has an "updated" field which is true if a cache update was + requested and it succeeded. + """ + list_data = {} + if update_cache: + if not await self.fetch_user_cache(): + update_cache = False + list_data["updated"] = update_cache + + watched_iter = self.watched_users.items() + if oldest_first: + watched_iter = reversed(watched_iter) + + list_data["info"] = {} + for user_id, user_data in watched_iter: + member = ctx.guild.get_member(user_id) + line = f"• <@{user_id}>" + if member: + line += f" ({member.name}#{member.discriminator})" + inserted_at = user_data['inserted_at'] + line += f", added {get_time_delta(inserted_at)}" + if not member: # Cross off users who left the server. + line = f"~~{line}~~" + list_data["info"][user_id] = line + + list_data["title"] = f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})" - return time_delta + return list_data def _remove_user(self, user_id: int) -> None: """Removes a user from a watch channel.""" diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 67513f386..60f5cdf8c 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,3 +1,4 @@ + import logging import textwrap from collections import ChainMap @@ -11,6 +12,7 @@ from bot.bot import Bot from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks from bot.converters import FetchedMember from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import time @@ -33,6 +35,9 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): disable_header=True, ) + self.reviewer = Reviewer(self.__class__.__name__, bot, self) + self.bot.loop.create_task(self.reviewer.reschedule_reviews()) + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: @@ -54,6 +59,44 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """ await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + async def list_watched_users( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: + """ + Gives an overview of the nominated users list. + + It specifies the users' mention, name, how long ago they were nominated, and whether their + review was scheduled or already posted. + + The optional kwarg `oldest_first` orders the list by oldest entry. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + """ + # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding + # the list_watched_users function. + watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache) + + if update_cache and not watched_data["updated"]: + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + + lines = [] + for user_id, line in watched_data["info"].items(): + if self.watched_users[user_id]['reviewed']: + line += " *(reviewed)*" + elif user_id in self.reviewer: + line += " *(scheduled)*" + lines.append(line) + + if not lines: + lines = ("There's nothing here yet.",) + + embed = Embed( + title=watched_data["title"], + color=Color.blue() + ) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + @nomination_group.command(name='oldest') @has_any_role(*MODERATION_ROLES) async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: @@ -115,7 +158,9 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): resp.raise_for_status() self.watched_users[user.id] = response_data - msg = f":white_check_mark: The nomination for {user} has been added to the talent pool" + + if user.id not in self.reviewer: + self.reviewer.schedule_review(user.id) history = await self.bot.api_client.get( self.api_endpoint, @@ -126,6 +171,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): } ) + msg = f"✅ The nomination for {user} has been added to the talent pool" if history: msg += f"\n\n({len(history)} previous nominations in total)" @@ -249,6 +295,22 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await self.fetch_user_cache() # Update cache. await ctx.send(":white_check_mark: Updated the end reason of the nomination!") + @nomination_group.command(aliases=('mr',)) + async def mark_reviewed(self, ctx: Context, nomination_id: int) -> None: + """Mark a nomination as reviewed and cancel the review task.""" + if not await self.reviewer.mark_reviewed(ctx, nomination_id): + return + await ctx.channel.send(f"✅ The nomination with ID `{nomination_id}` was marked as reviewed.") + + @nomination_group.command(aliases=('review',)) + async def post_review(self, ctx: Context, nomination_id: int) -> None: + """Post the automatic review for the user ahead of time.""" + if not (user_id := await self.reviewer.mark_reviewed(ctx, nomination_id)): + return + + await self.reviewer.post_review(user_id, update_database=False) + await ctx.message.add_reaction("✅") + @Cog.listener() async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: """Remove `user` from the talent pool after they are banned.""" @@ -277,6 +339,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) self._remove_user(user_id) + self.reviewer.cancel(user_id) + return True def _nomination_to_string(self, nomination_object: dict) -> str: @@ -328,3 +392,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) return lines.strip() + + def cog_unload(self) -> None: + """Cancels all review tasks on cog unload.""" + super().cog_unload() + self.reviewer.cancel_all() diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py new file mode 100644 index 000000000..64a1c6226 --- /dev/null +++ b/bot/exts/recruitment/talentpool/_review.py @@ -0,0 +1,273 @@ +import asyncio +import logging +import textwrap +import typing +from collections import Counter +from datetime import datetime, timedelta +from typing import List, Optional + +from dateutil.parser import isoparse +from dateutil.relativedelta import relativedelta +from discord import Member, Message, TextChannel +from discord.ext.commands import Context + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Guild, Roles +from bot.utils.scheduling import Scheduler +from bot.utils.time import get_time_delta, humanize_delta, time_since + +if typing.TYPE_CHECKING: + from bot.exts.recruitment.talentpool._cog import TalentPool + +log = logging.getLogger(__name__) + +# Maximum amount of days before an automatic review is posted. +MAX_DAYS_IN_POOL = 30 + +# Maximum amount of characters allowed in a message +MAX_MESSAGE_SIZE = 2000 + + +class Reviewer(Scheduler): + """Schedules, formats, and publishes reviews of helper nominees.""" + + def __init__(self, name: str, bot: Bot, pool: 'TalentPool'): + super().__init__(name) + self.bot = bot + self._pool = pool + + async def reschedule_reviews(self) -> None: + """Reschedule all active nominations to be reviewed at the appropriate time.""" + log.trace("Rescheduling reviews") + await self.bot.wait_until_guild_available() + # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function. + await self._pool.fetch_user_cache() + + for user_id, user_data in self._pool.watched_users.items(): + if not user_data["reviewed"]: + self.schedule_review(user_id) + + def schedule_review(self, user_id: int) -> None: + """Schedules a single user for review.""" + log.trace(f"Scheduling review of user with ID {user_id}") + + user_data = self._pool.watched_users[user_id] + inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) + review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) + + self.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True)) + + async def post_review(self, user_id: int, update_database: bool) -> None: + """Format a generic review of a user and post it to the mod announcements channel.""" + log.trace(f"Posting the review of {user_id}") + + nomination = self._pool.watched_users[user_id] + guild = self.bot.get_guild(Guild.id) + channel = guild.get_channel(Channels.mod_announcements) + member = guild.get_member(user_id) + if not member: + channel.send(f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server 😔") + return + + if update_database: + await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + + opening = f"<@&{Roles.moderators}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" + + current_nominations = "\n\n".join( + f"**<@{entry['actor']}>:** {entry['reason']}" for entry in nomination['entries'] + ) + current_nominations = f"**Nominated by:**\n{current_nominations}" + + review_body = await self._construct_review_body(member) + + vote_request = "*Refer to their nomination and infraction histories for further details*.\n" + vote_request += "*Please react 👀 if you've seen this post. Then react 👍 for approval, or 👎 for disapproval*." + + review = "\n\n".join(part for part in (opening, current_nominations, review_body, vote_request)) + + message = (await self._bulk_send(channel, review))[-1] + for reaction in ("👀", "👍", "👎"): + await message.add_reaction(reaction) + + async def _construct_review_body(self, member: Member) -> str: + """Formats the body of the nomination, with details of activity, infractions, and previous nominations.""" + activity = await self._activity_review(member) + infractions = await self._infractions_review(member) + prev_nominations = await self._previous_nominations_review(member) + + body = f"{activity}\n\n{infractions}" + if prev_nominations: + body += f"\n\n{prev_nominations}" + return body + + async def _activity_review(self, member: Member) -> str: + """ + Format the activity of the nominee. + + Adds details on how long they've been on the server, their total message count, + and the channels they're the most active in. + """ + log.trace(f"Fetching the metricity data for {member.id}'s review") + try: + user_activity = await self.bot.api_client.get(f"bot/users/{member.id}/metricity_review_data") + except ResponseCodeError as e: + if e.status == 404: + messages = "no" + channels = "" + else: + raise + else: + messages = user_activity["total_messages"] + # Making this part flexible to the amount of expected and returned channels. + first_channel = user_activity["top_channel_activity"][0] + channels = f", with {first_channel[1]} messages in {first_channel[0]}" + + if len(user_activity["top_channel_activity"]) > 1: + channels += ", " + ", ".join( + f"{count} in {channel}" for channel, count in user_activity["top_channel_activity"][1: -1] + ) + last_channel = user_activity["top_channel_activity"][-1] + channels += f", and {last_channel[1]} in {last_channel[0]}" + + time_on_server = humanize_delta(relativedelta(datetime.utcnow(), member.joined_at), max_units=2) + review = f"{member.name} has been on the server for **{time_on_server}**" + review += f" and has **{messages} messages**{channels}." + + return review + + async def _infractions_review(self, member: Member) -> str: + """ + Formats the review of the nominee's infractions, if any. + + The infractions are listed by type and amount, and it is stated how long ago the last one was issued. + """ + log.trace(f"Fetching the infraction data for {member.id}'s review") + infraction_list = await self.bot.api_client.get( + 'bot/infractions/expanded', + params={'user__id': str(member.id), 'ordering': '-inserted_at'} + ) + + if not infraction_list: + return "They have no infractions." + + # Count the amount of each type of infraction. + infr_stats = list(Counter(infr["type"] for infr in infraction_list).items()) + + # Format into a sentence. + infractions = ", ".join( + f"{count} {self._format_infr_name(infr_type, count)}" + for infr_type, count in infr_stats[:-1] + ) + if len(infr_stats) > 1: + last_infr, last_count = infr_stats[-1] + infractions += f", and {last_count} {self._format_infr_name(last_infr, last_count)}" + + infractions = f"**{infractions}**" + + # Show when the last one was issued. + if len(infraction_list) == 1: + infractions += ", issued " + else: + infractions += ", with the last infraction issued " + + # Infractions were ordered by time since insertion descending. + infractions += get_time_delta(infraction_list[0]['inserted_at']) + + return f"They have {infractions}." + + @staticmethod + def _format_infr_name(infr_type: str, count: int) -> str: + """ + Format the infraction type in a way readable in a sentence. + + Underscores are replaced with spaces, as well as *attempting* to show the appropriate plural form if necessary. + This function by no means covers all rules of grammar. + """ + formatted = infr_type.replace("_", " ") + if count > 1: + if infr_type.endswith(('ch', 'sh')): + formatted += "e" + formatted += "s" + + return formatted + + async def _previous_nominations_review(self, member: Member) -> Optional[str]: + """ + Formats the review of the nominee's previous nominations. + + The number of previous nominations and unnominations are shown, as well as the reason the last one ended. + """ + log.trace(f"Fetching the nomination history data for {member.id}'s review") + history = await self.bot.api_client.get( + self._pool.api_endpoint, + params={ + "user__id": str(member.id), + "active": "false", + "ordering": "-inserted_at" + } + ) + + if not history: + return + + num_entries = sum(len(nomination["entries"]) for nomination in history) + + nomination_times = f"{num_entries} times" if num_entries > 1 else "once" + rejection_times = f"{len(history)} times" if len(history) > 1 else "once" + review = f"They were nominated **{nomination_times}** before" + review += f", but their nomination was called off **{rejection_times}**." + + end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None), max_units=2) + review += f"\nThe last one ended {end_time} with the reason: {history[0]['end_reason']}" + + return review + + @staticmethod + async def _bulk_send(channel: TextChannel, text: str) -> List[Message]: + """ + Split a text into several if necessary, and post them to the channel. + + Returns the resulting message objects. + """ + messages = textwrap.wrap(text, width=MAX_MESSAGE_SIZE, replace_whitespace=False) + + results = [] + for message in messages: + await asyncio.sleep(1) + results.append(await channel.send(message)) + + return results + + async def mark_reviewed(self, ctx: Context, nomination_id: int) -> Optional[int]: + """ + Mark an active nomination as reviewed, updating the database and canceling the review task. + + On success, returns the user ID. + """ + log.trace(f"Updating nomination #{nomination_id} as review") + try: + nomination = await self.bot.api_client.get(f"{self._pool.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't find nomination with id {nomination_id}") + await ctx.send(f"❌ Can't find a nomination with id `{nomination_id}`") + return None + else: + raise + + if nomination["reviewed"]: + await ctx.send("❌ This nomination was already reviewed, but here's a cookie 🍪") + return None + elif not nomination["active"]: + await ctx.send("❌ This nomination is inactive") + return None + + await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + if nomination["user"] in self: + self.cancel(nomination["user"]) + + await self._pool.fetch_user_cache() + + return nomination["user"] diff --git a/bot/utils/time.py b/bot/utils/time.py index f862e40f7..466f0adc2 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -85,6 +85,14 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: return humanized +def get_time_delta(time_string: str) -> str: + """Returns the time in human-readable time delta format.""" + date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + def parse_duration_string(duration: str) -> Optional[relativedelta]: """ Converts a `duration` string to a relativedelta object. -- cgit v1.2.3 From 0eb8059a0ba6bb6bce464b4b3afb7847aa3bf098 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 11 Mar 2021 02:59:13 +0200 Subject: Limit new commands to mods+ --- bot/exts/recruitment/talentpool/_cog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 60f5cdf8c..070a4fd83 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -296,6 +296,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(":white_check_mark: Updated the end reason of the nomination!") @nomination_group.command(aliases=('mr',)) + @has_any_role(*MODERATION_ROLES) async def mark_reviewed(self, ctx: Context, nomination_id: int) -> None: """Mark a nomination as reviewed and cancel the review task.""" if not await self.reviewer.mark_reviewed(ctx, nomination_id): @@ -303,6 +304,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.channel.send(f"✅ The nomination with ID `{nomination_id}` was marked as reviewed.") @nomination_group.command(aliases=('review',)) + @has_any_role(*MODERATION_ROLES) async def post_review(self, ctx: Context, nomination_id: int) -> None: """Post the automatic review for the user ahead of time.""" if not (user_id := await self.reviewer.mark_reviewed(ctx, nomination_id)): -- cgit v1.2.3 From 608f755deead9f180d8c714b69d82c606dba931a Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 11 Mar 2021 22:50:02 +0200 Subject: The 'seen vote' emoji is now a random ducky. --- bot/exts/recruitment/talentpool/_review.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 64a1c6226..adab1a907 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -1,14 +1,15 @@ import asyncio import logging +import random import textwrap import typing from collections import Counter from datetime import datetime, timedelta -from typing import List, Optional +from typing import List, Optional, Union from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta -from discord import Member, Message, TextChannel +from discord import Emoji, Member, Message, TextChannel from discord.ext.commands import Context from bot.api import ResponseCodeError @@ -82,13 +83,15 @@ class Reviewer(Scheduler): review_body = await self._construct_review_body(member) + seen_emoji = self._random_ducky(guild) vote_request = "*Refer to their nomination and infraction histories for further details*.\n" - vote_request += "*Please react 👀 if you've seen this post. Then react 👍 for approval, or 👎 for disapproval*." + vote_request += f"*Please react {seen_emoji} if you've seen this post." + vote_request += " Then react 👍 for approval, or 👎 for disapproval*." review = "\n\n".join(part for part in (opening, current_nominations, review_body, vote_request)) message = (await self._bulk_send(channel, review))[-1] - for reaction in ("👀", "👍", "👎"): + for reaction in (seen_emoji, "👍", "👎"): await message.add_reaction(reaction) async def _construct_review_body(self, member: Member) -> str: @@ -224,6 +227,14 @@ class Reviewer(Scheduler): return review + @staticmethod + def _random_ducky(guild: Guild) -> Union[Emoji, str]: + """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns 👀.""" + duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")] + if not duckies: + return "👀" + return random.choice(duckies) + @staticmethod async def _bulk_send(channel: TextChannel, text: str) -> List[Message]: """ -- cgit v1.2.3 From de0bc6ea58a2766d9637af80e703e11291e424e1 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 12 Mar 2021 14:14:12 +0200 Subject: Reviewer no longer subclasses Scheduler It didn't make much sense for the Reviewer to subclasses Scheduler. The Scheduler has methods that don't make sense to use on the Reviewer directly. There is now a Scheduler object as an attribute of the Reviewer. Interacting with it is done by adding __contains__, cancel, and cancel_all methods. --- bot/exts/recruitment/talentpool/_review.py | 36 +++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index adab1a907..beb4c130f 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -30,13 +30,17 @@ MAX_DAYS_IN_POOL = 30 MAX_MESSAGE_SIZE = 2000 -class Reviewer(Scheduler): +class Reviewer: """Schedules, formats, and publishes reviews of helper nominees.""" def __init__(self, name: str, bot: Bot, pool: 'TalentPool'): - super().__init__(name) self.bot = bot self._pool = pool + self._review_scheduler = Scheduler(name) + + def __contains__(self, user_id: int) -> bool: + """Return True if the user with ID user_id is scheduled for review, False otherwise.""" + return user_id in self._review_scheduler async def reschedule_reviews(self) -> None: """Reschedule all active nominations to be reviewed at the appropriate time.""" @@ -57,13 +61,17 @@ class Reviewer(Scheduler): inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) - self.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True)) + self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True)) async def post_review(self, user_id: int, update_database: bool) -> None: """Format a generic review of a user and post it to the mod announcements channel.""" log.trace(f"Posting the review of {user_id}") nomination = self._pool.watched_users[user_id] + if not nomination: + log.trace(f"There doesn't appear to be an active nomination for {user_id}") + return + guild = self.bot.get_guild(Guild.id) channel = guild.get_channel(Channels.mod_announcements) member = guild.get_member(user_id) @@ -276,9 +284,27 @@ class Reviewer(Scheduler): return None await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) - if nomination["user"] in self: - self.cancel(nomination["user"]) + if nomination["user"] in self._review_scheduler: + self._review_scheduler.cancel(nomination["user"]) await self._pool.fetch_user_cache() return nomination["user"] + + def cancel(self, user_id: int) -> None: + """ + Cancels the review of the nominee with ID user_id. + + It's important to note that this applies only until reschedule_reviews is called again. + To permenantly cancel someone's review, either remove them from the pool, or use mark_reviewed. + """ + self._review_scheduler.cancel(user_id) + + def cancel_all(self) -> None: + """ + Cancels all reviews. + + It's important to note that this applies only until reschedule_reviews is called again. + To permenantly cancel someone's review, either remove them from the pool, or use mark_reviewed. + """ + self._review_scheduler.cancel_all() -- cgit v1.2.3 From 4f17ba526995927fa3b1fb8e925179ab61e26265 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 12 Mar 2021 15:16:43 +0200 Subject: Improve string building for long lines --- bot/exts/recruitment/talentpool/_review.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index beb4c130f..56b51925e 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -92,9 +92,11 @@ class Reviewer: review_body = await self._construct_review_body(member) seen_emoji = self._random_ducky(guild) - vote_request = "*Refer to their nomination and infraction histories for further details*.\n" - vote_request += f"*Please react {seen_emoji} if you've seen this post." - vote_request += " Then react 👍 for approval, or 👎 for disapproval*." + vote_request = ( + "*Refer to their nomination and infraction histories for further details*.\n" + f"*Please react {seen_emoji} if you've seen this post." + " Then react 👍 for approval, or 👎 for disapproval*." + ) review = "\n\n".join(part for part in (opening, current_nominations, review_body, vote_request)) @@ -143,8 +145,10 @@ class Reviewer: channels += f", and {last_channel[1]} in {last_channel[0]}" time_on_server = humanize_delta(relativedelta(datetime.utcnow(), member.joined_at), max_units=2) - review = f"{member.name} has been on the server for **{time_on_server}**" - review += f" and has **{messages} messages**{channels}." + review = ( + f"{member.name} has been on the server for **{time_on_server}**" + f" and has **{messages} messages**{channels}." + ) return review @@ -227,11 +231,13 @@ class Reviewer: nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - review = f"They were nominated **{nomination_times}** before" - review += f", but their nomination was called off **{rejection_times}**." - end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None), max_units=2) - review += f"\nThe last one ended {end_time} with the reason: {history[0]['end_reason']}" + + review = ( + f"They were nominated **{nomination_times}** before" + f", but their nomination was called off **{rejection_times}**." + f"\nThe last one ended {end_time} with the reason: {history[0]['end_reason']}" + ) return review -- cgit v1.2.3 From 4b5af57b4ed4eac18bf3c368f99e848e10a33cab Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sat, 13 Mar 2021 16:20:47 +0200 Subject: Use log instead of erroneous self.log --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 56b51925e..b84499d98 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -276,7 +276,7 @@ class Reviewer: nomination = await self.bot.api_client.get(f"{self._pool.api_endpoint}/{nomination_id}") except ResponseCodeError as e: if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find nomination with id {nomination_id}") + log.trace(f"Nomination API 404: Can't find nomination with id {nomination_id}") await ctx.send(f"❌ Can't find a nomination with id `{nomination_id}`") return None else: -- cgit v1.2.3 From 4324b3f6ac80bbcbd2eef80303bf7caf1dfa8cca Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sat, 13 Mar 2021 16:27:28 +0200 Subject: Apply requested grammar and style changes. --- bot/exts/recruitment/talentpool/_cog.py | 11 ++++++++--- bot/exts/recruitment/talentpool/_review.py | 10 +++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 070a4fd83..7b21dcd53 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,4 +1,3 @@ - import logging import textwrap from collections import ChainMap @@ -47,7 +46,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) @has_any_role(*MODERATION_ROLES) async def watched_command( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + self, + ctx: Context, + oldest_first: bool = False, + update_cache: bool = True ) -> None: """ Shows the users that are currently being monitored in the talent pool. @@ -60,7 +62,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) async def list_watched_users( - self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + self, + ctx: Context, + oldest_first: bool = False, + update_cache: bool = True ) -> None: """ Gives an overview of the nominated users list. diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index b84499d98..682a32918 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -278,16 +278,16 @@ class Reviewer: if e.response.status == 404: log.trace(f"Nomination API 404: Can't find nomination with id {nomination_id}") await ctx.send(f"❌ Can't find a nomination with id `{nomination_id}`") - return None + return else: raise if nomination["reviewed"]: await ctx.send("❌ This nomination was already reviewed, but here's a cookie 🍪") - return None + return elif not nomination["active"]: await ctx.send("❌ This nomination is inactive") - return None + return await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) if nomination["user"] in self._review_scheduler: @@ -302,7 +302,7 @@ class Reviewer: Cancels the review of the nominee with ID user_id. It's important to note that this applies only until reschedule_reviews is called again. - To permenantly cancel someone's review, either remove them from the pool, or use mark_reviewed. + To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. """ self._review_scheduler.cancel(user_id) @@ -311,6 +311,6 @@ class Reviewer: Cancels all reviews. It's important to note that this applies only until reschedule_reviews is called again. - To permenantly cancel someone's review, either remove them from the pool, or use mark_reviewed. + To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. """ self._review_scheduler.cancel_all() -- cgit v1.2.3 From 7aa572752ff24541b203f85fca1b74a66d226782 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 16:50:34 +0200 Subject: Apply requested style and grammar changes --- bot/exts/recruitment/talentpool/_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 682a32918..b4e425187 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -271,7 +271,7 @@ class Reviewer: On success, returns the user ID. """ - log.trace(f"Updating nomination #{nomination_id} as review") + log.trace(f"Updating nomination #{nomination_id} as reviewed") try: nomination = await self.bot.api_client.get(f"{self._pool.api_endpoint}/{nomination_id}") except ResponseCodeError as e: @@ -299,7 +299,7 @@ class Reviewer: def cancel(self, user_id: int) -> None: """ - Cancels the review of the nominee with ID user_id. + Cancels the review of the nominee with ID `user_id`. It's important to note that this applies only until reschedule_reviews is called again. To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. -- cgit v1.2.3 From 09d7f0775109224faa3a437bc65546d24ae3576f Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 17:04:13 +0200 Subject: Add additional logging to _review.py --- bot/exts/recruitment/talentpool/_review.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index b4e425187..920728544 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -127,11 +127,14 @@ class Reviewer: user_activity = await self.bot.api_client.get(f"bot/users/{member.id}/metricity_review_data") except ResponseCodeError as e: if e.status == 404: + log.trace(f"The user {member.id} seems to have no activity logged in Metricity.") messages = "no" channels = "" else: + log.trace(f"An unexpected error occured while fetching information of user {member.id}.") raise else: + log.trace(f"Activity found for {member.id}, formatting review.") messages = user_activity["total_messages"] # Making this part flexible to the amount of expected and returned channels. first_channel = user_activity["top_channel_activity"][0] @@ -164,6 +167,7 @@ class Reviewer: params={'user__id': str(member.id), 'ordering': '-inserted_at'} ) + log.trace(f"{len(infraction_list)} infractions found for {member.id}, formatting review.") if not infraction_list: return "They have no infractions." @@ -224,6 +228,7 @@ class Reviewer: } ) + log.trace(f"{len(history)} previous nominations found for {member.id}, formatting review.") if not history: return @@ -257,6 +262,7 @@ class Reviewer: Returns the resulting message objects. """ messages = textwrap.wrap(text, width=MAX_MESSAGE_SIZE, replace_whitespace=False) + log.trace(f"The provided string will be sent to the channel {channel.id} as {len(messages)} messages.") results = [] for message in messages: @@ -304,6 +310,7 @@ class Reviewer: It's important to note that this applies only until reschedule_reviews is called again. To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. """ + log.trace(f"Canceling the review of user {user_id}.") self._review_scheduler.cancel(user_id) def cancel_all(self) -> None: @@ -313,4 +320,5 @@ class Reviewer: It's important to note that this applies only until reschedule_reviews is called again. To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. """ + log.trace("Canceling all reviews.") self._review_scheduler.cancel_all() -- cgit v1.2.3 From 69c49a8ca9aaf552719e1045c7a4c99f73185d62 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 17:55:44 +0200 Subject: Use ctx.send instead of ctx.channel.send Co-authored-by: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 7b21dcd53..f3e3539b6 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -306,7 +306,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Mark a nomination as reviewed and cancel the review task.""" if not await self.reviewer.mark_reviewed(ctx, nomination_id): return - await ctx.channel.send(f"✅ The nomination with ID `{nomination_id}` was marked as reviewed.") + await ctx.send(f"✅ The nomination with ID `{nomination_id}` was marked as reviewed.") @nomination_group.command(aliases=('review',)) @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 96003d7e388587a77d6f6424a1aa1c93d059be99 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 17:57:29 +0200 Subject: Properly await coroutine in post_review --- bot/exts/recruitment/talentpool/_review.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 920728544..5fb1a505f 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -76,7 +76,9 @@ class Reviewer: channel = guild.get_channel(Channels.mod_announcements) member = guild.get_member(user_id) if not member: - channel.send(f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server 😔") + await channel.send( + f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server 😔" + ) return if update_database: -- cgit v1.2.3 From 2d9c47180157e7b6667340abc241e0d65cdb9cc5 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 18:20:56 +0200 Subject: Replace mentions for ID's in watchlist lists Uncached mentions render as 'invalid' users on mobile, and with the list now showing the user's name we can now just show the ID without many problems. --- bot/exts/moderation/watchchannels/_watchchannel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index b121243ce..9f26c34f2 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -353,7 +353,7 @@ class WatchChannel(metaclass=CogABCMeta): list_data["info"] = {} for user_id, user_data in watched_iter: member = ctx.guild.get_member(user_id) - line = f"• <@{user_id}>" + line = f"• `{user_id}`" if member: line += f" ({member.name}#{member.discriminator})" inserted_at = user_data['inserted_at'] -- cgit v1.2.3 From 1127da5c9a50bd01155b993eb0bac3e540410df9 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 18:53:42 +0200 Subject: Default message in review when no nomination reason given --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 5fb1a505f..db710c278 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -87,7 +87,7 @@ class Reviewer: opening = f"<@&{Roles.moderators}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" current_nominations = "\n\n".join( - f"**<@{entry['actor']}>:** {entry['reason']}" for entry in nomination['entries'] + f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" for entry in nomination['entries'] ) current_nominations = f"**Nominated by:**\n{current_nominations}" -- cgit v1.2.3 From 94fc1cc0d4c7a69433c74eb555621374ac71ee22 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 20:21:16 +0200 Subject: Mark as reviewed when nominee is off server This is necessary as otherwise the bot would try to review them every time it restarts --- bot/exts/recruitment/talentpool/_review.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index db710c278..49aee8970 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -75,15 +75,16 @@ class Reviewer: guild = self.bot.get_guild(Guild.id) channel = guild.get_channel(Channels.mod_announcements) member = guild.get_member(user_id) + + if update_database: + await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + if not member: await channel.send( f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server 😔" ) return - if update_database: - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) - opening = f"<@&{Roles.moderators}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" current_nominations = "\n\n".join( -- cgit v1.2.3 From 3bf532b4ba499fc276c94f1cd6d3d859afbb925e Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 20:50:57 +0200 Subject: Don't reschedule reviews that are long overdue If it's been over a day overdue for a review, don't reschedule it. This is done in order to not fire reviews for all nominations which are over 30 days old when the auto-reviewing feature is merged. --- bot/exts/recruitment/talentpool/_review.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 49aee8970..ba1564602 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -61,7 +61,9 @@ class Reviewer: inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) - self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True)) + # If it's over a day overdue, it's probably an old nomination and shouldn't be automatically reviewed. + if datetime.utcnow() - review_at < timedelta(days=1): + self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True)) async def post_review(self, user_id: int, update_database: bool) -> None: """Format a generic review of a user and post it to the mod announcements channel.""" -- cgit v1.2.3 From a7c85564d90a3dc556a9582e925b33adc303de8f Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 19 Mar 2021 22:35:00 +0200 Subject: Review commands now use the user ID instead of nomination ID The user ID is much more accessible, and is usually what is used to obtain the nomination ID. --- bot/exts/recruitment/talentpool/_cog.py | 12 +++++------ bot/exts/recruitment/talentpool/_review.py | 34 ++++++++++++------------------ 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index f3e3539b6..b809cea17 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -302,17 +302,17 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(aliases=('mr',)) @has_any_role(*MODERATION_ROLES) - async def mark_reviewed(self, ctx: Context, nomination_id: int) -> None: - """Mark a nomination as reviewed and cancel the review task.""" - if not await self.reviewer.mark_reviewed(ctx, nomination_id): + async def mark_reviewed(self, ctx: Context, user_id: int) -> None: + """Mark a user's nomination as reviewed and cancel the review task.""" + if not await self.reviewer.mark_reviewed(ctx, user_id): return - await ctx.send(f"✅ The nomination with ID `{nomination_id}` was marked as reviewed.") + await ctx.send(f"✅ The user with ID `{user_id}` was marked as reviewed.") @nomination_group.command(aliases=('review',)) @has_any_role(*MODERATION_ROLES) - async def post_review(self, ctx: Context, nomination_id: int) -> None: + async def post_review(self, ctx: Context, user_id: int) -> None: """Post the automatic review for the user ahead of time.""" - if not (user_id := await self.reviewer.mark_reviewed(ctx, nomination_id)): + if not await self.reviewer.mark_reviewed(ctx, user_id): return await self.reviewer.post_review(user_id, update_database=False) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index ba1564602..c2c1312d9 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -276,37 +276,29 @@ class Reviewer: return results - async def mark_reviewed(self, ctx: Context, nomination_id: int) -> Optional[int]: + async def mark_reviewed(self, ctx: Context, user_id: int) -> bool: """ Mark an active nomination as reviewed, updating the database and canceling the review task. - On success, returns the user ID. + Returns True if the user was successfully marked as reviewed, False otherwise. """ - log.trace(f"Updating nomination #{nomination_id} as reviewed") - try: - nomination = await self.bot.api_client.get(f"{self._pool.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - log.trace(f"Nomination API 404: Can't find nomination with id {nomination_id}") - await ctx.send(f"❌ Can't find a nomination with id `{nomination_id}`") - return - else: - raise + log.trace(f"Updating user {user_id} as reviewed") + await self._pool.fetch_user_cache() + if user_id not in self._pool.watched_users: + log.trace(f"Can't find a nominated user with id {user_id}") + await ctx.send(f"❌ Can't find a currently nominated user with id `{user_id}`") + return False + nomination = self._pool.watched_users[user_id] if nomination["reviewed"]: await ctx.send("❌ This nomination was already reviewed, but here's a cookie 🍪") - return - elif not nomination["active"]: - await ctx.send("❌ This nomination is inactive") - return + return False await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) - if nomination["user"] in self._review_scheduler: - self._review_scheduler.cancel(nomination["user"]) - - await self._pool.fetch_user_cache() + if user_id in self._review_scheduler: + self._review_scheduler.cancel(user_id) - return nomination["user"] + return True def cancel(self, user_id: int) -> None: """ -- cgit v1.2.3