aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/__init__.py2
-rw-r--r--bot/__main__.py2
-rw-r--r--bot/cogs/alias.py34
-rw-r--r--bot/cogs/bigbrother.py258
-rw-r--r--bot/cogs/nominations.py120
-rw-r--r--bot/cogs/watchchannels/__init__.py15
-rw-r--r--bot/cogs/watchchannels/bigbrother.py100
-rw-r--r--bot/cogs/watchchannels/talentpool.py233
-rw-r--r--bot/cogs/watchchannels/watchchannel.py353
-rw-r--r--bot/constants.py9
-rw-r--r--bot/utils/messages.py28
-rw-r--r--bot/utils/moderation.py12
-rw-r--r--config-default.yml5
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: