diff --git a/cogs/cOREmands.py b/cogs/cOREmands.py index 8c40a92..0e5d4df 100644 --- a/cogs/cOREmands.py +++ b/cogs/cOREmands.py @@ -2,7 +2,7 @@ import discord from discord.ext import commands -from util import app_is_staff, is_staff, create_deletion_embed, reply +from util import app_is_staff, is_staff, create_deletion_embed, reply, escape_nickname class COREmands(commands.Cog): @@ -35,10 +35,10 @@ async def trust(self, ctx, member: discord.Member): role = ctx.guild.get_role(self.bot.config["roles"]["trusted"]) if role in member.roles: await member.remove_roles(role) - await reply(ctx, f"{member.display_name} is no longer Trusted.") + await reply(ctx, f"{escape_nickname(member.display_name)} is no longer Trusted.") else: await member.add_roles(role) - await reply(ctx, f"{member.display_name} is now Trusted.") + await reply(ctx, f"{escape_nickname(member.display_name)} is now Trusted.") async def setup(bot): await bot.add_cog(COREmands(bot)) diff --git a/cogs/errorhandler.py b/cogs/errorhandler.py index b7b8300..e25b3b5 100644 --- a/cogs/errorhandler.py +++ b/cogs/errorhandler.py @@ -2,7 +2,7 @@ from discord.ext import commands -from util import NoRelayException, reply +from util import NoRelayException, reply, user_log_repr class ErrorHandler(commands.Cog): @@ -34,7 +34,7 @@ async def respond(message): ) elif isinstance(error, commands.CommandNotFound): self.bot.logger.info( - f"User '{ctx.author.display_name}' attempted to run an unrecognized command: '{ctx.message.content[1:]}'" + f"User {user_log_repr(ctx.author)} attempted to run an unrecognized command: '{ctx.message.content[1:]}'" ) await respond("Unrecognized command :'(") elif isinstance(error, commands.CommandOnCooldown): diff --git a/cogs/moderation.py b/cogs/moderation.py index 32f310d..cadb184 100644 --- a/cogs/moderation.py +++ b/cogs/moderation.py @@ -3,7 +3,7 @@ from discord.ext import commands, tasks import typing -from util import is_staff, app_is_staff, create_deletion_embed, reformat_relay_chat +from util import is_staff, app_is_staff, create_deletion_embed, reformat_relay_chat, escape_nickname from timeutil import UserFriendlyTime class Moderation(commands.Cog): @@ -97,7 +97,7 @@ async def tempban(self, ctx, user: typing.Union[discord.Member, discord.User], * embed.set_thumbnail(url="https://i.imgflip.com/44o9ir.png") embed.add_field(name="Staff Member", value=ctx.author.mention, inline=False) embed.add_field(name="User", value=user.mention, inline=True) - embed.add_field(name="Display Name", value=user.display_name, inline=True) + embed.add_field(name="Display Name", value=escape_nickname(user.display_name), inline=True) embed.add_field(name="Reason", value=reason if reason else "No reason provided", inline=False) embed.timestamp = ctx.message.created_at diff --git a/cogs/notifications.py b/cogs/notifications.py index 50ba1a7..5b9bbac 100644 --- a/cogs/notifications.py +++ b/cogs/notifications.py @@ -1,6 +1,8 @@ import discord from discord.ext import commands +from util import escape_nickname + class RoleButton(discord.ui.Button): def __init__(self, role): @@ -13,13 +15,13 @@ async def callback(self, interaction: discord.Interaction): if self.role in interaction.user.roles: await interaction.user.remove_roles(self.role) await interaction.response.send_message( - f"{interaction.user.display_name}, you are no longer subscribed to {self.role.name} notifications.", + f"{escape_nickname(interaction.user.display_name)}, you are no longer subscribed to {self.role.name} notifications.", ephemeral=True, ) else: await interaction.user.add_roles(self.role) await interaction.response.send_message( - f"{interaction.user.display_name}, you are now subscribed to {self.role.name} notifications.", + f"{escape_nickname(interaction.user.display_name)}, you are now subscribed to {self.role.name} notifications.", ephemeral=True, ) diff --git a/cogs/randcommands.py b/cogs/randcommands.py index 102ad81..fa905ba 100644 --- a/cogs/randcommands.py +++ b/cogs/randcommands.py @@ -15,7 +15,7 @@ from fractal import fractal from spirograph import spirograph from brainfuck import process_brainfuck -from util import is_staff, BaseConversionError, baseconvert, reply +from util import is_staff, BaseConversionError, baseconvert, reply, escape_nickname class RandCommands(commands.Cog): @@ -42,7 +42,7 @@ async def convert_func(ctx, number: str): # otherwise keep the generic text. except BaseConversionError as err: raise err from None - except ValueError as e: + except ValueError: await reply(ctx, f"Invalid input number for base {from_base}") for from_base, from_value in bases.items(): @@ -63,7 +63,8 @@ async def ping(self, ctx): message = await reply(ctx, "Testing...") latency = (perf_counter() - start) * 1000 await message.edit( - content=f"{ctx.author.display_name}: Pong!\nLatency: {latency:.2f}ms\n" + content=f"{escape_nickname(ctx.author.display_name)}: Pong!\n" + f"Latency: {latency:.2f}ms\n" f"API Latency: {self.bot.latency * 1000:.2f}ms" ) @@ -347,7 +348,7 @@ async def spirograph(self, ctx, seed: str): @commands.command(help="Be mean to someone. >:D") async def insult(self, ctx, target: str = None): if target is None: - target = ctx.author.display_name + target = escape_nickname(ctx.author.display_name) message = choice(self.bot.config["insults"]) await reply(ctx, message.format(user=target)) diff --git a/cogs/reminders.py b/cogs/reminders.py index 2c10c93..002a1c7 100644 --- a/cogs/reminders.py +++ b/cogs/reminders.py @@ -2,7 +2,7 @@ from discord.ext import commands, tasks from datetime import datetime, timedelta -from util import is_discord_member, return_or_truncate +from util import escape_nickname, is_discord_member, return_or_truncate from timeutil import UserFriendlyTime from paginator import EmbedPaginatorSession @@ -41,10 +41,10 @@ async def my_reminders(self, ctx): """List all reminders set by the user.""" reminders = await self.bot.database.get_reminders(ctx.author.id) if not reminders: - return await ctx.reply(f"{ctx.author.display_name}: You have no reminders set.") + return await ctx.reply(f"{escape_nickname(ctx.author.display_name)}: You have no reminders set.") if len(reminders) > 5: - embeds = [discord.Embed(title=f"{ctx.author.display_name}'s Reminders", color=discord.Color.blue()) for _ in range((len(reminders) - 1) // 5 + 1)] + embeds = [discord.Embed(title=f"{escape_nickname(ctx.author.display_name)}'s Reminders", color=discord.Color.blue()) for _ in range((len(reminders) - 1) // 5 + 1)] for i, reminder in enumerate(reminders): embeds[i // 5].add_field( name=f"Reminder at {reminder[2].strftime('%Y-%m-%d %H:%M:%S')}", @@ -54,7 +54,7 @@ async def my_reminders(self, ctx): paginator = EmbedPaginatorSession(ctx, *embeds) await paginator.run() else: - embed = discord.Embed(title=f"{ctx.author.display_name}'s Reminders", color=discord.Color.blue()) + embed = discord.Embed(title=f"{escape_nickname(ctx.author.display_name)}'s Reminders", color=discord.Color.blue()) for message, _, timestamp in reminders: embed.add_field( name=f"Reminder at {timestamp.strftime('%Y-%m-%d %H:%M:%S')}", diff --git a/cogs/timers.py b/cogs/timers.py index c984772..fc144eb 100644 --- a/cogs/timers.py +++ b/cogs/timers.py @@ -3,7 +3,7 @@ import discord from discord.ext import commands -from util import is_discord_member, reply +from util import is_discord_member, reply, escape_nickname def pretty_timedelta(delta): @@ -61,7 +61,7 @@ async def list_timers(self, ctx, member: discord.Member = None): if member == ctx.author: await reply(ctx, f"Your timers:\n{timers}") else: - await reply(ctx, f"{member.display_name}'s timers:\n{timers}") + await reply(ctx, f"{escape_nickname(member.display_name)}'s timers:\n{timers}") else: await reply(ctx, "No timers found.") diff --git a/patrick.py b/patrick.py index d0402e3..386cd86 100644 --- a/patrick.py +++ b/patrick.py @@ -14,7 +14,8 @@ from logger import StreamLogFormatter, setup_logger from util import (find_automod_matches, is_admin, load_automod_regexes, process_custom_command, reformat_relay_chat, split_list, - reply, create_automod_embed, RelayMember) + reply, create_automod_embed, RelayMember, escape_nickname, + user_log_repr) load_dotenv(Path(__file__).parent / ".env") TOKEN: str = getenv("TOKEN") @@ -218,7 +219,7 @@ async def on_message(self, message: discord.Message) -> None: if message.guild is not None and message.content.startswith("/link"): # If the message starts with /link, it's probably someone trying to link their account but not selecting the command from the popup. await message.channel.send( - f"{message.author.display_name}: Please use the `/link` command from the command popup as you type. Do not type it out manually." + f"{escape_nickname(message.author.display_name)}: Please use the `/link` command from the command popup as you type. Do not type it out manually." ) await message.delete() return @@ -229,7 +230,7 @@ async def on_message(self, message: discord.Message) -> None: matches = find_automod_matches(self, part) if matches: logger.info( - f"Automod triggered for user {message.author.display_name} with message {message.content}" + f"Automod triggered for user {user_log_repr(message.author)} with message {message.content}" ) channel = message.guild.get_channel( self.config["channels"]["automod"] @@ -269,7 +270,7 @@ async def process_commands(self, message: discord.Message) -> None: else: # A prefix was found, but no (custom) command was found. This means the user is trying to run a command that does not exist. self.logger.info( - f"User '{ctx.author.display_name}' attempted to run an unrecognized command: '{ctx.message.content[1:]}'" + f"User {user_log_repr(ctx.author)} attempted to run an unrecognized command: '{ctx.message.content[1:]}'" ) return await reply(ctx, "Unrecognized command :'(") @@ -277,7 +278,7 @@ async def process_commands(self, message: discord.Message) -> None: # The context is valid when a command and prefix was found. # This is provided by discord.py and ensures that the context is valid for regular command processing self.logger.info( - f"User '{message.author.display_name}' ran command '{ctx.command.name}'" + f"User {user_log_repr(message.author)} ran command '{ctx.command.name}'" ) await self.database.add_command_history( message.author.display_name, ctx.command.name diff --git a/util.py b/util.py index 11ad552..9581f33 100644 --- a/util.py +++ b/util.py @@ -9,6 +9,9 @@ from discord.ext import commands +DISCORD_NICKNAME_ESCAPE_RE = re.compile(r'([\\*#_`>~|\[\]()-])') + + class NoRelayException(Exception): ... @@ -26,6 +29,23 @@ class RelayMember(discord.Member): """ +def user_log_repr(user: discord.User | discord.Member) -> str: + """ + Create a formatted string using the un-escaped nickname as well as the user + id, so log messages are the same as in chattore. + """ + + return f"'{user.display_name}' ({user.id})" + + +def escape_nickname(name: str) -> str: + """ + Escape all characters in a discord nickname so they don't convert to markdown. + """ + + return DISCORD_NICKNAME_ESCAPE_RE.sub(r"\\\1", name) + + def return_or_truncate(text, max_length): """Takes a string and truncates it to a maximum length, adding ellipsis if truncated. If the string is shorter than the maximum length, it returns the original string. @@ -63,7 +83,7 @@ def reformat_relay_chat(bot, message) -> typing.Optional[discord.Message]: author_name, content = match.groups() message.author = copy(message.author) message.author.__class__ = RelayMember - message.author.nick = author_name + message.author.nick = author_name.replace("\\", "") message.content = content return message return None @@ -85,12 +105,14 @@ async def process_custom_command(bot, message) -> bool: for prefix in bot.command_prefix: if message.content.removeprefix(prefix) in commands: bot.logger.info( - f"User '{message.author.display_name}' ran custom command '{message.content[1:]}'" + f"User {user_log_repr(message.author)} ran custom command '{message.content[1:]}'" ) await message.channel.send( - f"{message.author.display_name}: {choice(commands[message.content.removeprefix(prefix)])}" + f"{escape_nickname(message.author.display_name)}: {choice(commands[message.content.removeprefix(prefix)])}" ) await bot.database.add_command_history( + # No need to escape name here, this is not sent immediately. Also, it might + # cause problems with the current state of the DB. message.author.display_name, message.content.removeprefix(prefix) ) return True @@ -327,7 +349,7 @@ async def create_deletion_embed( embed.set_thumbnail(url="https://i.imgflip.com/44o9ir.png") embed.add_field(name="Staff Member", value=staff.mention, inline=False) embed.add_field(name="User", value=message.author.mention, inline=True) - embed.add_field(name="Display Name", value=message.author.display_name, inline=True) + embed.add_field(name="Display Name", value=escape_nickname(message.author.display_name), inline=True) embed.add_field(name="Reason", value=reason, inline=False) if len(message.message_snapshots) > 0: embed.add_field( @@ -408,4 +430,4 @@ async def reply(ctx, message=None, is_reply=False, is_silent=False, **kwargs): if message is None: message = "" target = ctx.reply if is_reply else ctx.send - return await target(f"{ctx.author.display_name}: {message}", silent=is_silent, **kwargs) + return await target(f"{escape_nickname(ctx.author.display_name)}: {message}", silent=is_silent, **kwargs)