diff options
| -rw-r--r-- | bot/__init__.py | 2 | ||||
| -rw-r--r-- | bot/__main__.py | 2 | ||||
| -rw-r--r-- | bot/cogs/alias.py | 34 | ||||
| -rw-r--r-- | bot/cogs/bigbrother.py | 258 | ||||
| -rw-r--r-- | bot/cogs/nominations.py | 120 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/__init__.py | 15 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 100 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 233 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/watchchannel.py | 353 | ||||
| -rw-r--r-- | bot/constants.py | 9 | ||||
| -rw-r--r-- | bot/utils/messages.py | 28 | ||||
| -rw-r--r-- | bot/utils/moderation.py | 12 | ||||
| -rw-r--r-- | config-default.yml | 5 |
13 files changed, 772 insertions, 399 deletions
diff --git a/bot/__init__.py b/bot/__init__.py index 54550842e..b6919a489 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -55,7 +55,7 @@ else: logging.basicConfig( - format="%(asctime)s pd.beardfist.com Bot: | %(name)30s | %(levelname)8s | %(message)s", + format="%(asctime)s pd.beardfist.com Bot: | %(name)33s | %(levelname)8s | %(message)s", datefmt="%b %d %H:%M:%S", level=logging.TRACE if DEBUG_MODE else logging.INFO, handlers=logging_handlers diff --git a/bot/__main__.py b/bot/__main__.py index 8afec2718..44d4d9c02 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -38,7 +38,6 @@ bot.load_extension("bot.cogs.modlog") # Commands, etc bot.load_extension("bot.cogs.antispam") -bot.load_extension("bot.cogs.bigbrother") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.cogs") @@ -69,6 +68,7 @@ bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") +bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") bot.run(BotConfig.token) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 2ce4a51e3..f71d5d81f 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -1,11 +1,13 @@ import inspect import logging +from typing import Union -from discord import Colour, Embed, User +from discord import Colour, Embed, Member, User from discord.ext.commands import ( Command, Context, clean_content, command, group ) +from bot.cogs.watchchannels.watchchannel import proxy_user from bot.converters import TagNameConverter from bot.pagination import LinePaginator @@ -70,24 +72,20 @@ class Alias: await self.invoke(ctx, "site resources") @command(name="watch", hidden=True) - async def bigbrother_watch_alias( - self, ctx, user: User, *, reason: str = None - ): + async def bigbrother_watch_alias(self, ctx, user: Union[Member, User, proxy_user], *, reason: str): """ - Alias for invoking <prefix>bigbrother watch user [text_channel]. + Alias for invoking <prefix>bigbrother watch [user] [reason]. """ await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx, user: User): + async def bigbrother_unwatch_alias(self, ctx, user: Union[User, proxy_user], *, reason: str): """ - Alias for invoking <prefix>bigbrother unwatch user. - - user: discord.User - A user instance to unwatch + Alias for invoking <prefix>bigbrother unwatch [user] [reason]. """ - await self.invoke(ctx, "bigbrother unwatch", user) + await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) @command(name="home", hidden=True) async def site_home_alias(self, ctx): @@ -175,6 +173,22 @@ class Alias: await self.invoke(ctx, "docs get", symbol) + @command(name="nominate", hidden=True) + async def nomination_add_alias(self, ctx, user: Union[Member, User, proxy_user], *, reason: str): + """ + Alias for invoking <prefix>talentpool add [user] [reason]. + """ + + await self.invoke(ctx, "talentpool add", user, reason=reason) + + @command(name="unnominate", hidden=True) + async def nomination_end_alias(self, ctx, user: Union[User, proxy_user], *, reason: str): + """ + Alias for invoking <prefix>nomination end [user] [reason]. + """ + + await self.invoke(ctx, "nomination end", user, reason=reason) + def setup(bot): bot.add_cog(Alias(bot)) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py deleted file mode 100644 index df7a0b576..000000000 --- a/bot/cogs/bigbrother.py +++ /dev/null @@ -1,258 +0,0 @@ -import asyncio -import logging -import re -from collections import defaultdict, deque -from typing import List, Union - -from discord import Color, Embed, Guild, Member, Message, User -from discord.ext.commands import Bot, Context, group - -from bot.constants import ( - BigBrother as BigBrotherConfig, Channels, Emojis, Guild as GuildConfig, Roles -) -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils import messages -from bot.utils.moderation import post_infraction - -log = logging.getLogger(__name__) - -URL_RE = re.compile(r"(https?://[^\s]+)") - - -class BigBrother: - """User monitoring to assist with moderation.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.watched_users = set() # { user_id } - self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) } - self.last_log = [None, None, 0] # [user_id, channel_id, message_count] - self.consuming = False - - def update_cache(self, api_response: List[dict]): - """ - Updates the internal cache of watched users from the given `api_response`. - This function will only add (or update) existing keys, it will not delete - keys that were not present in the API response. - A user is only added if the bot can find a channel - with the given `channel_id` in its channel cache. - """ - - for entry in api_response: - user_id = entry['user'] - self.watched_users.add(user_id) - - async def on_ready(self): - """Retrieves watched users from the API.""" - - self.channel = self.bot.get_channel(Channels.big_brother_logs) - if self.channel is None: - log.error("Cannot find Big Brother channel. Cannot watch users.") - else: - data = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'watch' - } - ) - self.update_cache(data) - - async def on_member_ban(self, guild: Guild, user: Union[User, Member]): - if guild.id == GuildConfig.id and user.id in self.watched_users: - [active_watch] = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'watch', - 'user__id': str(user.id) - } - ) - await self.bot.api_client.put( - 'bot/infractions/' + str(active_watch['id']), - json={'active': False} - ) - self.watched_users.remove(user.id) - del self.channel_queues[user.id] - await self.channel.send( - f"{Emojis.bb_message}:hammer: {user} got banned, so " - f"`BigBrother` will no longer relay their messages." - ) - - async def on_message(self, msg: Message): - """Queues up messages sent by watched users.""" - - if msg.author.id in self.watched_users: - if not self.consuming: - self.bot.loop.create_task(self.consume_messages()) - - log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.channel_queues[msg.author.id][msg.channel.id].append(msg) - - async def consume_messages(self): - """Consumes the message queues to log watched users' messages.""" - - if not self.consuming: - self.consuming = True - log.trace("Sleeping before consuming...") - await asyncio.sleep(BigBrotherConfig.log_delay) - - log.trace("Begin consuming messages.") - channel_queues = self.channel_queues.copy() - self.channel_queues.clear() - for _, queues in channel_queues.items(): - for queue in queues.values(): - while queue: - msg = queue.popleft() - log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)") - - self.last_log[2] += 1 # Increment message count. - await self.send_header(msg) - await self.log_message(msg) - - if self.channel_queues: - log.trace("Queue not empty; continue consumption.") - self.bot.loop.create_task(self.consume_messages()) - else: - log.trace("Done consuming messages.") - self.consuming = False - - async def send_header(self, message: Message): - """ - Sends a log message header to the given channel. - - A header is only sent if the user or channel are different than the previous, or if the configured message - limit for a single header has been exceeded. - - :param message: the first message in the queue - """ - - last_user, last_channel, msg_count = self.last_log - limit = BigBrotherConfig.header_message_limit - - # Send header if user/channel are different or if message limit exceeded. - if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit: - self.last_log = [message.author.id, message.channel.id, 0] - - 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) - await self.channel.send(embed=embed) - - async def log_message(self, message: Message): - """ - Logs a watched user's message in the given channel. - - Attachments are also sent. All non-image or non-video URLs are put in inline code blocks to prevent preview - embeds from being automatically generated. - - :param message: the message to log - """ - - content = message.clean_content - if content: - # Put all non-media URLs in inline code blocks. - media_urls = {embed.url for embed in message.embeds if embed.type in ("image", "video")} - for url in URL_RE.findall(content): - if url not in media_urls: - content = content.replace(url, f"`{url}`") - - await self.channel.send(content) - - await messages.send_attachments(message, self.channel) - - @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def bigbrother_group(self, ctx: Context): - """Monitor users, NSA-style.""" - - await ctx.invoke(self.bot.get_command("help"), "bigbrother") - - @bigbrother_group.command(name='watched', aliases=('all',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watched_command(self, ctx: Context, from_cache: bool = True): - """ - Shows all users that are currently monitored and in which channel. - By default, the users are returned from the cache. - If this is not desired, `from_cache` can be given as a falsy value, e.g. e.g. 'no'. - """ - - if from_cache: - lines = tuple(f"• <@{user_id}>" for user_id in self.watched_users) - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed(title="Watched users (cached)", color=Color.blue()), - empty=False - ) - - else: - active_watches = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'watch' - } - ) - self.update_cache(active_watches) - lines = tuple( - f"• <@{entry['user']}>: {entry['reason'] or '*no reason provided*'}" - for entry in active_watches - ) - - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed(title="Watched users", color=Color.blue()), - empty=False - ) - - @bigbrother_group.command(name='watch', aliases=('w',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watch_command(self, ctx: Context, user: User, *, reason: str): - """ - Relay messages sent by the given `user` to the `#big-brother-logs` channel - - A `reason` for watching is required, which is added for the user to be watched as a - note (aka: shadow warning) - """ - - if user.id in self.watched_users: - return await ctx.send(":x: That user is already watched.") - - await post_infraction( - ctx, user, type='watch', reason=reason, hidden=True - ) - self.watched_users.add(user.id) - await ctx.send(f":ok_hand: will now relay messages sent by {user}") - - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def unwatch_command(self, ctx: Context, user: User): - """Stop relaying messages by the given `user`.""" - - active_watches = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'watch', - 'user__id': str(user.id) - } - ) - if active_watches: - [infraction] = active_watches - await self.bot.api_client.patch( - 'bot/infractions/' + str(infraction['id']), - json={'active': False} - ) - await ctx.send(f":ok_hand: will no longer relay messages sent by {user}") - self.watched_users.remove(user.id) - if user.id in self.channel_queues: - del self.channel_queues[user.id] - else: - await ctx.send(":x: that user is currently not being watched") - - -def setup(bot: Bot): - bot.add_cog(BigBrother(bot)) - log.info("Cog loaded: BigBrother") diff --git a/bot/cogs/nominations.py b/bot/cogs/nominations.py deleted file mode 100644 index 93ee0d885..000000000 --- a/bot/cogs/nominations.py +++ /dev/null @@ -1,120 +0,0 @@ -import logging - -from discord import Color, Embed, User -from discord.ext.commands import Context, group - -from bot.cogs.bigbrother import BigBrother, Roles -from bot.constants import Channels -from bot.decorators import with_role -from bot.pagination import LinePaginator - - -log = logging.getLogger(__name__) - - -class Nominations(BigBrother): - """Monitor potential helpers, NSA-style.""" - - async def on_ready(self): - """Retrieve nominees from the API.""" - - self.channel = self.bot.get_channel(Channels.talent_pool) - if self.channel is None: - log.error("Cannot find talent pool channel. Cannot watch nominees.") - else: - nominations = await self.bot.api_client.get( - 'bot/nominations', - params={'active': 'true'} - ) - self.update_cache(nominations) - - async def on_member_ban(self, *_): - pass - - @group(name='nominations', aliases=('n',), invoke_without_command=True) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def bigbrother_group(self, ctx: Context): - """Nominate helpers, NSA-style.""" - - await ctx.invoke(self.bot.get_command("help"), "nominations") - - @bigbrother_group.command(name='nominated', aliases=('nominees', 'all')) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watched_command(self, ctx: Context, from_cache: bool = True): - if from_cache: - lines = tuple(f"• <@{user_id}>" for user_id in self.watched_users) - - else: - active_nominations = await self.bot.api_client.get( - 'bot/nominations', - params={'active': 'true'} - ) - self.update_cache(active_nominations) - lines = tuple( - f"• <@{entry['user']}>: {entry['reason'] or '*no reason provided*'}" - for entry in active_nominations - ) - - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed( - title="Nominated users" + " (cached)" * from_cache, - color=Color.blue() - ), - empty=False - ) - - @bigbrother_group.command(name='nominate', aliases=('n',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watch_command(self, ctx: Context, user: User, *, reason: str): - """Talent pool the given `user`.""" - - active_nominations = await self.bot.api_client.get( - 'bot/nominations/' + str(user.id), - ) - if active_nominations: - active_nominations = await self.bot.api_client.put( - 'bot/nominations/' + str(user.id), - json={'active': True} - ) - await ctx.send(":ok_hand: user's watch was updated") - - else: - active_nominations = await self.bot.api_client.post( - 'bot/nominations/' + str(user.id), - json={ - 'active': True, - 'author': ctx.author.id, - 'reason': reason, - } - ) - self.watched_users.add(user.id) - await ctx.send(":ok_hand: user added to talent pool") - - @bigbrother_group.command(name='unnominate', aliases=('un',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def unwatch_command(self, ctx: Context, user: User): - """Stop talent pooling the given `user`.""" - - nomination = await self.bot.api_client.get( - 'bot/nominations/' + str(user.id) - ) - - if not nomination['active']: - await ctx.send(":x: the nomination is already inactive") - - else: - await self.bot.api_client.put( - 'bot/nominations/' + str(user.id), - json={'active': False} - ) - self.watched_users.remove(user.id) - if user.id in self.channel_queues: - del self.channel_queues[user.id] - await ctx.send(f":ok_hand: {user} is no longer part of the talent pool") - - -def setup(bot): - bot.add_cog(Nominations(bot)) - log.info("Cog loaded: Nominations") diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py new file mode 100644 index 000000000..ac7713803 --- /dev/null +++ b/bot/cogs/watchchannels/__init__.py @@ -0,0 +1,15 @@ +import logging + +from .bigbrother import BigBrother +from .talentpool import TalentPool + + +log = logging.getLogger(__name__) + + +def setup(bot): + bot.add_cog(BigBrother(bot)) + log.info("Cog loaded: BigBrother") + + bot.add_cog(TalentPool(bot)) + log.info("Cog loaded: TalentPool") diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py new file mode 100644 index 000000000..e7b3d70bc --- /dev/null +++ b/bot/cogs/watchchannels/bigbrother.py @@ -0,0 +1,100 @@ +import logging +from collections import ChainMap +from typing import Union + +from discord import User +from discord.ext.commands import Context, group + +from bot.constants import Channels, Roles, Webhooks +from bot.decorators import with_role +from bot.utils.moderation import post_infraction +from .watchchannel import WatchChannel, proxy_user + +log = logging.getLogger(__name__) + + +class BigBrother(WatchChannel): + """Monitors users by relaying their messages to a watch channel to assist with moderation.""" + + def __init__(self, bot) -> None: + super().__init__( + bot, + destination=Channels.big_brother_logs, + webhook_id=Webhooks.big_brother, + api_endpoint='bot/infractions', + api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, + logger=log + ) + + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def bigbrother_group(self, ctx: Context) -> None: + """Monitors users by relaying their messages to the Big Brother watch channel.""" + await ctx.invoke(self.bot.get_command("help"), "bigbrother") + + @bigbrother_group.command(name='watched', aliases=('all', 'list')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows the users that are currently being monitored by Big Brother. + + 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, update_cache) + + @bigbrother_group.command(name='watch', aliases=('w',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#big-brother` channel. + + A `reason` for adding the user to Big Brother is required and will be displayed + in the header when relaying messages of this user to the watchchannel. + """ + 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 not await self.fetch_user_cache(): + await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") + return + + if user.id in self.watched_users: + await ctx.send(":x: The specified user is already being watched.") + return + + response = await post_infraction( + ctx, user, type='watch', reason=reason, hidden=True + ) + + if response is not None: + self.watched_users[user.id] = response + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother.") + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + active_watches = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + if active_watches: + [infraction] = active_watches + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{infraction['id']}", + json={'active': False} + ) + + await post_infraction(ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False) + + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") + + self._remove_user(user.id) + else: + await ctx.send(":x: The specified user is currently not being watched.") diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py new file mode 100644 index 000000000..6fbe2bc03 --- /dev/null +++ b/bot/cogs/watchchannels/talentpool.py @@ -0,0 +1,233 @@ +import logging +import textwrap +from collections import ChainMap +from typing import Union + +from aiohttp.client_exceptions import ClientResponseError +from discord import Color, Embed, Member, User +from discord.ext.commands import Context, group + +from bot.constants import Channels, Guild, Roles, Webhooks +from bot.decorators import with_role +from bot.pagination import LinePaginator +from .watchchannel import WatchChannel, proxy_user + +log = logging.getLogger(__name__) +STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge? + + +class TalentPool(WatchChannel): + """Relays messages of helper candidates to a watch channel to observe them.""" + + def __init__(self, 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, + ) + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + 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.invoke(self.bot.get_command("help"), "talentpool") + + @nomination_group.command(name='watched', aliases=('all', 'list')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows the users that are currently being monitored in the talent pool. + + 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, update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, 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 required and 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(f":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 user.id in self.watched_users: + await ctx.send(":x: The specified user is already being watched in the talent pool") + 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 and response_data.get('user', False): + await ctx.send(":x: The specified user can't be found in the database tables") + return + else: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel") + + @nomination_group.command(name='history', aliases=('info', 'search')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> 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', )) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + + if not active_nomination: + await ctx.send(":x: The specified user does not have an active nomination") + return + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + self._remove_user(user.id) + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + + await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") + + @nomination_edit_group.command(name='reason') + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """ + Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. + + If the nomination is active, the reason for nominating the user will be edited; + If the nomination is no longer active, the reason for ending the nomination will be edited instead. + """ + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ClientResponseError as e: + if e.status == 404: + self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + field = "reason" if nomination["active"] else "end_reason" + + self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={field: reason} + ) + + await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + + def _nomination_to_string(self, nomination_object: dict) -> str: + """Creates a string representation of a nomination.""" + guild = self.bot.get_guild(Guild.id) + + actor_id = nomination_object["actor"] + actor = guild.get_member(actor_id) + + active = nomination_object["active"] + log.debug(active) + log.debug(type(nomination_object["inserted_at"])) + + start_date = self._get_human_readable(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + else: + end_date = self._get_human_readable(nomination_object["ended_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + + End date: {end_date} + Unwatch reason: {nomination_object["end_reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + + return lines.strip() diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py new file mode 100644 index 000000000..fe6d6bb6e --- /dev/null +++ b/bot/cogs/watchchannels/watchchannel.py @@ -0,0 +1,353 @@ +import asyncio +import datetime +import logging +import re +import textwrap +from abc import ABC, abstractmethod +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Optional + +import aiohttp +import discord +from discord import Color, Embed, Message, Object, errors +from discord.ext.commands import BadArgument, Bot, Context + +from bot.cogs.modlog import ModLog +from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons +from bot.pagination import LinePaginator +from bot.utils import messages +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + +URL_RE = re.compile(r"(https?://[^\s]+)") + + +def proxy_user(user_id: str) -> Object: + """A proxy user object that mocks a real User instance for when the later is not available.""" + try: + user_id = int(user_id) + except ValueError: + raise BadArgument + + user = Object(user_id) + user.mention = user.id + user.display_name = f"<@{user.id}>" + user.avatar_url_as = lambda static_format: None + user.bot = False + + return user + + +@dataclass +class MessageHistory: + last_author: Optional[int] = None + last_channel: Optional[int] = None + message_count: int = 0 + + +class WatchChannel(ABC): + """ABC with functionality for relaying users' messages to a certain channel.""" + + @abstractmethod + def __init__(self, bot: Bot, destination, webhook_id, api_endpoint, api_default_params, logger) -> None: + self.bot = bot + + self.destination = destination # E.g., Channels.big_brother_logs + self.webhook_id = webhook_id # E.g., Webhooks.big_brother + self.api_endpoint = api_endpoint # E.g., 'bot/infractions' + self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} + self.log = logger # Logger of the child cog for a correct name in the logs + + self._consume_task = None + self.watched_users = defaultdict(dict) + self.message_queue = defaultdict(lambda: defaultdict(deque)) + self.consumption_queue = {} + self.retries = 5 + self.retry_delay = 10 + self.channel = None + self.webhook = None + self.message_history = MessageHistory() + + self._start = self.bot.loop.create_task(self.start_watchchannel()) + + @property + def modlog(self) -> ModLog: + """Provides access to the ModLog cog for alert purposes.""" + return self.bot.get_cog("ModLog") + + @property + def consuming_messages(self) -> bool: + """Checks if a consumption task is currently running.""" + if self._consume_task is None: + return False + + if self._consume_task.done(): + exc = self._consume_task.exception() + if exc: + self.log.exception( + f"The message queue consume task has failed with:", + exc_info=exc + ) + return False + + return True + + async def start_watchchannel(self) -> None: + """Starts the watch channel by getting the channel, webhook, and user cache ready.""" + await self.bot.wait_until_ready() + + # After updating d.py, this block can be replaced by `fetch_channel` with a try-except + for attempt in range(1, self.retries+1): + self.channel = self.bot.get_channel(self.destination) + if self.channel is None: + if attempt < self.retries: + await asyncio.sleep(self.retry_delay) + else: + break + else: + self.log.error(f"Failed to retrieve the text channel with id {self.destination}") + + # `get_webhook_info` has been renamed to `fetch_webhook` in newer versions of d.py + try: + self.webhook = await self.bot.get_webhook_info(self.webhook_id) + except (discord.HTTPException, discord.NotFound, discord.Forbidden): + self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + if self.channel is None or self.webhook is None: + self.log.error("Failed to start the watch channel; unloading the cog.") + + message = textwrap.dedent( + f""" + An error occurred while loading the text channel or webhook. + + TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} + Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} + + The Cog has been unloaded. + """ + ) + + await self.modlog.send_log_message( + title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", + text=message, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + 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.", + ping_everyone=True, + icon=Icons.token_removed, + color=Color.red() + ) + + async def fetch_user_cache(self) -> bool: + """ + Fetches watched users from the API and updates the watched user cache accordingly. + + This function returns `True` if the update succeeded. + """ + try: + data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) + except aiohttp.ClientResponseError as e: + self.log.exception(f"Failed to fetch the watched users from the API", exc_info=e) + return False + + self.watched_users = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.watched_users[user_id] = entry + + return True + + async def on_message(self, msg: Message) -> None: + """Queues up messages sent by watched users.""" + if msg.author.id in self.watched_users: + if not self.consuming_messages: + self._consume_task = self.bot.loop.create_task(self.consume_messages()) + + self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.message_queue[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self, delay_consumption: bool = True) -> None: + """Consumes the message queues to log watched users' messages.""" + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) + + self.log.trace(f"Started consuming the message queue") + + # If the previous consumption Task failed, first consume the existing comsumption_queue + if not self.consumption_queue: + self.consumption_queue = self.message_queue.copy() + self.message_queue.clear() + + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() + + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) + + self.consumption_queue.clear() + + if self.message_queue: + self.log.trace("Channel queue not empty: Continuing consuming queues") + self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + else: + self.log.trace("Done consuming messages.") + + async def webhook_send( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + ) -> None: + """Sends a message to the webhook with the specified kwargs.""" + try: + await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) + except discord.HTTPException as exc: + self.log.exception( + f"Failed to send a message to the webhook", + exc_info=exc + ) + + async def relay_message(self, msg: Message) -> None: + """Relays the message to the relevant watch channel.""" + limit = BigBrotherConfig.header_message_limit + + if ( + msg.author.id != self.message_history.last_author + or msg.channel.id != self.message_history.last_channel + or self.message_history.message_count >= limit + ): + self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) + + await self.send_header(msg) + + cleaned_content = msg.clean_content + + if cleaned_content: + # Put all non-media URLs in a code block to prevent embeds + media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(cleaned_content): + if url not in media_urls: + cleaned_content = cleaned_content.replace(url, f"`{url}`") + await self.webhook_send( + cleaned_content, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + if msg.attachments: + try: + await messages.send_attachments(msg, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.webhook_send( + embed=e, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + except discord.HTTPException as exc: + self.log.exception( + f"Failed to send an attachment to the webhook", + exc_info=exc + ) + + self.message_history.message_count += 1 + + async def send_header(self, msg) -> None: + """Sends a header embed with information about the relayed messages to the watch channel.""" + user_id = msg.author.id + + guild = self.bot.get_guild(GuildConfig.id) + actor = guild.get_member(self.watched_users[user_id]['actor']) + 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) + + reason = self.watched_users[user_id]['reason'] + + embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})") + embed.set_footer(text=f"Added {time_delta} by {actor} | Reason: {reason}") + + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + + async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None: + """ + Gives an overview of the watched user list for this channel. + + 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 + + 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})") + + lines = lines or ("There's nothing here yet.",) + embed = Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + 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 = datetime.datetime.strptime( + time_string, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + @staticmethod + def _get_human_readable(time_string: str, output_format: str = "%Y-%m-%d %H:%M:%S") -> str: + date_time = datetime.datetime.strptime( + time_string, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + return date_time.strftime(output_format) + + def _remove_user(self, user_id: int) -> None: + """Removes a user from a watch channel.""" + self.watched_users.pop(user_id, None) + self.message_queue.pop(user_id, None) + self.consumption_queue.pop(user_id, None) + + def cog_unload(self) -> None: + """Takes care of unloading the cog and canceling the consumption task.""" + self.log.trace(f"Unloading the cog") + if not self._consume_task.done(): + self._consume_task.cancel() + try: + self._consume_task.result() + except asyncio.CancelledError as e: + self.log.exception( + f"The consume task was canceled. Messages may be lost.", + exc_info=e + ) diff --git a/bot/constants.py b/bot/constants.py index 0bd950a7d..82df8c6f0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -336,9 +336,18 @@ class Channels(metaclass=YAMLGetter): off_topic_3: int python: int reddit: int + talent_pool: int verification: int +class Webhooks(metaclass=YAMLGetter): + section = "guild" + subsection = "webhooks" + + talent_pool: int + big_brother: int + + class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" diff --git a/bot/utils/messages.py b/bot/utils/messages.py index fc38b0127..94a8b36ed 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,9 +1,9 @@ import asyncio import contextlib from io import BytesIO -from typing import Sequence +from typing import Sequence, Union -from discord import Embed, File, Message, TextChannel +from discord import Embed, File, Message, TextChannel, Webhook from discord.abc import Snowflake from discord.errors import HTTPException @@ -78,9 +78,9 @@ async def wait_for_deletion( await message.delete() -async def send_attachments(message: Message, destination: TextChannel): +async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]): """ - Re-uploads each attachment in a message to the given channel. + Re-uploads each attachment in a message to the given channel or webhook. Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. If attachments are too large, they are instead grouped into a single embed which links to them. @@ -97,7 +97,16 @@ async def send_attachments(message: Message, destination: TextChannel): if attachment.size <= MAX_SIZE - 512: with BytesIO() as file: await attachment.save(file) - await destination.send(file=File(file, filename=attachment.filename)) + attachment_file = File(file, filename=attachment.filename) + + if isinstance(destination, TextChannel): + await destination.send(file=attachment_file) + else: + await destination.send( + file=attachment_file, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) else: large.append(attachment) except HTTPException as e: @@ -109,4 +118,11 @@ async def send_attachments(message: Message, destination: TextChannel): if large: embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)) embed.set_footer(text="Attachments exceed upload size limit.") - await destination.send(embed=embed) + if isinstance(destination, TextChannel): + await destination.send(embed=embed) + else: + await destination.send( + embed=embed, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index fcdf3c4d5..c1eb98dd6 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -14,8 +14,13 @@ HEADERS = {"X-API-KEY": Keys.site_api} async def post_infraction( - ctx: Context, user: Union[Member, Object, User], - type: str, reason: str, expires_at: datetime = None, hidden: bool = False + ctx: Context, + user: Union[Member, Object, User], + type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True, ): payload = { @@ -23,7 +28,8 @@ async def post_infraction( "hidden": hidden, "reason": reason, "type": type, - "user": user.id + "user": user.id, + "active": active } if expires_at: payload['expires_at'] = expires_at.isoformat() diff --git a/config-default.yml b/config-default.yml index dff8ed599..8db12f70b 100644 --- a/config-default.yml +++ b/config-default.yml @@ -109,6 +109,7 @@ guild: python: 267624335836053506 reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 + talent_pool: &TALENT_POOL 534321732593647616 verification: 352442727016693763 ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] @@ -128,6 +129,10 @@ guild: helpers: 267630620367257601 rockstars: &ROCKSTARS_ROLE 458226413825294336 + webhooks: + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + filter: |