diff options
| -rw-r--r-- | bot/cogs/antispam.py | 4 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 3 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 3 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 124 | ||||
| -rw-r--r-- | bot/cogs/moderation/infractions.py | 3 | ||||
| -rw-r--r-- | bot/cogs/moderation/management.py | 49 | ||||
| -rw-r--r-- | bot/cogs/moderation/modlog.py | 35 | ||||
| -rw-r--r-- | bot/cogs/moderation/scheduler.py | 51 | ||||
| -rw-r--r-- | bot/cogs/moderation/superstarify.py | 12 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 6 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 3 | ||||
| -rw-r--r-- | bot/cogs/webhook_remover.py | 5 | ||||
| -rw-r--r-- | bot/utils/messages.py | 34 | ||||
| -rw-r--r-- | tests/bot/cogs/test_token_remover.py | 4 |
14 files changed, 149 insertions, 187 deletions
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..e6fcb079c 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -19,7 +19,7 @@ from bot.constants import ( STAFF_ROLES, ) from bot.converters import Duration -from bot.utils.messages import send_attachments +from bot.utils.messages import format_user, send_attachments log = logging.getLogger(__name__) @@ -67,7 +67,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/cogs/clean.py b/bot/cogs/clean.py index f436e531a..c36ff3aba 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -179,7 +179,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/cogs/defcon.py b/bot/cogs/defcon.py index 4c0ad5914..a7dd4670e 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -12,6 +12,7 @@ from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role +from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -107,7 +108,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/cogs/filtering.py b/bot/cogs/filtering.py index 93cc1c655..0205e7a98 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/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 List, Mapping, NamedTuple, Optional, Union import dateutil import discord.errors @@ -17,6 +17,7 @@ from bot.constants import ( Channels, Colours, Filter, Icons, URLs ) +from bot.utils.messages import format_user from bot.utils.redis_cache import RedisCache from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler @@ -32,6 +33,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.""" @@ -174,8 +185,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)}" ) @@ -214,35 +225,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 @@ -305,43 +289,51 @@ 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["type"], 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) - - # 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 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_type: str, + 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}" + ping_everyone = Filter.ping_everyone + + 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": @@ -378,7 +370,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/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 8df642428..5404991e8 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -13,6 +13,7 @@ from bot.constants import Event from bot.converters import Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.utils.checks import with_role_check +from bot.utils.messages import format_user from . import utils from .scheduler import InfractionScheduler from .utils import UserSnowflake @@ -316,7 +317,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/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 672bb0e9c..56a601cb7 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -6,12 +6,13 @@ 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.pagination import LinePaginator -from bot.utils import time +from bot.utils import messages, time from bot.utils.checks import in_whitelist_check, with_role_check from . import utils from .infractions import Infractions @@ -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} """) ) @@ -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/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0a63f57b8..5d3055796 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/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__) @@ -392,7 +392,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 ) @@ -403,12 +403,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}" @@ -430,10 +428,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 ) @@ -448,10 +445,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 ) @@ -511,8 +507,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, @@ -545,17 +540,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" @@ -641,9 +635,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}" @@ -675,7 +666,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" @@ -727,12 +718,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" @@ -740,7 +730,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" @@ -818,9 +808,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/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 75028d851..0d54075f0 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -13,8 +13,7 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, STAFF_CHANNELS -from bot.utils import time -from bot.utils.scheduling import Scheduler +from bot.utils import messages, scheduling, time from . import utils from .modlog import ModLog from .utils import UserSnowflake @@ -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)) @@ -197,8 +196,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.message.author}{dm_log_text}{expiry_log_text} + Member: {messages.format_user(user)} + Actor: {ctx.message.author.mention}{dm_log_text}{expiry_log_text} Reason: {reason} """), content=log_content, @@ -241,48 +240,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.message.author) + log_text["Member"] = messages.format_user(user) + log_text["Actor"] = ctx.message.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": @@ -356,7 +319,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/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 867de815a..b23588b1c 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -7,11 +7,13 @@ from pathlib import Path from discord import Colour, Embed, Member from discord.ext.commands import Cog, Context, command +from discord.utils import escape_markdown from bot import constants from bot.bot import Bot from bot.converters import Expiry from bot.utils.checks import with_role_check +from bot.utils.messages import format_user from bot.utils.time import format_infraction from . import utils from .scheduler import InfractionScheduler @@ -138,7 +140,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"]) @@ -148,6 +149,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, @@ -181,8 +185,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}` @@ -221,7 +225,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/cogs/token_remover.py b/bot/cogs/token_remover.py index ef979f222..67d6918ab 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -11,11 +11,12 @@ from bot import utils from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Event, Icons +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/cogs/verification.py b/bot/cogs/verification.py index ae156cf70..f4cdc7059 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -9,6 +9,7 @@ from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.decorators import in_whitelist, without_role from bot.utils.checks import InWhitelistCheckFailure, without_role_check +from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -66,7 +67,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/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 5812da87c..d87664e85 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -7,6 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons +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/utils/messages.py b/bot/utils/messages.py index 670289941..31825d4a7 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,12 +17,12 @@ log = logging.getLogger(__name__) async def wait_for_deletion( - message: Message, - user_ids: Sequence[Snowflake], + message: discord.Message, + user_ids: Sequence[discord.abc.Snowflake], deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, - client: Optional[Client] = None + client: Optional[discord.Client] = None ) -> None: """ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. @@ -42,7 +42,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 @@ -56,8 +56,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]: """ @@ -81,9 +81,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: @@ -104,10 +104,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( @@ -138,9 +138,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/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3349caa73..5dee6922e 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -9,6 +9,7 @@ from bot import constants from bot.cogs import token_remover from bot.cogs.moderation import ModLog from bot.cogs.token_remover import Token, TokenRemover +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, |