aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/constants.py2
-rw-r--r--bot/converters.py67
-rw-r--r--bot/exts/filters/antispam.py4
-rw-r--r--bot/exts/filters/filtering.py128
-rw-r--r--bot/exts/filters/token_remover.py83
-rw-r--r--bot/exts/filters/webhook_remover.py5
-rw-r--r--bot/exts/info/information.py22
-rw-r--r--bot/exts/moderation/defcon.py3
-rw-r--r--bot/exts/moderation/incidents.py2
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py57
-rw-r--r--bot/exts/moderation/infraction/infractions.py3
-rw-r--r--bot/exts/moderation/infraction/management.py67
-rw-r--r--bot/exts/moderation/infraction/superstarify.py12
-rw-r--r--bot/exts/moderation/modlog.py35
-rw-r--r--bot/exts/moderation/verification.py5
-rw-r--r--bot/exts/utils/clean.py3
-rw-r--r--bot/utils/messages.py32
-rw-r--r--config-default.yml9
-rw-r--r--tests/bot/exts/filters/test_token_remover.py154
-rw-r--r--tests/bot/exts/info/test_information.py20
20 files changed, 427 insertions, 286 deletions
diff --git a/bot/constants.py b/bot/constants.py
index b798f42aa..bb82b976d 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -469,7 +469,6 @@ class Guild(metaclass=YAMLGetter):
moderation_roles: List[int]
modlog_blacklist: List[int]
reminder_whitelist: List[int]
- staff_channels: List[int]
staff_roles: List[int]
@@ -616,7 +615,6 @@ MODERATION_ROLES = Guild.moderation_roles
STAFF_ROLES = Guild.staff_roles
# Channel combinations
-STAFF_CHANNELS = Guild.staff_channels
MODERATION_CHANNELS = Guild.moderation_channels
# Bot replies
diff --git a/bot/converters.py b/bot/converters.py
index 1358cbf1e..2e118d476 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -2,6 +2,7 @@ import logging
import re
import typing as t
from datetime import datetime
+from functools import partial
from ssl import CertificateError
import dateutil.parser
@@ -10,6 +11,7 @@ import discord
from aiohttp import ClientConnectorError
from dateutil.relativedelta import relativedelta
from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter
+from discord.utils import DISCORD_EPOCH, snowflake_time
from bot.api import ResponseCodeError
from bot.constants import URLs
@@ -17,6 +19,9 @@ from bot.utils.regex import INVITE_RE
log = logging.getLogger(__name__)
+DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000)
+RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$")
+
def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]:
"""
@@ -172,17 +177,42 @@ class ValidURL(Converter):
return url
-class InfractionSearchQuery(Converter):
- """A converter that checks if the argument is a Discord user, and if not, falls back to a string."""
+class Snowflake(IDConverter):
+ """
+ Converts to an int if the argument is a valid Discord snowflake.
+
+ A snowflake is valid if:
+
+ * It consists of 15-21 digits (0-9)
+ * Its parsed datetime is after the Discord epoch
+ * Its parsed datetime is less than 1 day after the current time
+ """
+
+ async def convert(self, ctx: Context, arg: str) -> int:
+ """
+ Ensure `arg` matches the ID pattern and its timestamp is in range.
+
+ Return `arg` as an int if it's a valid snowflake.
+ """
+ error = f"Invalid snowflake {arg!r}"
+
+ if not self._get_id_match(arg):
+ raise BadArgument(error)
+
+ snowflake = int(arg)
- @staticmethod
- async def convert(ctx: Context, arg: str) -> t.Union[discord.Member, str]:
- """Check if the argument is a Discord user, and if not, falls back to a string."""
try:
- maybe_snowflake = arg.strip("<@!>")
- return await ctx.bot.fetch_user(maybe_snowflake)
- except (discord.NotFound, discord.HTTPException):
- return arg
+ time = snowflake_time(snowflake)
+ except (OverflowError, OSError) as e:
+ # Not sure if this can ever even happen, but let's be safe.
+ raise BadArgument(f"{error}: {e}")
+
+ if time < DISCORD_EPOCH_DT:
+ raise BadArgument(f"{error}: timestamp is before the Discord epoch.")
+ elif (datetime.utcnow() - time).days < -1:
+ raise BadArgument(f"{error}: timestamp is too far into the future.")
+
+ return snowflake
class Subreddit(Converter):
@@ -447,14 +477,13 @@ class UserMentionOrID(UserConverter):
"""
Converts to a `discord.User`, but only if a mention or userID is provided.
- Unlike the default `UserConverter`, it does allow conversion from name, or name#descrim.
-
+ Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim.
This is useful in cases where that lookup strategy would lead to ambiguity.
"""
async def convert(self, ctx: Context, argument: str) -> discord.User:
"""Convert the `arg` to a `discord.User`."""
- match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument)
+ match = self._get_id_match(argument) or RE_USER_MENTION.match(argument)
if match is not None:
return await super().convert(ctx, argument)
@@ -507,5 +536,19 @@ class FetchedUser(UserConverter):
raise BadArgument(f"User `{arg}` does not exist")
+def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int:
+ """
+ Extract the snowflake from `arg` using a regex `pattern` and return it as an int.
+
+ The snowflake is expected to be within the first capture group in `pattern`.
+ """
+ match = pattern.match(arg)
+ if not match:
+ raise BadArgument(f"Mention {str!r} is invalid.")
+
+ return int(match.group(1))
+
+
Expiry = t.Union[Duration, ISODateTime]
FetchedMember = t.Union[discord.Member, FetchedUser]
+UserMention = partial(_snowflake_from_regex, RE_USER_MENTION)
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index f2a2689e1..4964283f1 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -19,7 +19,7 @@ from bot.constants import (
)
from bot.converters import Duration
from bot.exts.moderation.modlog import ModLog
-from bot.utils.messages import send_attachments
+from bot.utils.messages import format_user, send_attachments
log = logging.getLogger(__name__)
@@ -68,7 +68,7 @@ class DeletionContext:
async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:
"""Method that takes care of uploading the queue and posting modlog alert."""
- triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values())
+ triggered_by_users = ", ".join(format_user(m) for m in self.members.values())
mod_alert_message = (
f"**Triggered by:** {triggered_by_users}\n"
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index b7eb41244..92cdfb8f5 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -2,7 +2,7 @@ import asyncio
import logging
import re
from datetime import datetime, timedelta
-from typing import List, Mapping, Optional, Tuple, Union
+from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Union
import dateutil
import discord.errors
@@ -19,6 +19,7 @@ from bot.constants import (
Guild, Icons, URLs
)
from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
from bot.utils.regex import INVITE_RE
from bot.utils.scheduling import Scheduler
@@ -39,6 +40,16 @@ ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]")
DAYS_BETWEEN_ALERTS = 3
OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days)
+FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]]
+
+
+class Stats(NamedTuple):
+ """Additional stats on a triggered filter to append to a mod log."""
+
+ message_content: str
+ additional_embeds: Optional[List[discord.Embed]]
+ additional_embeds_msg: Optional[str]
+
class Filtering(Cog):
"""Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""
@@ -194,8 +205,8 @@ class Filtering(Cog):
log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).")
log_string = (
- f"**User:** {member.mention} (`{member.id}`)\n"
- f"**Display Name:** {member.display_name}\n"
+ f"**User:** {format_user(member)}\n"
+ f"**Display Name:** {escape_markdown(member.display_name)}\n"
f"**Bad Matches:** {', '.join(match.group() for match in matches)}"
)
@@ -234,35 +245,8 @@ class Filtering(Cog):
if _filter["type"] == "filter":
filter_triggered = True
- # We do not have to check against DM channels since !eval cannot be used there.
- channel_str = f"in {msg.channel.mention}"
-
- message_content, additional_embeds, additional_embeds_msg = self._add_stats(
- filter_name, match, result
- )
-
- message = (
- f"The {filter_name} {_filter['type']} was triggered "
- f"by **{msg.author}** "
- f"(`{msg.author.id}`) {channel_str} using !eval with "
- f"[the following message]({msg.jump_url}):\n\n"
- f"{message_content}"
- )
-
- log.debug(message)
-
- # Send pretty mod log embed to mod-alerts
- await self.mod_log.send_log_message(
- icon_url=Icons.filtering,
- colour=Colour(Colours.soft_red),
- title=f"{_filter['type'].title()} triggered!",
- text=message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
- channel_id=Channels.mod_alerts,
- ping_everyone=Filter.ping_everyone,
- additional_embeds=additional_embeds,
- additional_embeds_msg=additional_embeds_msg
- )
+ stats = self._add_stats(filter_name, match, result)
+ await self._send_log(filter_name, _filter["type"], msg, stats, is_eval=True)
break # We don't want multiple filters to trigger
@@ -332,46 +316,52 @@ class Filtering(Cog):
self.schedule_msg_delete(data)
log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}")
- if is_private:
- channel_str = "via DM"
- else:
- channel_str = f"in {msg.channel.mention}"
+ stats = self._add_stats(filter_name, match, msg.content)
+ await self._send_log(filter_name, _filter, msg, stats)
- message_content, additional_embeds, additional_embeds_msg = self._add_stats(
- filter_name, match, msg.content
- )
-
- message = (
- f"The {filter_name} {_filter['type']} was triggered "
- f"by **{msg.author}** "
- f"(`{msg.author.id}`) {channel_str} with [the "
- f"following message]({msg.jump_url}):\n\n"
- f"{message_content}"
- )
+ break # We don't want multiple filters to trigger
- log.debug(message)
-
- # Allow specific filters to override ping_everyone
- ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True)
-
- # Send pretty mod log embed to mod-alerts
- await self.mod_log.send_log_message(
- icon_url=Icons.filtering,
- colour=Colour(Colours.soft_red),
- title=f"{_filter['type'].title()} triggered!",
- text=message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
- channel_id=Channels.mod_alerts,
- ping_everyone=ping_everyone if not is_private else False,
- additional_embeds=additional_embeds,
- additional_embeds_msg=additional_embeds_msg
- )
+ async def _send_log(
+ self,
+ filter_name: str,
+ _filter: Dict[str, Any],
+ msg: discord.Message,
+ stats: Stats,
+ *,
+ is_eval: bool = False,
+ ) -> None:
+ """Send a mod log for a triggered filter."""
+ if msg.channel.type is discord.ChannelType.private:
+ channel_str = "via DM"
+ ping_everyone = False
+ else:
+ channel_str = f"in {msg.channel.mention}"
+ # Allow specific filters to override ping_everyone
+ ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True)
+
+ eval_msg = "using !eval " if is_eval else ""
+ message = (
+ f"The {filter_name} {_filter['type']} was triggered by {format_user(msg.author)} "
+ f"{channel_str} {eval_msg}with [the following message]({msg.jump_url}):\n\n"
+ f"{stats.message_content}"
+ )
- break # We don't want multiple filters to trigger
+ log.debug(message)
+
+ # Send pretty mod log embed to mod-alerts
+ await self.mod_log.send_log_message(
+ icon_url=Icons.filtering,
+ colour=Colour(Colours.soft_red),
+ title=f"{_filter['type'].title()} triggered!",
+ text=message,
+ thumbnail=msg.author.avatar_url_as(static_format="png"),
+ channel_id=Channels.mod_alerts,
+ ping_everyone=ping_everyone,
+ additional_embeds=stats.additional_embeds,
+ additional_embeds_msg=stats.additional_embeds_msg
+ )
- def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[
- str, Optional[List[discord.Embed]], Optional[str]
- ]:
+ def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats:
"""Adds relevant statistical information to the relevant filter and increments the bot's stats."""
# Word and match stats for watch_regex
if name == "watch_regex":
@@ -408,7 +398,7 @@ class Filtering(Cog):
additional_embeds = match
additional_embeds_msg = "With the following embed(s):"
- return message_content, additional_embeds, additional_embeds_msg
+ return Stats(message_content, additional_embeds, additional_embeds_msg)
@staticmethod
def _check_filter(msg: Message) -> bool:
diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py
index 0eda3dc6a..bd6a1f97a 100644
--- a/bot/exts/filters/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -11,13 +11,19 @@ from bot import utils
from bot.bot import Bot
from bot.constants import Channels, Colours, Event, Icons
from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
log = logging.getLogger(__name__)
LOG_MESSAGE = (
- "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, "
+ "Censored a seemingly valid token sent by {author} in {channel}, "
"token was `{user_id}.{timestamp}.{hmac}`"
)
+UNKNOWN_USER_LOG_MESSAGE = "Decoded user ID: `{user_id}` (Not present in server)."
+KNOWN_USER_LOG_MESSAGE = (
+ "Decoded user ID: `{user_id}` **(Present in server)**.\n"
+ "This matches `{user_name}` and means this is likely a valid **{kind}** token."
+)
DELETION_MESSAGE_TEMPLATE = (
"Hey {mention}! I noticed you posted a seemingly valid Discord API "
"token in your message and have removed your message. "
@@ -93,6 +99,7 @@ class TokenRemover(Cog):
await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))
log_message = self.format_log_message(msg, found_token)
+ userid_message, mention_everyone = self.format_userid_log_message(msg, found_token)
log.debug(log_message)
# Send pretty mod log embed to mod-alerts
@@ -100,19 +107,43 @@ class TokenRemover(Cog):
icon_url=Icons.token_removed,
colour=Colour(Colours.soft_red),
title="Token removed!",
- text=log_message,
+ text=log_message + "\n" + userid_message,
thumbnail=msg.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts,
+ ping_everyone=mention_everyone,
)
self.bot.stats.incr("tokens.removed_tokens")
+ @classmethod
+ def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]:
+ """
+ Format the portion of the log message that includes details about the detected user ID.
+
+ If the user is resolved to a member, the format includes the user ID, name, and the
+ kind of user detected.
+
+ If we resolve to a member and it is not a bot, we also return True to ping everyone.
+
+ Returns a tuple of (log_message, mention_everyone)
+ """
+ user_id = cls.extract_user_id(token.user_id)
+ user = msg.guild.get_member(user_id)
+
+ if user:
+ return KNOWN_USER_LOG_MESSAGE.format(
+ user_id=user_id,
+ user_name=str(user),
+ kind="BOT" if user.bot else "USER",
+ ), not user.bot
+ else:
+ return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False
+
@staticmethod
def format_log_message(msg: Message, token: Token) -> str:
- """Return the log message to send for `token` being censored in `msg`."""
+ """Return the generic portion of the log message to send for `token` being censored in `msg`."""
return LOG_MESSAGE.format(
- author=msg.author,
- author_id=msg.author.id,
+ author=format_user(msg.author),
channel=msg.channel.mention,
user_id=token.user_id,
timestamp=token.timestamp,
@@ -126,7 +157,11 @@ class TokenRemover(Cog):
# token check (e.g. `message.channel.send` also matches our token pattern)
for match in TOKEN_RE.finditer(msg.content):
token = Token(*match.groups())
- if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp):
+ if (
+ (cls.extract_user_id(token.user_id) is not None)
+ and cls.is_valid_timestamp(token.timestamp)
+ and cls.is_maybe_valid_hmac(token.hmac)
+ ):
# Short-circuit on first match
return token
@@ -134,22 +169,20 @@ class TokenRemover(Cog):
return
@staticmethod
- def is_valid_user_id(b64_content: str) -> bool:
- """
- Check potential token to see if it contains a valid Discord user ID.
-
- See: https://discordapp.com/developers/docs/reference#snowflakes
- """
+ def extract_user_id(b64_content: str) -> t.Optional[int]:
+ """Return a user ID integer from part of a potential token, or None if it couldn't be decoded."""
b64_content = utils.pad_base64(b64_content)
try:
decoded_bytes = base64.urlsafe_b64decode(b64_content)
string = decoded_bytes.decode('utf-8')
-
- # isdigit on its own would match a lot of other Unicode characters, hence the isascii.
- return string.isascii() and string.isdigit()
+ if not (string.isascii() and string.isdigit()):
+ # This case triggers if there are fancy unicode digits in the base64 encoding,
+ # that means it's not a valid user id.
+ return None
+ return int(string)
except (binascii.Error, ValueError):
- return False
+ return None
@staticmethod
def is_valid_timestamp(b64_content: str) -> bool:
@@ -176,6 +209,24 @@ class TokenRemover(Cog):
log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch")
return False
+ @staticmethod
+ def is_maybe_valid_hmac(b64_content: str) -> bool:
+ """
+ Determine if a given HMAC portion of a token is potentially valid.
+
+ If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx",
+ and thus the token can probably be skipped.
+ """
+ unique = len(set(b64_content.lower()))
+ if unique <= 3:
+ log.debug(
+ f"Considering the HMAC {b64_content} a dummy because it has {unique}"
+ " case-insensitively unique characters"
+ )
+ return False
+ else:
+ return True
+
def setup(bot: Bot) -> None:
"""Load the TokenRemover cog."""
diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py
index ca126ebf5..08fe94055 100644
--- a/bot/exts/filters/webhook_remover.py
+++ b/bot/exts/filters/webhook_remover.py
@@ -7,6 +7,7 @@ from discord.ext.commands import Cog
from bot.bot import Bot
from bot.constants import Channels, Colours, Event, Icons
from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE)
@@ -45,8 +46,8 @@ class WebhookRemover(Cog):
await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))
message = (
- f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL "
- f"to #{msg.channel}. Webhook URL was `{redacted_url}`"
+ f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. "
+ f"Webhook URL was `{redacted_url}`"
)
log.debug(message)
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 581b3a227..156dfec35 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -15,7 +15,7 @@ from bot import constants
from bot.bot import Bot
from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
-from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, has_no_roles_check
+from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
@@ -202,14 +202,10 @@ class Information(Cog):
await ctx.send("You may not use this command on users other than yourself.")
return
- # Non-staff may only do this in #bot-commands
- if await has_no_roles_check(ctx, *constants.STAFF_ROLES):
- if not ctx.channel.id == constants.Channels.bot_commands:
- raise InWhitelistCheckFailure(constants.Channels.bot_commands)
-
- embed = await self.create_user_embed(ctx, user)
-
- await ctx.send(embed=embed)
+ # Will redirect to #bot-commands if it fails.
+ if in_whitelist_check(ctx, roles=constants.STAFF_ROLES):
+ embed = await self.create_user_embed(ctx, user)
+ await ctx.send(embed=embed)
async def create_user_embed(self, ctx: Context, user: Member) -> Embed:
"""Creates an embed containing information on the `user`."""
@@ -278,8 +274,14 @@ class Information(Cog):
)
]
+ # Use getattr to future-proof for commands invoked via DMs.
+ show_verbose = (
+ ctx.channel.id in constants.MODERATION_CHANNELS
+ or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail
+ )
+
# Show more verbose output in moderation channels for infractions and nominations
- if ctx.channel.id in constants.MODERATION_CHANNELS:
+ if show_verbose:
fields.append(await self.expanded_user_infraction_counts(user))
fields.append(await self.user_nomination_counts(user))
else:
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index 3bf462877..caa6fb917 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -11,6 +11,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles
from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
log = logging.getLogger(__name__)
@@ -106,7 +107,7 @@ class Defcon(Cog):
self.bot.stats.incr("defcon.leaves")
message = (
- f"{member} (`{member.id}`) was denied entry because their account is too new."
+ f"{format_user(member)} was denied entry because their account is too new."
)
if not message_sent:
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index e49913552..31be48a43 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -237,7 +237,7 @@ class Incidents(Cog):
not all information was relayed, return False. This signals that the original
message is not safe to be deleted, as we will lose some information.
"""
- log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})")
+ log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})")
embed, attachment_file = await make_embed(incident, outcome, actioned_by)
try:
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index cf48ef2ac..814b17830 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -12,12 +12,11 @@ from discord.ext.commands import Context
from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Colours, STAFF_CHANNELS
+from bot.constants import Colours, MODERATION_CHANNELS
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._utils import UserSnowflake
from bot.exts.moderation.modlog import ModLog
-from bot.utils import time
-from bot.utils.scheduling import Scheduler
+from bot.utils import messages, scheduling, time
log = logging.getLogger(__name__)
@@ -27,7 +26,7 @@ class InfractionScheduler:
def __init__(self, bot: Bot, supported_infractions: t.Container[str]):
self.bot = bot
- self.scheduler = Scheduler(self.__class__.__name__)
+ self.scheduler = scheduling.Scheduler(self.__class__.__name__)
self.bot.loop.create_task(self.reschedule_infractions(supported_infractions))
@@ -137,9 +136,9 @@ class InfractionScheduler:
)
if reason:
end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
- elif ctx.channel.id not in STAFF_CHANNELS:
+ elif ctx.channel.id not in MODERATION_CHANNELS:
log.trace(
- f"Infraction #{id_} context is not in a staff channel; omitting infraction count."
+ f"Infraction #{id_} context is not in a mod channel; omitting infraction count."
)
else:
log.trace(f"Fetching total infraction count for {user}.")
@@ -199,8 +198,8 @@ class InfractionScheduler:
title=f"Infraction {log_title}: {infr_type}",
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.author}{dm_log_text}{expiry_log_text}
+ Member: {messages.format_user(user)}
+ Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text}
Reason: {reason}
"""),
content=log_content,
@@ -243,48 +242,12 @@ class InfractionScheduler:
# Deactivate the infraction and cancel its scheduled expiration task.
log_text = await self.deactivate_infraction(response[0], send_log=False)
- log_text["Member"] = f"{user.mention}(`{user.id}`)"
- log_text["Actor"] = str(ctx.author)
+ log_text["Member"] = messages.format_user(user)
+ log_text["Actor"] = ctx.author.mention
log_content = None
id_ = response[0]['id']
footer = f"ID: {id_}"
- # If multiple active infractions were found, mark them as inactive in the database
- # and cancel their expiration tasks.
- if len(response) > 1:
- log.info(
- f"Found more than one active {infr_type} infraction for user {user.id}; "
- "deactivating the extra active infractions too."
- )
-
- footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
-
- log_note = f"Found multiple **active** {infr_type} infractions in the database."
- if "Note" in log_text:
- log_text["Note"] = f" {log_note}"
- else:
- log_text["Note"] = log_note
-
- # deactivate_infraction() is not called again because:
- # 1. Discord cannot store multiple active bans or assign multiples of the same role
- # 2. It would send a pardon DM for each active infraction, which is redundant
- for infraction in response[1:]:
- id_ = infraction['id']
- try:
- # Mark infraction as inactive in the database.
- await self.bot.api_client.patch(
- f"bot/infractions/{id_}",
- json={"active": False}
- )
- except ResponseCodeError:
- log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})")
- # This is simpler and cleaner than trying to concatenate all the errors.
- log_text["Failure"] = "See bot's logs for details."
-
- # Cancel pending expiration task.
- if infraction["expires_at"] is not None:
- self.scheduler.cancel(infraction["id"])
-
# Accordingly display whether the user was successfully notified via DM.
dm_emoji = ""
if log_text.get("DM") == "Sent":
@@ -358,7 +321,7 @@ class InfractionScheduler:
log_content = None
log_text = {
"Member": f"<@{user_id}>",
- "Actor": str(self.bot.get_user(actor) or actor),
+ "Actor": f"<@{actor}>",
"Reason": infraction["reason"],
"Created": created,
}
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index 5fa62d3c4..ef6f6e3c6 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -15,6 +15,7 @@ from bot.decorators import respect_role_hierarchy
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.exts.moderation.infraction._utils import UserSnowflake
+from bot.utils.messages import format_user
log = logging.getLogger(__name__)
@@ -315,7 +316,7 @@ class Infractions(InfractionScheduler, commands.Cog):
icon_url=_utils.INFRACTION_ICONS["mute"][1]
)
- log_text["Member"] = f"{user.mention}(`{user.id}`)"
+ log_text["Member"] = format_user(user)
log_text["DM"] = "Sent" if notified else "**Failed**"
else:
log.info(f"Failed to unmute user {user_id}: user not found")
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 15ee28537..856a4e1a2 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -6,15 +6,15 @@ from datetime import datetime
import discord
from discord.ext import commands
from discord.ext.commands import Context
+from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user
-from bot.exts.moderation.infraction import _utils
+from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user
from bot.exts.moderation.infraction.infractions import Infractions
from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
-from bot.utils import time
+from bot.utils import messages, time
from bot.utils.checks import in_whitelist_check
log = logging.getLogger(__name__)
@@ -154,16 +154,12 @@ class ModManagement(commands.Cog):
user = ctx.guild.get_member(user_id)
if user:
- user_text = f"{user.mention} (`{user.id}`)"
+ user_text = messages.format_user(user)
thumbnail = user.avatar_url_as(static_format="png")
else:
- user_text = f"`{user_id}`"
+ user_text = f"<@{user_id}>"
thumbnail = None
- # The infraction's actor
- actor_id = new_infraction['actor']
- actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"
-
await self.mod_log.send_log_message(
icon_url=constants.Icons.pencil,
colour=discord.Colour.blurple(),
@@ -171,8 +167,8 @@ class ModManagement(commands.Cog):
thumbnail=thumbnail,
text=textwrap.dedent(f"""
Member: {user_text}
- Actor: {actor}
- Edited by: {ctx.message.author}{log_text}
+ Actor: <@{new_infraction['actor']}>
+ Edited by: {ctx.message.author.mention}{log_text}
""")
)
@@ -180,10 +176,10 @@ class ModManagement(commands.Cog):
# region: Search infractions
@infraction_group.group(name="search", invoke_without_command=True)
- async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None:
+ async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None:
"""Searches for infractions in the database."""
- if isinstance(query, discord.User):
- await ctx.invoke(self.search_user, query)
+ if isinstance(query, int):
+ await ctx.invoke(self.search_user, discord.Object(query))
else:
await ctx.invoke(self.search_reason, query)
@@ -191,9 +187,16 @@ class ModManagement(commands.Cog):
async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:
"""Search for infractions by member."""
infraction_list = await self.bot.api_client.get(
- 'bot/infractions',
+ 'bot/infractions/expanded',
params={'user__id': str(user.id)}
)
+
+ user = self.bot.get_user(user.id)
+ if not user and infraction_list:
+ # Use the user data retrieved from the DB for the username.
+ user = infraction_list[0]["user"]
+ user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}"
+
embed = discord.Embed(
title=f"Infractions for {user} ({len(infraction_list)} total)",
colour=discord.Colour.orange()
@@ -204,7 +207,7 @@ class ModManagement(commands.Cog):
async def search_reason(self, ctx: Context, reason: str) -> None:
"""Search for infractions by their reason. Use Re2 for matching."""
infraction_list = await self.bot.api_client.get(
- 'bot/infractions',
+ 'bot/infractions/expanded',
params={'search': reason}
)
embed = discord.Embed(
@@ -220,7 +223,7 @@ class ModManagement(commands.Cog):
self,
ctx: Context,
embed: discord.Embed,
- infractions: t.Iterable[_utils.Infraction]
+ infractions: t.Iterable[t.Dict[str, t.Any]]
) -> None:
"""Send a paginated embed of infractions for the specified user."""
if not infractions:
@@ -241,37 +244,43 @@ class ModManagement(commands.Cog):
max_size=1000
)
- def infraction_to_string(self, infraction: _utils.Infraction) -> str:
+ def infraction_to_string(self, infraction: t.Dict[str, t.Any]) -> str:
"""Convert the infraction object to a string representation."""
- actor_id = infraction["actor"]
- guild = self.bot.get_guild(constants.Guild.id)
- actor = guild.get_member(actor_id)
active = infraction["active"]
- user_id = infraction["user"]
- hidden = infraction["hidden"]
+ user = infraction["user"]
+ expires_at = infraction["expires_at"]
created = time.format_infraction(infraction["inserted_at"])
+ # Format the user string.
+ if user_obj := self.bot.get_user(user["id"]):
+ # The user is in the cache.
+ user_str = messages.format_user(user_obj)
+ else:
+ # Use the user data retrieved from the DB.
+ name = escape_markdown(user['name'])
+ user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})"
+
if active:
- remaining = time.until_expiration(infraction["expires_at"]) or "Expired"
+ remaining = time.until_expiration(expires_at) or "Expired"
else:
remaining = "Inactive"
- if infraction["expires_at"] is None:
+ if expires_at is None:
expires = "*Permanent*"
else:
date_from = datetime.strptime(created, time.INFRACTION_FORMAT)
- expires = time.format_infraction_with_duration(infraction["expires_at"], date_from)
+ expires = time.format_infraction_with_duration(expires_at, date_from)
lines = textwrap.dedent(f"""
{"**===============**" if active else "==============="}
Status: {"__**Active**__" if active else "Inactive"}
- User: {self.bot.get_user(user_id)} (`{user_id}`)
+ User: {user_str}
Type: **{infraction["type"]}**
- Shadow: {hidden}
+ Shadow: {infraction["hidden"]}
Created: {created}
Expires: {expires}
Remaining: {remaining}
- Actor: {actor.mention if actor else actor_id}
+ Actor: <@{infraction["actor"]["id"]}>
ID: `{infraction["id"]}`
Reason: {infraction["reason"] or "*None*"}
{"**===============**" if active else "==============="}
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 29f41f2ab..eec63f5b3 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -7,12 +7,14 @@ from pathlib import Path
from discord import Colour, Embed, Member
from discord.ext.commands import Cog, Context, command, has_any_role
+from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
from bot.converters import Expiry
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
+from bot.utils.messages import format_user
from bot.utils.time import format_infraction
log = logging.getLogger(__name__)
@@ -137,7 +139,6 @@ class Superstarify(InfractionScheduler, Cog):
infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True)
id_ = infraction["id"]
- old_nick = member.display_name
forced_nick = self.get_nick(id_, member.id)
expiry_str = format_infraction(infraction["expires_at"])
@@ -147,6 +148,9 @@ class Superstarify(InfractionScheduler, Cog):
await member.edit(nick=forced_nick, reason=reason)
self.schedule_expiration(infraction)
+ old_nick = escape_markdown(member.display_name)
+ forced_nick = escape_markdown(forced_nick)
+
# Send a DM to the user to notify them of their new infraction.
await _utils.notify_infraction(
user=member,
@@ -180,8 +184,8 @@ class Superstarify(InfractionScheduler, Cog):
title="Member achieved superstardom",
thumbnail=member.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
- Member: {member.mention} (`{member.id}`)
- Actor: {ctx.message.author}
+ Member: {member.mention}
+ Actor: {ctx.message.author.mention}
Expires: {expiry_str}
Old nickname: `{old_nick}`
New nickname: `{forced_nick}`
@@ -220,7 +224,7 @@ class Superstarify(InfractionScheduler, Cog):
)
return {
- "Member": f"{user.mention}(`{user.id}`)",
+ "Member": format_user(user),
"DM": "Sent" if notified else "**Failed**"
}
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index b0d9b5b2b..41ed46b69 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -12,10 +12,10 @@ from deepdiff import DeepDiff
from discord import Colour
from discord.abc import GuildChannel
from discord.ext.commands import Cog, Context
-from discord.utils import escape_markdown
from bot.bot import Bot
from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
+from bot.utils.messages import format_user
from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -396,7 +396,7 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
Icons.user_ban, Colours.soft_red,
- "User banned", f"{member} (`{member.id}`)",
+ "User banned", format_user(member),
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.user_log
)
@@ -407,12 +407,10 @@ class ModLog(Cog, name="ModLog"):
if member.guild.id != GuildConstant.id:
return
- member_str = escape_markdown(str(member))
- message = f"{member_str} (`{member.id}`)"
now = datetime.utcnow()
difference = abs(relativedelta(now, member.created_at))
- message += "\n\n**Account age:** " + humanize_delta(difference)
+ message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)
if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account!
message = f"{Emojis.new} {message}"
@@ -434,10 +432,9 @@ class ModLog(Cog, name="ModLog"):
self._ignored[Event.member_remove].remove(member.id)
return
- member_str = escape_markdown(str(member))
await self.send_log_message(
Icons.sign_out, Colours.soft_red,
- "User left", f"{member_str} (`{member.id}`)",
+ "User left", format_user(member),
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.user_log
)
@@ -452,10 +449,9 @@ class ModLog(Cog, name="ModLog"):
self._ignored[Event.member_unban].remove(member.id)
return
- member_str = escape_markdown(str(member))
await self.send_log_message(
Icons.user_unban, Colour.blurple(),
- "User unbanned", f"{member_str} (`{member.id}`)",
+ "User unbanned", format_user(member),
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.mod_log
)
@@ -515,8 +511,7 @@ class ModLog(Cog, name="ModLog"):
for item in sorted(changes):
message += f"{Emojis.bullet} {item}\n"
- member_str = escape_markdown(str(after))
- message = f"**{member_str}** (`{after.id}`)\n{message}"
+ message = f"{format_user(after)}\n{message}"
await self.send_log_message(
icon_url=Icons.user_update,
@@ -549,17 +544,16 @@ class ModLog(Cog, name="ModLog"):
if author.bot:
return
- author_str = escape_markdown(str(author))
if channel.category:
response = (
- f"**Author:** {author_str} (`{author.id}`)\n"
+ f"**Author:** {format_user(author)}\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
)
else:
response = (
- f"**Author:** {author_str} (`{author.id}`)\n"
+ f"**Author:** {format_user(author)}\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -645,9 +639,6 @@ class ModLog(Cog, name="ModLog"):
if msg_before.content == msg_after.content:
return
- author = msg_before.author
- author_str = escape_markdown(str(author))
-
channel = msg_before.channel
channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"
@@ -679,7 +670,7 @@ class ModLog(Cog, name="ModLog"):
content_after.append(sub)
response = (
- f"**Author:** {author_str} (`{author.id}`)\n"
+ f"**Author:** {format_user(msg_before.author)}\n"
f"**Channel:** {channel_name} (`{channel.id}`)\n"
f"**Message ID:** `{msg_before.id}`\n"
"\n"
@@ -731,12 +722,11 @@ class ModLog(Cog, name="ModLog"):
self._cached_edits.remove(event.message_id)
return
- author = message.author
channel = message.channel
channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"
before_response = (
- f"**Author:** {author} (`{author.id}`)\n"
+ f"**Author:** {format_user(message.author)}\n"
f"**Channel:** {channel_name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -744,7 +734,7 @@ class ModLog(Cog, name="ModLog"):
)
after_response = (
- f"**Author:** {author} (`{author.id}`)\n"
+ f"**Author:** {format_user(message.author)}\n"
f"**Channel:** {channel_name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -822,9 +812,8 @@ class ModLog(Cog, name="ModLog"):
if not changes:
return
- member_str = escape_markdown(str(member))
message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes))
- message = f"**{member_str}** (`{member.id}`)\n{message}"
+ message = f"{format_user(member)}\n{message}"
await self.send_log_message(
icon_url=icon,
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index 6dad82d1e..6bbe81701 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -15,6 +15,7 @@ from bot.bot import Bot
from bot.decorators import has_no_roles, in_whitelist
from bot.exts.moderation.modlog import ModLog
from bot.utils.checks import InWhitelistCheckFailure, has_no_roles_check
+from bot.utils.messages import format_user
log = logging.getLogger(__name__)
@@ -285,7 +286,7 @@ class Verification(Cog):
Returns the amount of successful requests. Failed requests are logged at info level.
"""
- log.info(f"Sending {len(members)} requests")
+ log.trace(f"Sending {len(members)} requests")
n_success, bad_statuses = 0, set()
for progress, member in enumerate(members, start=1):
@@ -525,7 +526,7 @@ class Verification(Cog):
)
embed_text = (
- f"{message.author.mention} sent a message in "
+ f"{format_user(message.author)} sent a message in "
f"{message.channel.mention} that contained user and/or role mentions."
f"\n\n**Original message:**\n>>> {message.content}"
)
diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py
index 236603dba..bf25cb4c2 100644
--- a/bot/exts/utils/clean.py
+++ b/bot/exts/utils/clean.py
@@ -178,7 +178,8 @@ class Clean(Cog):
target_channels = ", ".join(channel.mention for channel in channels)
message = (
- f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n"
+ f"**{len(message_ids)}** messages deleted in {target_channels} by "
+ f"{ctx.author.mention}\n\n"
f"A log of the deleted messages can be found [here]({log_url})."
)
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index aa8f17f75..9cc0d8a34 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -6,8 +6,7 @@ import re
from io import BytesIO
from typing import List, Optional, Sequence, Union
-from discord import Client, Colour, Embed, File, Member, Message, Reaction, TextChannel, Webhook
-from discord.abc import Snowflake
+import discord
from discord.errors import HTTPException
from discord.ext.commands import Context
@@ -17,9 +16,9 @@ log = logging.getLogger(__name__)
async def wait_for_deletion(
- message: Message,
- user_ids: Sequence[Snowflake],
- client: Client,
+ message: discord.Message,
+ user_ids: Sequence[discord.abc.Snowflake],
+ client: discord.Client,
deletion_emojis: Sequence[str] = (Emojis.trashcan,),
timeout: float = 60 * 5,
attach_emojis: bool = True,
@@ -37,7 +36,7 @@ async def wait_for_deletion(
for emoji in deletion_emojis:
await message.add_reaction(emoji)
- def check(reaction: Reaction, user: Member) -> bool:
+ def check(reaction: discord.Reaction, user: discord.Member) -> bool:
"""Check that the deletion emoji is reacted by the appropriate user."""
return (
reaction.message.id == message.id
@@ -51,8 +50,8 @@ async def wait_for_deletion(
async def send_attachments(
- message: Message,
- destination: Union[TextChannel, Webhook],
+ message: discord.Message,
+ destination: Union[discord.TextChannel, discord.Webhook],
link_large: bool = True
) -> List[str]:
"""
@@ -76,9 +75,9 @@ async def send_attachments(
if attachment.size <= destination.guild.filesize_limit - 512:
with BytesIO() as file:
await attachment.save(file, use_cached=True)
- attachment_file = File(file, filename=attachment.filename)
+ attachment_file = discord.File(file, filename=attachment.filename)
- if isinstance(destination, TextChannel):
+ if isinstance(destination, discord.TextChannel):
msg = await destination.send(file=attachment_file)
urls.append(msg.attachments[0].url)
else:
@@ -99,10 +98,10 @@ async def send_attachments(
if link_large and large:
desc = "\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)
- embed = Embed(description=desc)
+ embed = discord.Embed(description=desc)
embed.set_footer(text="Attachments exceed upload size limit.")
- if isinstance(destination, TextChannel):
+ if isinstance(destination, discord.TextChannel):
await destination.send(embed=embed)
else:
await destination.send(
@@ -133,9 +132,14 @@ def sub_clyde(username: Optional[str]) -> Optional[str]:
async def send_denial(ctx: Context, reason: str) -> None:
"""Send an embed denying the user with the given reason."""
- embed = Embed()
- embed.colour = Colour.red()
+ embed = discord.Embed()
+ embed.colour = discord.Colour.red()
embed.title = random.choice(NEGATIVE_REPLIES)
embed.description = reason
await ctx.send(embed=embed)
+
+
+def format_user(user: discord.abc.User) -> str:
+ """Return a string for `user` which has their mention and ID."""
+ return f"{user.mention} (`{user.id}`)"
diff --git a/config-default.yml b/config-default.yml
index b4cdce38f..3de83dbb1 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -197,15 +197,6 @@ guild:
big_brother_logs: &BB_LOGS 468507907357409333
talent_pool: &TALENT_POOL 534321732593647616
- staff_channels:
- - *ADMINS
- - *ADMIN_SPAM
- - *DEFCON
- - *HELPERS
- - *MODS
- - *MOD_SPAM
- - *ORGANISATION
-
moderation_channels:
- *ADMINS
- *ADMIN_SPAM
diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py
index a0ff8a877..f99cc3370 100644
--- a/tests/bot/exts/filters/test_token_remover.py
+++ b/tests/bot/exts/filters/test_token_remover.py
@@ -9,6 +9,7 @@ from bot import constants
from bot.exts.filters import token_remover
from bot.exts.filters.token_remover import Token, TokenRemover
from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
from tests.helpers import MockBot, MockMessage, autospec
@@ -22,23 +23,25 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
self.msg = MockMessage(id=555, content="hello world")
self.msg.channel.mention = "#lemonade-stand"
+ self.msg.guild.get_member.return_value.bot = False
+ self.msg.guild.get_member.return_value.__str__.return_value = "Woody"
self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name)
self.msg.author.avatar_url_as.return_value = "picture-lemon.png"
- def test_is_valid_user_id_valid(self):
- """Should consider user IDs valid if they decode entirely to ASCII digits."""
- ids = (
- "NDcyMjY1OTQzMDYyNDEzMzMy",
- "NDc1MDczNjI5Mzk5NTQ3OTA0",
- "NDY3MjIzMjMwNjUwNzc3NjQx",
+ def test_extract_user_id_valid(self):
+ """Should consider user IDs valid if they decode into an integer ID."""
+ id_pairs = (
+ ("NDcyMjY1OTQzMDYyNDEzMzMy", 472265943062413332),
+ ("NDc1MDczNjI5Mzk5NTQ3OTA0", 475073629399547904),
+ ("NDY3MjIzMjMwNjUwNzc3NjQx", 467223230650777641),
)
- for user_id in ids:
- with self.subTest(user_id=user_id):
- result = TokenRemover.is_valid_user_id(user_id)
- self.assertTrue(result)
+ for token_id, user_id in id_pairs:
+ with self.subTest(token_id=token_id):
+ result = TokenRemover.extract_user_id(token_id)
+ self.assertEqual(result, user_id)
- def test_is_valid_user_id_invalid(self):
+ def test_extract_user_id_invalid(self):
"""Should consider non-digit and non-ASCII IDs invalid."""
ids = (
("SGVsbG8gd29ybGQ", "non-digit ASCII"),
@@ -52,8 +55,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
for user_id, msg in ids:
with self.subTest(msg=msg):
- result = TokenRemover.is_valid_user_id(user_id)
- self.assertFalse(result)
+ result = TokenRemover.extract_user_id(user_id)
+ self.assertIsNone(result)
def test_is_valid_timestamp_valid(self):
"""Should consider timestamps valid if they're greater than the Discord epoch."""
@@ -85,6 +88,34 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
result = TokenRemover.is_valid_timestamp(timestamp)
self.assertFalse(result)
+ def test_is_valid_hmac_valid(self):
+ """Should consider an HMAC valid if it has at least 3 unique characters."""
+ valid_hmacs = (
+ "VXmErH7j511turNpfURmb0rVNm8",
+ "Ysnu2wacjaKs7qnoo46S8Dm2us8",
+ "sJf6omBPORBPju3WJEIAcwW9Zds",
+ "s45jqDV_Iisn-symw0yDRrk_jf4",
+ )
+
+ for hmac in valid_hmacs:
+ with self.subTest(msg=hmac):
+ result = TokenRemover.is_maybe_valid_hmac(hmac)
+ self.assertTrue(result)
+
+ def test_is_invalid_hmac_invalid(self):
+ """Should consider an HMAC invalid if has fewer than 3 unique characters."""
+ invalid_hmacs = (
+ ("xxxxxxxxxxxxxxxxxx", "Single character"),
+ ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"),
+ ("ASFasfASFasfASFASsf", "Three characters alternating-case"),
+ ("asdasdasdasdasdasdasd", "Three characters one case"),
+ )
+
+ for hmac, msg in invalid_hmacs:
+ with self.subTest(msg=msg):
+ result = TokenRemover.is_maybe_valid_hmac(hmac)
+ self.assertFalse(result)
+
def test_mod_log_property(self):
"""The `mod_log` property should ask the bot to return the `ModLog` cog."""
self.bot.get_cog.return_value = 'lemon'
@@ -142,11 +173,18 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(return_value)
token_re.finditer.assert_called_once_with(self.msg.content)
- @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp")
+ @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac")
@autospec("bot.exts.filters.token_remover", "Token")
@autospec("bot.exts.filters.token_remover", "TOKEN_RE")
- def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp):
- """The first match with a valid user ID and timestamp should be returned as a `Token`."""
+ def test_find_token_valid_match(
+ self,
+ token_re,
+ token_cls,
+ extract_user_id,
+ is_valid_timestamp,
+ is_maybe_valid_hmac,
+ ):
+ """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`."""
matches = [
mock.create_autospec(Match, spec_set=True, instance=True),
mock.create_autospec(Match, spec_set=True, instance=True),
@@ -158,23 +196,32 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
token_re.finditer.return_value = matches
token_cls.side_effect = tokens
- is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid.
+ extract_user_id.side_effect = (None, True) # The 1st match will be invalid, 2nd one valid.
is_valid_timestamp.return_value = True
+ is_maybe_valid_hmac.return_value = True
return_value = TokenRemover.find_token_in_message(self.msg)
self.assertEqual(tokens[1], return_value)
token_re.finditer.assert_called_once_with(self.msg.content)
- @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp")
+ @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac")
@autospec("bot.exts.filters.token_remover", "Token")
@autospec("bot.exts.filters.token_remover", "TOKEN_RE")
- def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp):
- """None should be returned if no matches have valid user IDs or timestamps."""
+ def test_find_token_invalid_matches(
+ self,
+ token_re,
+ token_cls,
+ extract_user_id,
+ is_valid_timestamp,
+ is_maybe_valid_hmac,
+ ):
+ """None should be returned if no matches have valid user IDs, HMACs, and timestamps."""
token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)]
token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True)
- is_valid_id.return_value = False
+ extract_user_id.return_value = None
is_valid_timestamp.return_value = False
+ is_maybe_valid_hmac.return_value = False
return_value = TokenRemover.find_token_in_message(self.msg)
@@ -233,33 +280,82 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
@autospec("bot.exts.filters.token_remover", "LOG_MESSAGE")
def test_format_log_message(self, log_message):
"""Should correctly format the log message with info from the message and token."""
- token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
+ token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
log_message.format.return_value = "Howdy"
return_value = TokenRemover.format_log_message(self.msg, token)
self.assertEqual(return_value, log_message.format.return_value)
log_message.format.assert_called_once_with(
- author=self.msg.author,
- author_id=self.msg.author.id,
+ author=format_user(self.msg.author),
channel=self.msg.channel.mention,
user_id=token.user_id,
timestamp=token.timestamp,
hmac="x" * len(token.hmac),
)
+ @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE")
+ def test_format_userid_log_message_unknown(self, unknown_user_log_message):
+ """Should correctly format the user ID portion when the actual user it belongs to is unknown."""
+ token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
+ unknown_user_log_message.format.return_value = " Partner"
+ msg = MockMessage(id=555, content="hello world")
+ msg.guild.get_member.return_value = None
+
+ return_value = TokenRemover.format_userid_log_message(msg, token)
+
+ self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False))
+ unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332)
+
+ @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE")
+ def test_format_userid_log_message_bot(self, known_user_log_message):
+ """Should correctly format the user ID portion when the ID belongs to a known bot."""
+ token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
+ known_user_log_message.format.return_value = " Partner"
+ msg = MockMessage(id=555, content="hello world")
+ msg.guild.get_member.return_value.__str__.return_value = "Sam"
+ msg.guild.get_member.return_value.bot = True
+
+ return_value = TokenRemover.format_userid_log_message(msg, token)
+
+ self.assertEqual(return_value, (known_user_log_message.format.return_value, False))
+
+ known_user_log_message.format.assert_called_once_with(
+ user_id=472265943062413332,
+ user_name="Sam",
+ kind="BOT",
+ )
+
+ @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE")
+ def test_format_log_message_user_token_user(self, user_token_message):
+ """Should correctly format the user ID portion when the ID belongs to a known user."""
+ token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
+ user_token_message.format.return_value = "Partner"
+
+ return_value = TokenRemover.format_userid_log_message(self.msg, token)
+
+ self.assertEqual(return_value, (user_token_message.format.return_value, True))
+ user_token_message.format.assert_called_once_with(
+ user_id=467223230650777641,
+ user_name="Woody",
+ kind="USER",
+ )
+
@mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)
@autospec("bot.exts.filters.token_remover", "log")
- @autospec(TokenRemover, "format_log_message")
- async def test_take_action(self, format_log_message, logger, mod_log_property):
+ @autospec(TokenRemover, "format_log_message", "format_userid_log_message")
+ async def test_take_action(self, format_log_message, format_userid_log_message, logger, mod_log_property):
"""Should delete the message and send a mod log."""
cog = TokenRemover(self.bot)
mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True)
token = mock.create_autospec(Token, spec_set=True, instance=True)
+ token.user_id = "no-id"
log_msg = "testing123"
+ userid_log_message = "userid-log-message"
mod_log_property.return_value = mod_log
format_log_message.return_value = log_msg
+ format_userid_log_message.return_value = (userid_log_message, True)
await cog.take_action(self.msg, token)
@@ -269,6 +365,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
)
format_log_message.assert_called_once_with(self.msg, token)
+ format_userid_log_message.assert_called_once_with(self.msg, token)
logger.debug.assert_called_with(log_msg)
self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens")
@@ -277,9 +374,10 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
icon_url=constants.Icons.token_removed,
colour=Colour(constants.Colours.soft_red),
title="Token removed!",
- text=log_msg,
+ text=log_msg + "\n" + userid_log_message,
thumbnail=self.msg.author.avatar_url_as.return_value,
- channel_id=constants.Channels.mod_alerts
+ channel_id=constants.Channels.mod_alerts,
+ ping_everyone=True,
)
@mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index ba8d5d608..d3f2995fb 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -532,10 +532,13 @@ class UserCommandTests(unittest.TestCase):
self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role])
self.target = helpers.MockMember(id=3, name="__fluzz__")
+ # There's no way to mock the channel constant without deferring imports. The constant is
+ # used as a default value for a parameter, which gets defined upon import.
+ self.bot_command_channel = helpers.MockTextChannel(id=constants.Channels.bot_commands)
+
def test_regular_member_cannot_target_another_member(self, constants):
"""A regular user should not be able to use `!user` targeting another user."""
constants.MODERATION_ROLES = [self.moderator_role.id]
-
ctx = helpers.MockContext(author=self.author)
asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target))
@@ -546,8 +549,6 @@ class UserCommandTests(unittest.TestCase):
"""A regular user should not be able to use this command outside of bot-commands."""
constants.MODERATION_ROLES = [self.moderator_role.id]
constants.STAFF_ROLES = [self.moderator_role.id]
- constants.Channels.bot_commands = 50
-
ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100))
msg = "Sorry, but you may only use this command within <#50>."
@@ -558,9 +559,7 @@ class UserCommandTests(unittest.TestCase):
def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):
"""A regular user should be allowed to use `!user` targeting themselves in bot-commands."""
constants.STAFF_ROLES = [self.moderator_role.id]
- constants.Channels.bot_commands = 50
-
- ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50))
+ ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel)
asyncio.run(self.cog.user_info.callback(self.cog, ctx))
@@ -568,12 +567,10 @@ class UserCommandTests(unittest.TestCase):
ctx.send.assert_called_once()
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
- def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants):
+ def test_regular_user_can_explicitly_target_themselves(self, create_embed, _):
"""A user should target itself with `!user` when a `user` argument was not provided."""
constants.STAFF_ROLES = [self.moderator_role.id]
- constants.Channels.bot_commands = 50
-
- ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50))
+ ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel)
asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author))
@@ -584,8 +581,6 @@ class UserCommandTests(unittest.TestCase):
def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):
"""Staff members should be able to bypass the bot-commands channel restriction."""
constants.STAFF_ROLES = [self.moderator_role.id]
- constants.Channels.bot_commands = 50
-
ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200))
asyncio.run(self.cog.user_info.callback(self.cog, ctx))
@@ -598,7 +593,6 @@ class UserCommandTests(unittest.TestCase):
"""A moderator should be able to use `!user` targeting another user."""
constants.MODERATION_ROLES = [self.moderator_role.id]
constants.STAFF_ROLES = [self.moderator_role.id]
-
ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50))
asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target))