diff options
| -rw-r--r-- | bot/cogs/alias.py | 2 | ||||
| -rw-r--r-- | bot/cogs/bigbrother.py | 173 | ||||
| -rw-r--r-- | bot/constants.py | 1 | ||||
| -rw-r--r-- | config-default.yml | 2 |
4 files changed, 134 insertions, 44 deletions
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 0b848c773..0e6b3a7c6 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -71,7 +71,7 @@ class Alias: @command(name="watch", hidden=True) async def bigbrother_watch_alias( - self, ctx, user: User, *, reason: str + self, ctx: Context, user: User, *, reason: str ): """ Alias for invoking <prefix>bigbrother watch user [text_channel]. diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 70916cd7b..f07289985 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -3,23 +3,30 @@ import logging import re from collections import defaultdict, deque from time import strptime, struct_time -from typing import List, Union +from typing import List, NamedTuple, Optional, Union from aiohttp import ClientError from discord import Color, Embed, Guild, Member, Message, TextChannel, User -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Context, command, group from bot.constants import BigBrother as BigBrotherConfig, Channels, Emojis, Guild as GuildConfig, Keys, Roles, URLs from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import messages from bot.utils.moderation import post_infraction +from bot.utils.time import parse_rfc1123, time_since log = logging.getLogger(__name__) URL_RE = re.compile(r"(https?://[^\s]+)") +class WatchInformation(NamedTuple): + reason: str + actor_id: Optional[int] + inserted_at: Optional[str] + + class BigBrother: """User monitoring to assist with moderation.""" @@ -66,7 +73,7 @@ class BigBrother: data = await response.json() self.update_cache(data) - async def get_watch_reason(self, user_id: int) -> str: + async def get_watch_information(self, user_id: int) -> WatchInformation: """ Fetches and returns the latest watch reason for a user using the infraction API """ re_bb_watch = rf"^{self.infraction_watch_prefix}" @@ -84,20 +91,34 @@ class BigBrother: infraction_list = await response.json() except ClientError: log.exception(f"Failed to retrieve bb watch reason for {user_id}.") - return "(error retrieving bb reason)" + return WatchInformation(reason="(error retrieving bb reason)", actor_id=None, inserted_at=None) if infraction_list: + # Get the latest watch reason latest_reason_infraction = max(infraction_list, key=self._parse_infraction_time) + + # Get the actor of the watch/nominate action + actor_id = int(latest_reason_infraction["actor"]["user_id"]) + + # Get the date the watch was set + date = latest_reason_infraction["inserted_at"] + + # Get the latest reason without the prefix latest_reason = latest_reason_infraction['reason'][len(self.infraction_watch_prefix):] + log.trace(f"The latest bb watch reason for {user_id}: {latest_reason}") - return latest_reason + return WatchInformation(reason=latest_reason, actor_id=actor_id, inserted_at=date) - log.trace(f"No bb watch reason found for {user_id}; returning default string") - return "(no reason specified)" + log.trace(f"No bb watch reason found for {user_id}; returning defaults") + return WatchInformation(reason="(no reason specified)", actor_id=None, inserted_at=None) @staticmethod - def _parse_infraction_time(infraction: str) -> struct_time: - """Takes RFC1123 date_time string and returns time object for sorting purposes""" + def _parse_infraction_time(infraction: dict) -> struct_time: + """ + Helper function that retrieves the insertion time from the infraction dictionary, + converts the retrieved RFC1123 date_time string to a time object, and returns it + so infractions can be sorted by their insertion time. + """ date_string = infraction["inserted_at"] return strptime(date_string, "%a, %d %b %Y %H:%M:%S %Z") @@ -182,15 +203,38 @@ class BigBrother: if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit: # Retrieve watch reason from API if it's not already in the cache if message.author.id not in self.watch_reasons: - log.trace(f"No watch reason for {message.author.id} found in cache; retrieving from API") - user_watch_reason = await self.get_watch_reason(message.author.id) - self.watch_reasons[message.author.id] = user_watch_reason + log.trace(f"No watch information for {message.author.id} found in cache; retrieving from API") + user_watch_information = await self.get_watch_information(message.author.id) + self.watch_reasons[message.author.id] = user_watch_information self.last_log = [message.author.id, message.channel.id, 0] + # Get reason, actor, inserted_at + reason, actor_id, inserted_at = self.watch_reasons[message.author.id] + + # Setting up the default author_field + author_field = message.author.nick or message.author.name + + # When we're dealing with a talent-pool header, add nomination info to the author field + if destination == self.bot.get_channel(Channels.talent_pool): + log.trace("We're sending a header to the talent-pool; let's add nomination info") + # If a reason was provided, both should be known + if actor_id and inserted_at: + # Parse actor name + guild: GuildConfig = self.bot.get_guild(GuildConfig.id) + actor_as_member = guild.get_member(actor_id) + actor = actor_as_member.nick or actor_as_member.name + + # Get time delta since insertion + date_time = parse_rfc1123(inserted_at).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + # Adding nomination info to author_field + author_field = f"{author_field} (nominated {time_delta} by {actor})" + embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})") - embed.set_author(name=message.author.nick or message.author.name, icon_url=message.author.avatar_url) - embed.set_footer(text=f"Watch reason: {self.watch_reasons[message.author.id]}") + embed.set_author(name=author_field, icon_url=message.author.avatar_url) + embed.set_footer(text=f"Reason: {reason}") await destination.send(embed=embed) @staticmethod @@ -217,6 +261,39 @@ class BigBrother: await messages.send_attachments(message, destination) + async def _watch_user(self, ctx: Context, user: User, reason: str, channel_id: int): + post_data = { + 'user_id': str(user.id), + 'channel_id': str(channel_id) + } + + async with self.bot.http_session.post( + URLs.site_bigbrother_api, + headers=self.HEADERS, + json=post_data + ) as response: + if response.status == 204: + if channel_id == Channels.talent_pool: + await ctx.send(f":ok_hand: added {user} to the <#{channel_id}>!") + else: + await ctx.send(f":ok_hand: will now relay messages sent by {user} in <#{channel_id}>") + + channel = self.bot.get_channel(channel_id) + if channel is None: + log.error( + f"could not update internal cache, failed to find a channel with ID {channel_id}" + ) + else: + self.watched_users[user.id] = channel + + # Add a note (shadow warning) with the reason for watching + reason = f"{self.infraction_watch_prefix}{reason}" + await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + else: + data = await response.json() + error_reason = data.get('error_message', "no message provided") + await ctx.send(f":x: the API returned an error: {error_reason}") + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def bigbrother_group(self, ctx: Context): @@ -274,35 +351,7 @@ class BigBrother: channel_id = Channels.big_brother_logs - post_data = { - 'user_id': str(user.id), - 'channel_id': str(channel_id) - } - - async with self.bot.http_session.post( - URLs.site_bigbrother_api, - headers=self.HEADERS, - json=post_data - ) as response: - if response.status == 204: - await ctx.send(f":ok_hand: will now relay messages sent by {user} in <#{channel_id}>") - - channel = self.bot.get_channel(channel_id) - if channel is None: - log.error( - f"could not update internal cache, failed to find a channel with ID {channel_id}" - ) - else: - self.watched_users[user.id] = channel - self.watch_reasons[user.id] = reason - - # Add a note (shadow warning) with the reason for watching - reason = f"{self.infraction_watch_prefix}{reason}" - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) - else: - data = await response.json() - error_reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {error_reason}") + await self._watch_user(ctx, user, reason, channel_id) @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -328,7 +377,45 @@ class BigBrother: reason = data.get('error_message', "no message provided") await ctx.send(f":x: the API returned an error: {reason}") + @bigbrother_group.command(name='nominate', aliases=('n',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def nominate_command(self, ctx: Context, user: User, *, reason: str): + """ + Nominates a user for the helper role by adding them to the talent-pool channel + + A `reason` for the nomination is required and will be added as a note to + the user's records. + """ + + # Note: This function is called from HelperNomination.nominate_command so that the + # !nominate command does not show up under "BigBrother" in the help embed, but under + # the header HelperNomination for users with the helper role. + + channel_id = Channels.talent_pool + + await self._watch_user(ctx, user, reason, channel_id) + + +class HelperNomination: + def __init__(self, bot): + self.bot = bot + + @command(name='nominate', aliases=('n',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) + async def nominate_command(self, ctx: Context, user: User, *, reason: str): + """ + Nominates a user for the helper role by adding them to the talent-pool channel + + A `reason` for the nomination is required and will be added as a note to + the user's records. + """ + + cmd = self.bot.get_command("bigbrother nominate") + + await ctx.invoke(cmd, user, reason=reason) + def setup(bot: Bot): bot.add_cog(BigBrother(bot)) + bot.add_cog(HelperNomination(bot)) log.info("Cog loaded: BigBrother") diff --git a/bot/constants.py b/bot/constants.py index 61f62b09c..0e0c1845b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -351,6 +351,7 @@ class Channels(metaclass=YAMLGetter): off_topic_3: int python: int reddit: int + talent_pool: int userlog: int verification: int diff --git a/config-default.yml b/config-default.yml index 5938ae533..515a50519 100644 --- a/config-default.yml +++ b/config-default.yml @@ -114,6 +114,7 @@ guild: python: 267624335836053506 reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 + talent_pool: &TALENT_POOL 534321732593647616 userlog: 528976905546760203 verification: 352442727016693763 @@ -202,6 +203,7 @@ filter: - *BBLOGS - *STAFF_LOUNGE - *DEVTEST + - *TALENT_POOL role_whitelist: - *ADMIN_ROLE |