aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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.py6
-rw-r--r--bot/exts/filters/webhook_remover.py5
-rw-r--r--bot/exts/moderation/defcon.py3
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py51
-rw-r--r--bot/exts/moderation/infraction/infractions.py3
-rw-r--r--bot/exts/moderation/infraction/management.py55
-rw-r--r--bot/exts/moderation/infraction/superstarify.py12
-rw-r--r--bot/exts/moderation/modlog.py35
-rw-r--r--bot/exts/moderation/verification.py3
-rw-r--r--bot/exts/utils/clean.py3
-rw-r--r--bot/utils/messages.py34
-rw-r--r--tests/bot/exts/filters/test_token_remover.py4
15 files changed, 208 insertions, 205 deletions
diff --git a/bot/converters.py b/bot/converters.py
index 1358cbf1e..4cfd663ba 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..80ec67641 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..ba86e557a 100644
--- a/bot/exts/filters/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -11,11 +11,12 @@ 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}`"
)
DELETION_MESSAGE_TEMPLATE = (
@@ -111,8 +112,7 @@ class TokenRemover(Cog):
def format_log_message(msg: Message, token: Token) -> str:
"""Return 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,
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/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/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index cf48ef2ac..05a6ac03f 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -16,8 +16,7 @@ from bot.constants import Colours, STAFF_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))
@@ -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..622262c9b 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -6,15 +6,16 @@ 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.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user
from bot.exts.moderation.infraction import _utils
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 +155,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 +168,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,9 +177,9 @@ 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):
+ if isinstance(query, int):
await ctx.invoke(self.search_user, query)
else:
await ctx.invoke(self.search_reason, query)
@@ -191,7 +188,7 @@ 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)}
)
embed = discord.Embed(
@@ -204,7 +201,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(
@@ -241,37 +238,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..210c7a1af 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__)
@@ -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..5a5ee9a81 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.name.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..74956ed24 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -6,10 +6,10 @@ 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
+from discord.utils import escape_markdown
from bot.constants import Emojis, NEGATIVE_REPLIES
@@ -17,9 +17,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 +37,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 +51,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 +76,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 +99,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 +133,15 @@ 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 name#discriminator."""
+ name = escape_markdown(str(user))
+ return f"{user.mention} ({name})"
diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py
index a0ff8a877..ea822053b 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
@@ -240,8 +241,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
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,