diff --git a/didier/cogs/discord.py b/didier/cogs/discord.py index 70b4414..fa85b35 100644 --- a/didier/cogs/discord.py +++ b/didier/cogs/discord.py @@ -16,9 +16,10 @@ from didier.exceptions import expect from didier.menus.bookmarks import BookmarkSource from didier.menus.common import Menu from didier.utils.discord import colours -from didier.utils.discord.assets import get_author_avatar +from didier.utils.discord.assets import get_author_avatar, get_user_avatar +from didier.utils.discord.constants import Limits from didier.utils.types.datetime import str_to_date -from didier.utils.types.string import leading +from didier.utils.types.string import abbreviate, leading from didier.views.modals import CreateBookmark @@ -204,9 +205,7 @@ class Discord(commands.Cog): user = user or ctx.author embed = discord.Embed(colour=colours.github_white(), title="GitHub Links") - embed.set_author( - name=user.display_name, icon_url=user.avatar.url if user.avatar is not None else user.default_avatar.url - ) + embed.set_author(name=user.display_name, icon_url=get_user_avatar(user)) embed.set_footer(text="Links can be added using didier github add .") @@ -323,6 +322,39 @@ class Discord(commands.Cog): await message.add_reaction("📌") return await interaction.response.send_message("📌", ephemeral=True) + @commands.hybrid_command(name="snipe") + async def snipe(self, ctx: commands.Context): + """Publicly shame people when they edit or delete one of their messages. + + Note that uncached messages will not be sniped. + """ + if ctx.guild is None: + return await ctx.reply("Snipe only works in servers.", mention_author=False, ephemeral=True) + + sniped_data = self.client.sniped.get(ctx.channel.id, None) + if sniped_data is None: + return await ctx.reply( + "There's no one to make fun of in this channel.", mention_author=False, ephemeral=True + ) + + embed = discord.Embed(colour=discord.Colour.blue()) + + embed.set_author(name=sniped_data[0].author.display_name, icon_url=get_user_avatar(sniped_data[0].author)) + + if sniped_data[1] is not None: + embed.title = "Edit Snipe" + embed.add_field( + name="Before", value=abbreviate(sniped_data[0].content, Limits.EMBED_FIELD_VALUE_LENGTH), inline=False + ) + embed.add_field( + name="After", value=abbreviate(sniped_data[1].content, Limits.EMBED_FIELD_VALUE_LENGTH), inline=False + ) + else: + embed.title = "Delete Snipe" + embed.add_field(name="Message", value=sniped_data[0].content) + + return await ctx.reply(embed=embed, mention_author=False) + async def setup(client: Didier): """Load the cog""" diff --git a/didier/didier.py b/didier/didier.py index 9bec3f6..eb83df0 100644 --- a/didier/didier.py +++ b/didier/didier.py @@ -3,7 +3,7 @@ import os import pathlib import re from functools import cached_property -from typing import Union +from typing import Union, Optional import discord from aiohttp import ClientSession @@ -20,6 +20,7 @@ from didier.data.embeds.schedules import Schedule, parse_schedule from didier.exceptions import HTTPException, NoMatch from didier.utils.discord.prefix import get_prefix from didier.utils.easter_eggs import detect_easter_egg +from didier.utils.discord.snipe import should_snipe from didier.utils.types.datetime import tz_aware_now __all__ = ["Didier"] @@ -36,6 +37,7 @@ class Didier(commands.Bot): initial_extensions: tuple[str, ...] = () http_session: ClientSession schedules: dict[settings.ScheduleType, Schedule] = {} + sniped: dict[int, tuple[discord.Message, Optional[discord.Message]]] = {} wordle_words: set[str] = set() def __init__(self): @@ -335,6 +337,20 @@ class Didier(commands.Bot): if easter_egg is not None: await message.reply(easter_egg, mention_author=False) + async def on_message_delete(self, message: discord.Message): + """Event triggered when a message is deleted""" + if not should_snipe(message): + return + + self.sniped[message.channel.id] = (message, None,) + + async def on_message_edit(self, before: discord.Message, after: discord.Message): + """Event triggered when a message is edited""" + if not should_snipe(before): + return + + self.sniped[before.channel.id] = (before, after,) + async def on_ready(self): """Event triggered when the bot is ready""" print(settings.DISCORD_READY_MESSAGE) diff --git a/didier/utils/discord/assets.py b/didier/utils/discord/assets.py index 90473a6..800c511 100644 --- a/didier/utils/discord/assets.py +++ b/didier/utils/discord/assets.py @@ -3,10 +3,18 @@ from typing import Union import discord from discord.ext import commands -__all__ = ["get_author_avatar"] +__all__ = ["get_author_avatar", "get_user_avatar"] + + +def get_user_avatar(user: Union[discord.User, discord.Member]) -> discord.Asset: + """Get a user's avatar asset""" + if isinstance(user, discord.Member): + return user.display_avatar or user.default_avatar + + return user.avatar or user.default_avatar def get_author_avatar(ctx: Union[commands.Context, discord.Interaction]) -> discord.Asset: - """Get a user's avatar asset""" + """Get the avatar asset of a command author""" author = ctx.author if isinstance(ctx, commands.Context) else ctx.user - return author.avatar or author.default_avatar + return get_user_avatar(author) diff --git a/didier/utils/discord/snipe.py b/didier/utils/discord/snipe.py new file mode 100644 index 0000000..e5c70bd --- /dev/null +++ b/didier/utils/discord/snipe.py @@ -0,0 +1,19 @@ +import discord + +from didier.utils.regexes import STEAM_CODE + + +__all__ = ["should_snipe"] + + +def should_snipe(message: discord.Message) -> bool: + """Check if a message should be sniped or not""" + # Don't snipe DM's + if message.guild is None: + return False + + # Don't snipe bots + if message.author.bot: + return False + + return not STEAM_CODE.is_in(message.content) diff --git a/didier/utils/regexes.py b/didier/utils/regexes.py new file mode 100644 index 0000000..7656006 --- /dev/null +++ b/didier/utils/regexes.py @@ -0,0 +1,19 @@ +from typing import Union +from dataclasses import dataclass +import re + +__all__ = ["STEAM_CODE"] + + +@dataclass +class Regex: + """Dataclass for a type of pattern""" + pattern: str + flags: Union[int, re.RegexFlag] = 0 + + def is_in(self, text: str) -> bool: + """Check if a match for a pattern can be found within a string""" + return re.search(self.pattern, text, self.flags) is not None + + +STEAM_CODE = Regex(pattern="[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}", flags=re.IGNORECASE)