From fdfb50675a84701d5bc17813184738282495773a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:18:45 +0000 Subject: [PATCH 1/8] Split modlogformats into a package with registry decorator and overloads Co-authored-by: celveren <46062298+celveren@users.noreply.github.com> --- lightning/cogs/modlog/cog.py | 193 +++---- lightning/utils/modlogformats.py | 613 ---------------------- lightning/utils/modlogformats/__init__.py | 34 ++ lightning/utils/modlogformats/base.py | 401 ++++++++++++++ lightning/utils/modlogformats/embed.py | 176 +++++++ lightning/utils/modlogformats/emoji.py | 204 +++++++ lightning/utils/modlogformats/minimal.py | 207 ++++++++ 7 files changed, 1084 insertions(+), 744 deletions(-) delete mode 100644 lightning/utils/modlogformats.py create mode 100644 lightning/utils/modlogformats/__init__.py create mode 100644 lightning/utils/modlogformats/base.py create mode 100644 lightning/utils/modlogformats/embed.py create mode 100644 lightning/utils/modlogformats/emoji.py create mode 100644 lightning/utils/modlogformats/minimal.py diff --git a/lightning/cogs/modlog/cog.py b/lightning/cogs/modlog/cog.py index 415306a3..0d6c7d6a 100644 --- a/lightning/cogs/modlog/cog.py +++ b/lightning/cogs/modlog/cog.py @@ -147,6 +147,23 @@ async def get_records(self, guild: Union[discord.Guild, int], feature: int): yield emitter, rec + # Every event ends up here once it's formatted. Text formats get sent as a message, embeds + # get sent as an embed, and anything extra (like the offending message) tags along as embeds. + async def _emit(self, emitter: TextChannelEmitter, fmt_name: str, result: str | discord.Embed, *, + extra_embeds: Optional[List[discord.Embed]] = None, mentions: Optional[list] = None) -> None: + extra_embeds = extra_embeds or [] + if isinstance(result, discord.Embed): + await emitter.put(embeds=[result, *extra_embeds]) + return + + kwargs = {} + if extra_embeds: + kwargs['embeds'] = extra_embeds + # Only the emoji format actually pings people, so it's the only one that needs the allowed mentions + if fmt_name == "emoji" and mentions: + kwargs['allowed_mentions'] = discord.AllowedMentions(users=mentions) + await emitter.put(result, **kwargs) + # Bot events @LightningCog.listener() async def on_command_completion(self, ctx: LightningContext) -> None: @@ -154,16 +171,9 @@ async def on_command_completion(self, ctx: LightningContext) -> None: return async for emitter, record in self.get_records(ctx.guild, LoggingType.COMMAND_RAN): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - arg = False if record['format'] == "minimal without timestamp" else True - fmt = modlogformats.MinimalisticFormat.command_ran(ctx, with_timestamp=arg) - await emitter.send(fmt) - elif record['format'] == "emoji": - fmt = modlogformats.EmojiFormat.command_ran(ctx) - await emitter.send(fmt, allowed_mentions=discord.AllowedMentions(users=[ctx.author])) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.command_ran(ctx) - await emitter.send(embed=embed) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "command_ran", ctx, **kwargs) + await self._emit(emitter, fmt_name, result, mentions=[ctx.author]) async def handle_automod_events(self, event_name: str, event: LightningAutoModInfractionEvent): parts = [] @@ -181,22 +191,10 @@ async def handle_automod_events(self, event_name: str, event: LightningAutoModIn value=truncate_text(content, 1024)) async for emitter, record in self.get_records(event.guild, LoggingType(event_name)): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - fmt = modlogformats.MinimalisticFormat.from_action(event.action) - arg = False if record['format'] == "minimal without timestamp" else True - msg = fmt.format_message(with_timestamp=arg) - await emitter.send(msg, embed=msg_embed) - elif record['format'] == "emoji": - fmt = modlogformats.EmojiFormat.from_action(event.action) - msg = fmt.format_message() - await emitter.send(msg, embed=msg_embed, - allowed_mentions=discord.AllowedMentions(users=[event.action.target, - event.action.moderator])) - elif record['format'] == "embed": - fmt = modlogformats.EmbedFormat.from_action(event.action) - embed = fmt.format_message() - embeds = [embed, msg_embed] if msg_embed else [embed] - await emitter.send(embeds=embeds) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "from_action", event.action, **kwargs) + await self._emit(emitter, fmt_name, result, extra_embeds=[msg_embed] if msg_embed else [], + mentions=[event.action.target, event.action.moderator]) # Moderation @LightningCog.listener('on_lightning_member_warn') @@ -217,39 +215,23 @@ async def on_lightning_member_action(self, event: Union[AuditLogModAction, Infra return async for emitter, record in self.get_records(event.guild, LoggingType(event_name)): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - fmt = modlogformats.MinimalisticFormat.from_action(event.action) - arg = False if record['format'] == "minimal without timestamp" else True - msg = fmt.format_message(with_timestamp=arg) - await emitter.send(msg) - elif record['format'] == "emoji": - fmt = modlogformats.EmojiFormat.from_action(event.action) - msg = fmt.format_message() - await emitter.send(msg, - allowed_mentions=discord.AllowedMentions(users=[event.action.target, - event.action.moderator])) - elif record['format'] == "embed": - fmt = modlogformats.EmbedFormat.from_action(event.action) - embed = fmt.format_message() - await emitter.send(embed=embed) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "from_action", event.action, **kwargs) + await self._emit(emitter, fmt_name, result, mentions=[event.action.target, event.action.moderator]) @LightningCog.listener() async def on_lightning_timed_moderation_action_done(self, action, guild_id, user, moderator, timer): async for emitter, record in self.get_records(guild_id, LoggingType(f"MEMBER_{action.upper()}")): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - arg = False if record['format'] == "minimal without timestamp" else True - message = modlogformats.MinimalisticFormat.timed_action_expired(action.lower(), user, moderator, - timer.created_at, timer.expiry, - with_timestamp=arg) - await emitter.send(message) - elif record['format'] == "emoji": - message = modlogformats.EmojiFormat.timed_action_expired(action.lower(), user, moderator, - timer.created_at) - await emitter.send(message, allowed_mentions=discord.AllowedMentions(users=[user, moderator])) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.timed_action_expired(action.lower(), moderator, user, - timer.created_at) - await emitter.send(embed=embed) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + # The args are a bit different for each format, so we sort that out here + if fmt_name == "minimal": + args = (action.lower(), user, moderator, timer.created_at, timer.expiry) + elif fmt_name == "embed": + args = (action.lower(), moderator, user, timer.created_at) + else: + args = (action.lower(), user, moderator, timer.created_at) + result = modlogformats.format_event(fmt_name, "timed_action_expired", *args, **kwargs) + await self._emit(emitter, fmt_name, result, mentions=[user, moderator]) # Member events async def _log_member_join_leave(self, member, event): @@ -257,15 +239,12 @@ async def _log_member_join_leave(self, member, event): guild = member.guild async for emitter, record in self.get_records(guild, event): - if record['format'] == "minimal with timestamp": - message = modlogformats.MinimalisticFormat.join_leave(str(event), member) - await emitter.put(message) - elif record['format'] == "emoji": - message = modlogformats.EmojiFormat.join_leave(str(event), member) - await emitter.put(message, allowed_mentions=discord.AllowedMentions(users=[member])) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.join_leave(str(event), member) - await emitter.put(embed=embed) + # join_leave has always skipped the timestampless minimal format, so we keep skipping it + if record['format'] == "minimal without timestamp": + continue + fmt_name, _ = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "join_leave", str(event), member) + await self._emit(emitter, fmt_name, result, mentions=[member]) @LightningCog.listener() async def on_member_join(self, member): @@ -278,30 +257,15 @@ async def on_member_remove(self, member): @LightningCog.listener() async def on_lightning_member_passed_screening(self, member): async for emitter, record in self.get_records(member.guild, LoggingType.MEMBER_SCREENING_COMPLETE): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - arg = False if record['format'] == "minimal without timestamp" else True - message = modlogformats.MinimalisticFormat.completed_screening(member, with_timestamp=arg) - await emitter.put(message) - elif record['format'] == "emoji": - message = modlogformats.EmojiFormat.completed_screening(member) - await emitter.put(message, allowed_mentions=discord.AllowedMentions(users=[member])) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.completed_screening(member) - await emitter.put(embed=embed) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "completed_screening", member, **kwargs) + await self._emit(emitter, fmt_name, result, mentions=[member]) async def _log_role_changes(self, ltype: LoggingType, event: MemberRolesUpdateEvent) -> None: async for emitter, record in self.get_records(event.guild.id, ltype): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - arg = False if record['format'] == "minimal without timestamp" else True - message = modlogformats.MinimalisticFormat.role_change(event, - with_timestamp=arg) - await emitter.send(message) - elif record['format'] == "emoji": - message = modlogformats.EmojiFormat.role_change(event) - await emitter.put(message) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.role_change(event) - await emitter.put(embed=embed) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "role_change", event, **kwargs) + await self._emit(emitter, fmt_name, result) @LightningCog.listener() async def on_lightning_member_role_change(self, event: MemberRolesUpdateEvent): @@ -315,49 +279,25 @@ async def on_lightning_member_role_change(self, event: MemberRolesUpdateEvent): async def on_lightning_member_nick_change(self, event: MemberUpdateEvent): guild = event.guild async for emitter, record in self.get_records(guild, LoggingType.MEMBER_NICK_CHANGE): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - arg = False if record['format'] == "minimal without timestamp" else True - message = modlogformats.MinimalisticFormat.nick_change(event.after, event.before.nick, event.after.nick, - event.moderator, with_timestamp=arg) - await emitter.put(message) - elif record['format'] == "emoji": - message = modlogformats.EmojiFormat.nick_change(event.after, event.before.nick, event.after.nick, - event.moderator) - await emitter.put(message, allowed_mentions=discord.AllowedMentions(users=[event.after])) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.nick_change(event.after, event.before.nick, event.after.nick, - event.moderator) - await emitter.put(embed=embed) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "nick_change", event.after, event.before.nick, + event.after.nick, event.moderator, **kwargs) + await self._emit(emitter, fmt_name, result, mentions=[event.after]) @LightningCog.listener() async def on_lightning_infraction_update(self, event: InfractionUpdateEvent): async for emitter, record in self.get_records(event.after.guild, LoggingType.INFRACTION_UPDATE): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - arg = record['format'] != "minimal without timestamp" - message = modlogformats.MinimalisticFormat.infraction_update(event, with_timestamp=arg) - await emitter.put(message) - elif record['format'] == "emoji": - message = modlogformats.EmojiFormat.infraction_update(event) - await emitter.put(message) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.infraction_update(event) - await emitter.put(embed=embed) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "infraction_update", event, **kwargs) + await self._emit(emitter, fmt_name, result) @LightningCog.listener() async def on_lightning_infraction_delete(self, event: InfractionDeleteEvent): details = event.format_infraction() async for emitter, record in self.get_records(event.moderator.guild, LoggingType.INFRACTION_DELETE): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - arg = record['format'] != "minimal without timestamp" - message = modlogformats.MinimalisticFormat.infraction_delete(event, with_timestamp=arg) - await emitter.put(message, embed=details) - elif record['format'] == "emoji": - message = modlogformats.EmojiFormat.infraction_delete(event) - await emitter.put(message, embed=details, - allowed_mentions=discord.AllowedMentions(users=[event.moderator])) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.infraction_delete(event) - await emitter.put(embeds=[embed, details]) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "infraction_delete", event, **kwargs) + await self._emit(emitter, fmt_name, result, extra_embeds=[details], mentions=[event.moderator]) @LightningCog.listener() async def on_lightning_member_timeout_remove(self, event: AuditLogTimeoutEvent | MemberUpdateEvent): @@ -365,18 +305,9 @@ async def on_lightning_member_timeout_remove(self, event: AuditLogTimeoutEvent | await self.bot.pool.execute(query, event.guild.id, event.member.id) async for emitter, record in self.get_records(event.guild, LoggingType.MEMBER_TIMEOUT_REMOVE): - if record['format'] in ("minimal with timestamp", "minimal without timestamp"): - arg = record['format'] != "minimal without timestamp" - message = modlogformats.MinimalisticFormat.timeout_expired(event, - with_timestamp=arg) - await emitter.put(message) - elif record['format'] == "emoji": - message = modlogformats.EmojiFormat.timeout_expired(event) - await emitter.put(message, - allowed_mentions=discord.AllowedMentions(users=[event.member])) - elif record['format'] == "embed": - embed = modlogformats.EmbedFormat.timeout_expired(event) - await emitter.put(embeds=[embed]) + fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) + result = modlogformats.format_event(fmt_name, "timeout_expired", event, **kwargs) + await self._emit(emitter, fmt_name, result, mentions=[event.member]) @LightningCog.listener() async def on_lightning_guild_alert(self, guild_id: int, message: str): diff --git a/lightning/utils/modlogformats.py b/lightning/utils/modlogformats.py deleted file mode 100644 index cd7da01d..00000000 --- a/lightning/utils/modlogformats.py +++ /dev/null @@ -1,613 +0,0 @@ -""" -Lightning.py - A Discord bot -Copyright (C) 2019-present LightSage - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published -by the Free Software Foundation at version 3 of the License. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -""" -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Optional, Union - -import discord - -from lightning.formatters import truncate_text -from lightning.models import Action -from lightning.utils.helpers import Emoji -from lightning.utils.time import get_utc_timestamp, natural_timedelta - -if TYPE_CHECKING: - from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, - InfractionUpdateEvent, - MemberRolesUpdateEvent, MemberUpdateEvent) - - -class BaseFormat: - def __init__(self, log_action, target, moderator, infraction_id, reason=None, *, expiry=None, **kwargs): - self.log_action = log_action - self.target = target - self.moderator = moderator - self.infraction_id = infraction_id - self.reason = reason or "no reason given" - self.timestamp = kwargs.pop("timestamp", discord.utils.utcnow()) - self.expiry: datetime | None = expiry - self.kwargs = kwargs - - @classmethod - def from_action(cls, action: Action): - if not action.is_logged(): - raise # TODO - - return cls(action.action, action.target, action.moderator, action.infraction_id, action.reason, - timestamp=action.timestamp, expiry=action.expiry, **action.kwargs) - - def format_message(self): - raise NotImplementedError - - -@dataclass -class CompactModAction: - tense: str - emoji: str - title: str - color: Union[discord.Color, int, hex] - - -log_actions = { - "ban": CompactModAction("banned", "\N{NO ENTRY}", "Ban", 0xFF0000), - "kick": CompactModAction("kicked", "\N{WOMANS BOOTS}", "Kick", 0xFF7F50), - "warn": CompactModAction("warned", "\N{WARNING SIGN}", "Warn", 0xFFDF00), - "temprole": CompactModAction("temporarily restricted role to", "\N{TIMER CLOCK}\N{VARIATION SELECTOR-16}", - "Temporary Role", 0x607d8b), - "unban": CompactModAction("unbanned", "\N{WARNING SIGN}", "Unban", 0xB22222), - "timed_restriction_removed": CompactModAction("", "\N{WARNING SIGN}", "Timed restriction expired", 0x6B8E23), - "timeban": CompactModAction("temporarily banned", "\N{NO ENTRY}", "Timed Ban", 0xC7031E), - "mute": CompactModAction("muted", "\N{SPEAKER WITH CANCELLATION STROKE}", "Mute", 0x7c7b82), - "timemute": CompactModAction("temporarily muted", "\N{SPEAKER WITH CANCELLATION STROKE}", "Timed Mute", 0x7c7b82), - "unmute": CompactModAction("umuted", "\N{SPEAKER}", "Unmute", 0xFFFFFF), - "timeout": CompactModAction("timed out", "\N{SPEAKER WITH CANCELLATION STROKE}", "Timeout", 0x7c7b82) -} - - -def construct_dm_message(member, action_verb, location, *, middle=None, reason=None, ending=None): - msg = [f"You were {action_verb} {location} {member.guild.name}"] - if middle: - msg.append(middle) - if reason: - msg.append(f"\n\n**Reason**: {reason}") - if ending: - msg.append(f"\n{ending}") - return ''.join(msg) - - -class EmojiFormat(BaseFormat): - def target_mention_and_safe_name(self): - if self.target is None: - return "" - if hasattr(self.target, 'mention'): - mention = self.target.mention - else: - mention = f"<@!{self.target.id}>" - if isinstance(self.target, discord.Object): - return mention - return f"{mention} | {discord.utils.escape_markdown(str(self.target))}" - - def temp_action_target(self, expiry): - if hasattr(self.target, 'mention'): - mention = self.target.mention - else: - mention = f"<@!{self.target.id}>" - if isinstance(self.target, discord.Object): - return mention - return f"{mention} for {expiry} | {discord.utils.escape_markdown(str(self.target))}" - - def format_message(self) -> str: - attrs = log_actions[str(self.log_action).lower()] - message = [f"{attrs.emoji} **{attrs.title}**: {self.moderator.mention}" - f" {attrs.tense} "] - - if self.expiry: - message.append(self.temp_action_target(self.expiry)) - else: - message.append(self.target_mention_and_safe_name()) - - if hasattr(self.target, 'id'): - message.append(f"\n\N{LABEL} __User ID__: {self.target.id}") - - message.append(f"\n\N{PENCIL}\N{VARIATION SELECTOR-16} __Reason__: \"{self.reason}\"") - return ''.join(message) - - @staticmethod - def bot_addition(bot: discord.Member, mod) -> str: - return f"\N{ROBOT FACE} **Bot Add** {mod.mention} added bot {bot.mention} | "\ - f"{escape_markdown_and_mentions(str(bot))}" - - @staticmethod - def completed_screening(member: discord.Member): - return f"\N{PASSPORT CONTROL} **Member Completed Screening** {member.mention} | "\ - f"({escape_markdown_and_mentions(str(member))}" - - @staticmethod - def role_change(event: MemberRolesUpdateEvent): - if event.entry: - removed = event.entry.changes.before.roles - added = event.entry.changes.after.roles - else: - removed = event.removed_roles - added = event.added_roles - - msg = ["\n👑__Role change__: "] - if len(added) != 0 or len(removed) != 0: - roles = [] - for role in removed: - roles.append(f"_~~{escape_markdown_and_mentions(role.name)}~~_") - - for role in added: - roles.append(f"__**{escape_markdown_and_mentions(role.name)}**__") - - for role in event.after.roles: - if role.name == "@everyone": - continue - - if role not in added and role not in removed: - roles.append(escape_markdown_and_mentions(role.name)) - - msg.append(", ".join(roles)) - msg = [f"\N{INFORMATION SOURCE} **Member update**: {escape_markdown_and_mentions(str(event.after))} | " - f"{event.after.id} {''.join(msg)}"] - - if event.moderator: - msg.append(f"\n\N{BLUE BOOK} __Moderator__: " - f"{escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") - - return ''.join(msg) - - @staticmethod - def timed_action_expired(action, user, mod, creation) -> str: - msg = [f"\N{WARNING SIGN} **{action.capitalize()} expired**: <@!{user.id}>"] - - if hasattr(user, 'name'): - msg.append(f" | {discord.utils.escape_mentions(str(user))}") - - msg.append(f"\nTime{action} was made by <@!{mod.id}>") - - if hasattr(mod, 'name'): - msg.append(f" | {discord.utils.escape_mentions(str(mod))}") - - msg.append(f" at {get_utc_timestamp(creation)}") - return ''.join(msg) - - @staticmethod - def join_leave(log_type: str, member) -> str: - safe_name = escape_markdown_and_mentions(str(member)) - if log_type == "MEMBER_JOIN": - msg = f"{Emoji.member_join}"\ - f" **Member Join**: {member.mention} | "\ - f"{safe_name}\n"\ - f"\N{CLOCK FACE FOUR OCLOCK} __Account Creation__: {discord.utils.format_dt(member.created_at)}\n"\ - f"\N{LABEL} __User ID__: {member.id}" - else: - msg = f"{Emoji.member_leave} "\ - f"**Member Leave**: {member.mention} | "\ - f"{safe_name}\n"\ - f"\N{LABEL} __User ID__: {member.id}" - return msg - - @staticmethod - def command_ran(ctx) -> str: - command = ctx.command - msg = f"\N{CLIPBOARD} **Command Used**: {ctx.author.mention} ran `{command.qualified_name}`\n"\ - f"__Channel__: {ctx.channel.mention} | {ctx.channel.name}" - return msg - - @staticmethod - def nick_change(member, previous, current, moderator=None): - if previous is None and current is not None: - msg = f"\N{LABEL} __Nickname added__: None -> {current}" - elif previous is not None and current is not None: - msg = f"\N{LABEL} __Nickname changed__: {previous} -> {current}" - elif previous is not None and current is None: - msg = f"\N{LABEL} __Nickname removed__: {previous} -> None" - - msg = [f"\N{INFORMATION SOURCE} **Member update**: {member} | " - f"{member.id} {msg}"] - - if moderator: - safe_mod = escape_markdown_and_mentions(str(moderator)) - msg.append(f"\n\N{BLUE BOOK} __Moderator__: " - f"{safe_mod} ({moderator.id})") - - return ''.join(msg) - - @staticmethod - def infraction_update(event: InfractionUpdateEvent) -> str: - base = [f"\N{MEMO} **Infraction update**: ID: {event.after.id}"] - - if event.before.moderator_id != event.after.moderator_id: - base.append(f"\n__Old Moderator__: {escape_markdown_and_mentions(event.before.moderator)}" - f"\n__New Moderator__: {escape_markdown_and_mentions(event.after.moderator)}") - - if event.before.reason != event.after.reason: - base.append(f"\n__Old Reason__: {truncate_text(event.before.reason, limit=200)}" - f"\n__New Reason__: {truncate_text(event.after.reason, limit=200)}") - - return ''.join(base) - - @staticmethod - def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent) -> str: - text = [f"\N{WARNING SIGN} **Timeout expired** <@!{event.member.id}>"] - - if hasattr(event, 'moderator'): - text.append(f"\N{BLUE BOOK} __Moderator__: " - f"{escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") - - return ''.join(text) - - @staticmethod - def infraction_delete(event: InfractionDeleteEvent): - msg = f"\N{PUT LITTER IN ITS PLACE SYMBOL} **Infraction deleted** "\ - f"{event.moderator.mention} deleted #{event.infraction.id}" - return msg - - -def escape_markdown_and_mentions(text) -> str: - """Helper function to escape mentions and markdown from a string - - Parameters - ---------- - text : str - The string to remove markdown and mentions from - - Returns - ------- - str - The escaped text - """ - escaped_markdown = discord.utils.escape_markdown(text) - return discord.utils.escape_mentions(escaped_markdown) - - -def action_format(author, action_text="Action done by", *, reason=None) -> str: - if reason is None: - return f"{action_text} {str(author)} (ID: {author.id})" - else: - return f"{str(author)} (ID: {author.id}): {reason}" - - -def base_user_format(user, unknown_text="Unknown user with ID: ") -> str: - if hasattr(user, 'name'): - return f"{escape_markdown_and_mentions(str(user))} ({user.id})" - else: - return f"{unknown_text}{user.id if hasattr(user, 'id') else user}" - - -def format_timestamp(dt: datetime): - return discord.utils.format_dt(dt, style="T") - - -class MinimalisticFormat(BaseFormat): - @staticmethod - def format_user(user) -> str: - if hasattr(user, 'name'): - return f"{escape_markdown_and_mentions(str(user))} ({user.id})" - else: - return f"ID: {user.id}" - - @staticmethod - def role_change(event: MemberRolesUpdateEvent, *, with_timestamp=True) -> str: - time = event.entry.created_at if event.entry else discord.utils.utcnow() - - if with_timestamp: - base = [f"[{format_timestamp(time)}] **Role Change**\n" - f"**User**: {escape_markdown_and_mentions(str(event.after))} ({event.after.id})\n"] - else: - base = ["**Role Change**\n**User**:" - f"{escape_markdown_and_mentions(str(event.after))} ({event.after.id})\n"] - - if event.added_roles != 0: - for role in event.added_roles: - base.append(f"**Role Added**: {escape_markdown_and_mentions(str(role))} ({role.id})\n") - if event.removed_roles != 0: - for role in event.removed_roles: - base.append(f"**Role Removed**: {escape_markdown_and_mentions(str(role))} ({role.id})\n") - - if event.moderator is not None: - base.append(f"**Moderator**: {escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") - - if event.reason: - base.append(f"\n**Reason**: {escape_markdown_and_mentions(event.reason)}") - - return ''.join(base) - - @staticmethod - def timed_action_expired(action, user, mod, creation, expiry, *, with_timestamp: bool = True) -> str: - text = [f"[{format_timestamp(expiry)}] "] if with_timestamp else [] - - text.append(f"**{action.capitalize()} expired**\n**User**: " - f"{MinimalisticFormat.format_user(user)}\n**Moderator**: {MinimalisticFormat.format_user(mod)}" - f"\n**Created at**: {discord.utils.format_dt(creation)}") - return ''.join(text) - - @staticmethod - def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent, *, with_timestamp: bool = True) -> str: - text = [f"[{format_timestamp(datetime.now(timezone.utc))}] "] if with_timestamp else [] - - text.append(f"**Timeout expired**\n**User**: {MinimalisticFormat.format_user(event.member)}\n") - - if hasattr(event, 'moderator'): - text.append(f"**Moderator**: {MinimalisticFormat.format_user(event.moderator)}") - - return ''.join(text) - - @staticmethod - def bot_addition(bot, mod, time) -> str: - safe_name = escape_markdown_and_mentions(str(bot)) - safe_mod_name = escape_markdown_and_mentions(str(mod)) - return f"[{format_timestamp(time)}] **Bot Add**"\ - f"\n**Bot**: {safe_name} ({bot.id})\n"\ - f"**Moderator**: {safe_mod_name} ({mod.id})" - - @staticmethod - def join_leave(log_type: str, member) -> str: - if log_type == "MEMBER_JOIN": - msg = f"[{format_timestamp(member.joined_at)}]"\ - f" **Member Join**: {discord.utils.escape_markdown(str(member))} ({member.id})\n"\ - "__Account Creation Date__: "\ - f"{discord.utils.format_dt(member.created_at)}" - else: - msg = f"[{format_timestamp(discord.utils.utcnow())}]"\ - f" **Member Leave**: {discord.utils.escape_markdown(str(member))} ({member.id})" - return msg - - @staticmethod - def completed_screening(member, *, with_timestamp: bool = True): - if with_timestamp: - base = [f"[{format_timestamp(discord.utils.utcnow())}] "] - else: - base = [] - - base.append(f"**Member Passed Screening**: {discord.utils.escape_markdown(str(member))} ({member.id})") - return ''.join(base) - - @staticmethod - def command_ran(ctx, *, with_timestamp: bool = True) -> str: - if with_timestamp: - base = [f"[{format_timestamp(ctx.message.created_at)}] "] - else: - base = [] - - base.append(f"**Command Ran**\n**Command**: {ctx.command.qualified_name}\n**User**: " - f"{MinimalisticFormat.format_user(ctx.author)}\n" - f"**Channel**: {base_user_format(ctx.channel)}") - - return ''.join(base) - - @staticmethod - def nick_change(member, previous: str, current: Optional[str], moderator=None, *, with_timestamp: bool = True): - if with_timestamp: - base = [f"[{format_timestamp(discord.utils.utcnow())}] "] - else: - base = [] - - if current and previous: - base.append(f"**Member Nickname Update**\n**Old Nickname**: {previous}\n**New Nickname**: {current}") - elif previous is None and current is not None: - base.append(f"**Member Nickname Add**\n**New Nickname**: {current}") - elif current is None and previous is not None: - base.append(f"**Member Nickname Removed**\n**Old Nickname**: {previous}") - - base.append(f"\n**Member**: {MinimalisticFormat.format_user(member)}") - - if moderator: - base.append(f"\n**Moderator**: {MinimalisticFormat.format_user(moderator)}") - - return ''.join(base) - - @staticmethod - def infraction_update(event: InfractionUpdateEvent, *, - with_timestamp: bool = True) -> str: - if with_timestamp: - base = [f"[{format_timestamp(discord.utils.utcnow())}] **Infraction Update**\n**ID**: {event.after.id}"] - else: - base = [f"**Infraction Update**\n**ID**: {event.after.id}"] - - if event.before.moderator_id != event.after.moderator_id: - base.append(f"\n**Old Moderator**: {MinimalisticFormat.format_user(event.before.moderator)}" - f"\n**New Moderator**: {MinimalisticFormat.format_user(event.after.moderator)}") - - if event.before.reason != event.after.reason: - base.append(f"\n**Old Reason**: {truncate_text(event.before.reason, limit=200)}" - f"\n**New Reason**: {truncate_text(event.after.reason, limit=200)}") - - return ''.join(base) - - @staticmethod - def infraction_delete(event: InfractionDeleteEvent, *, with_timestamp: bool = True): - if with_timestamp: - base = [f"[{format_timestamp(discord.utils.utcnow())}] "] - else: - base = [] - - base.append(f"**Infraction Delete**\n**ID**: {event.infraction.id}\n" - f"**Moderator**: {MinimalisticFormat.format_user(event.moderator)}") - - return ''.join(base) - - def format_message(self, *, with_timestamp: bool = True) -> str: - """Formats a log entry.""" - log_action = log_actions[str(self.log_action).lower()] - - if with_timestamp: - base = [f"[{format_timestamp(self.timestamp)}] "] - else: - base = [] - - base.append(f"**{log_action.title}** | Infraction ID {self.infraction_id}" - f"\n**User**: {self.format_user(self.target)}\n**Moderator**: {self.format_user(self.moderator)}") - - if self.expiry: - if isinstance(self.expiry, datetime): - # TZINFO should not be stripped at this point. - expy = discord.utils.format_dt(self.expiry) - else: - expy = self.expiry - - base.append(f"\n**Expiry**: {expy}") - - base.append(f"\n**Reason**: {escape_markdown_and_mentions(self.reason)}") - return ''.join(base) - - -class EmbedFormat(BaseFormat): - @staticmethod - def join_leave(log_type: str, member: discord.Member): - embed = discord.Embed() - - if log_type == "MEMBER_JOIN": - embed.title = "Member Join" - embed.color = discord.Color.green() - else: - embed.title = "Member Leave" - embed.color = discord.Color.red() - - embed.set_author(name=member, icon_url=member.display_avatar.url) - embed.description = f"**User**: {member.mention} ({member.id}) \n**Created at**: "\ - f"{natural_timedelta(member.created_at)}" - return embed - - def format_message(self) -> discord.Embed: - action = log_actions[str(self.log_action).lower()] - embed = discord.Embed(title=action.title, color=action.color) - reason = truncate_text(self.reason, 512) - - base = [f"**User**: {base_user_format(self.target)} <@!{self.target.id}>\n" - f"**Moderator**: {base_user_format(self.moderator)} <@!{self.moderator.id}>"] - - if self.expiry: - base.append(f"\n**Expiry**: {self.expiry}") - - base.append(f"\n**Reason**: {discord.utils.escape_markdown(reason)}") - embed.description = ''.join(base) - embed.set_footer(text=f"Infraction ID: {self.infraction_id}") - embed.timestamp = self.timestamp - return embed - - @staticmethod - def timed_action_expired(action, moderator, user, created_at) -> discord.Embed: - embed = discord.Embed(description=f"Time {action} for {base_user_format(user)} expired") - embed.add_field(name="Moderator", value=base_user_format(moderator)) - embed.timestamp = created_at - embed.set_footer(text=f"Time {action} was made at") - return embed - - @staticmethod - def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent) -> discord.Embed: - embed = discord.Embed(description=f"Timeout for {base_user_format(event.member)} expired") - - if hasattr(event, 'moderator'): - embed.add_field(name="Moderator", value=base_user_format(event.moderator)) - - return embed - - @staticmethod - def role_change(event: MemberRolesUpdateEvent) -> discord.Embed: - embed = discord.Embed(title="Role Change", color=discord.Color.dark_gold(), - description=f"User: {event.after.mention} ({event.after.id})") - - if event.entry: - removed = event.entry.changes.before.roles - added = event.entry.changes.after.roles - time = event.entry.created_at - else: - added = event.added_roles - removed = event.removed_roles - time = discord.utils.utcnow() - - if event.moderator: - embed.add_field(name="Moderator", value=base_user_format(event.moderator)) - - if added: - added = "".join(r.mention for r in added) - embed.description += f"\nAdded: {added}" - - if removed: - removed = "".join(r.mention for r in removed) - embed.description += f"\nRemoved: {removed}" - - embed.timestamp = time - return embed - - @staticmethod - def role_addition(event: MemberRolesUpdateEvent): - return EmbedFormat.role_change(event) - - @staticmethod - def command_ran(ctx) -> discord.Embed: - embed = discord.Embed(title="Command Ran", color=0xf74b06, timestamp=ctx.message.created_at) - user = ctx.author - embed.description = f"**Command**: {ctx.command.qualified_name}\n**User**: {user.mention} ({user.id})"\ - f"\n**Channel**: {ctx.channel.mention} ({ctx.channel.id})" - return embed - - @staticmethod - def nick_change(member, previous: str, current: Optional[str], moderator=None) -> discord.Embed: - embed = discord.Embed(color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) - - if current and previous: - embed.title = "Member Nickname Update" - embed.description = f"**Old Nickname**: {previous}\n**New Nickname**: {current}" - elif previous is None and current is not None: - embed.title = "Member Nickname Add" - embed.description = f"**New Nickname**: {current}" - elif current is None and previous is not None: - embed.title = "Member Nickname Removed" - embed.description = f"**Old Nickname**: {previous}" - - embed.description += f"\n**Member**: {base_user_format(member)}" - - if moderator: - embed.add_field(name="Moderator", value=base_user_format(moderator)) - - return embed - - @staticmethod - def completed_screening(member: discord.Member): - return discord.Embed(title="Member Passed Screening", description=f"**Member**: {base_user_format(member)}", - color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) - - @staticmethod - def infraction_update(event: InfractionUpdateEvent) -> discord.Embed: - embed = discord.Embed(color=discord.Color.yellow(), timestamp=discord.utils.utcnow(), title="Infraction Update") - embed.set_footer(text=f"Infraction ID: {event.after.id}") - - if event.before.moderator_id != event.after.moderator_id: - embed.add_field(name="Moderator", - value=f"**Old**: {base_user_format(event.before.moderator)}\n**New**: " - f"{base_user_format(event.after.moderator)}") - - if event.before.reason != event.after.reason: - embed.add_field(name="Reason", - value=f"**Old**: {truncate_text(event.before.reason, limit=200)}\n**New**: " - f"{truncate_text(event.after.reason, limit=200)}") - - return embed - - @staticmethod - def infraction_delete(event: InfractionDeleteEvent) -> discord.Embed: - embed = discord.Embed(color=discord.Color.red(), - title="Infraction Delete", - description=f"**ID**: {event.infraction.id}\n**Moderator**: " - f"{base_user_format(event.moderator)}") - - return embed diff --git a/lightning/utils/modlogformats/__init__.py b/lightning/utils/modlogformats/__init__.py new file mode 100644 index 00000000..d183016c --- /dev/null +++ b/lightning/utils/modlogformats/__init__.py @@ -0,0 +1,34 @@ +""" +Lightning.py - A Discord bot +Copyright (C) 2019-present LightSage + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation at version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from lightning.utils.modlogformats.base import (BaseFormat, CompactModAction, + action_format, + base_user_format, + construct_dm_message, + escape_markdown_and_mentions, + format_event, format_timestamp, + log_actions, modlog_format, + parse_format_setting) +# Importing these is what registers them with @modlog_format +from lightning.utils.modlogformats.embed import EmbedFormat +from lightning.utils.modlogformats.emoji import EmojiFormat +from lightning.utils.modlogformats.minimal import MinimalisticFormat + +__all__ = ( + "BaseFormat", "CompactModAction", "EmbedFormat", "EmojiFormat", "MinimalisticFormat", + "action_format", "base_user_format", "construct_dm_message", "escape_markdown_and_mentions", + "format_event", "format_timestamp", "log_actions", "modlog_format", "parse_format_setting" +) diff --git a/lightning/utils/modlogformats/base.py b/lightning/utils/modlogformats/base.py new file mode 100644 index 00000000..2ebc001f --- /dev/null +++ b/lightning/utils/modlogformats/base.py @@ -0,0 +1,401 @@ +""" +Lightning.py - A Discord bot +Copyright (C) 2019-present LightSage + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation at version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import (TYPE_CHECKING, Any, Callable, Literal, Optional, Type, + TypeVar, Union, overload) + +import discord + +from lightning.models import Action + +if TYPE_CHECKING: + from lightning import LightningContext + from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, + InfractionUpdateEvent, + MemberRolesUpdateEvent, MemberUpdateEvent) + +FormatName = Literal["emoji", "minimal", "embed"] + + +class BaseFormat: + def __init__(self, log_action, target, moderator, infraction_id, reason=None, *, expiry=None, **kwargs): + self.log_action = log_action + self.target = target + self.moderator = moderator + self.infraction_id = infraction_id + self.reason = reason or "no reason given" + self.timestamp = kwargs.pop("timestamp", discord.utils.utcnow()) + self.expiry: datetime | None = expiry + self.kwargs = kwargs + + @classmethod + def from_action(cls, action: Action): + if not action.is_logged(): + raise # TODO + + return cls(action.action, action.target, action.moderator, action.infraction_id, action.reason, + timestamp=action.timestamp, expiry=action.expiry, **action.kwargs) + + def format_message(self): + raise NotImplementedError + + +@dataclass +class CompactModAction: + tense: str + emoji: str + title: str + color: Union[discord.Color, int, hex] + + +log_actions = { + "ban": CompactModAction("banned", "\N{NO ENTRY}", "Ban", 0xFF0000), + "kick": CompactModAction("kicked", "\N{WOMANS BOOTS}", "Kick", 0xFF7F50), + "warn": CompactModAction("warned", "\N{WARNING SIGN}", "Warn", 0xFFDF00), + "temprole": CompactModAction("temporarily restricted role to", "\N{TIMER CLOCK}\N{VARIATION SELECTOR-16}", + "Temporary Role", 0x607d8b), + "unban": CompactModAction("unbanned", "\N{WARNING SIGN}", "Unban", 0xB22222), + "timed_restriction_removed": CompactModAction("", "\N{WARNING SIGN}", "Timed restriction expired", 0x6B8E23), + "timeban": CompactModAction("temporarily banned", "\N{NO ENTRY}", "Timed Ban", 0xC7031E), + "mute": CompactModAction("muted", "\N{SPEAKER WITH CANCELLATION STROKE}", "Mute", 0x7c7b82), + "timemute": CompactModAction("temporarily muted", "\N{SPEAKER WITH CANCELLATION STROKE}", "Timed Mute", 0x7c7b82), + "unmute": CompactModAction("umuted", "\N{SPEAKER}", "Unmute", 0xFFFFFF), + "timeout": CompactModAction("timed out", "\N{SPEAKER WITH CANCELLATION STROKE}", "Timeout", 0x7c7b82) +} + + +def construct_dm_message(member, action_verb, location, *, middle=None, reason=None, ending=None): + msg = [f"You were {action_verb} {location} {member.guild.name}"] + if middle: + msg.append(middle) + if reason: + msg.append(f"\n\n**Reason**: {reason}") + if ending: + msg.append(f"\n{ending}") + return ''.join(msg) + + +def escape_markdown_and_mentions(text) -> str: + """Helper function to escape mentions and markdown from a string + + Parameters + ---------- + text : str + The string to remove markdown and mentions from + + Returns + ------- + str + The escaped text + """ + escaped_markdown = discord.utils.escape_markdown(text) + return discord.utils.escape_mentions(escaped_markdown) + + +def action_format(author, action_text="Action done by", *, reason=None) -> str: + if reason is None: + return f"{action_text} {str(author)} (ID: {author.id})" + else: + return f"{str(author)} (ID: {author.id}): {reason}" + + +def base_user_format(user, unknown_text="Unknown user with ID: ") -> str: + if hasattr(user, 'name'): + return f"{escape_markdown_and_mentions(str(user))} ({user.id})" + else: + return f"{unknown_text}{user.id if hasattr(user, 'id') else user}" + + +def format_timestamp(dt: datetime): + return discord.utils.format_dt(dt, style="T") + + +# Registry time! Each format class tags itself with @modlog_format("name") so we +# never have to hand-maintain a lookup table, and the cog doesn't need to know +# which classes exist. +_formats: dict[str, Type[BaseFormat]] = {} +_F = TypeVar("_F", bound=Type[BaseFormat]) + + +def modlog_format(name: FormatName) -> Callable[[_F], _F]: + def decorator(cls: _F) -> _F: + _formats[name] = cls + return cls + return decorator + + +def parse_format_setting(setting: str) -> tuple[FormatName, dict[str, bool]]: + """Turns the format we store in the db into a format name + the kwargs it accepts. + + Only the minimal format cares about timestamps, so that's the only one that gets a kwarg. + """ + if setting in ("minimal with timestamp", "minimal without timestamp"): + return "minimal", {"with_timestamp": setting != "minimal without timestamp"} + return setting, {} # type: ignore[return-value] + + +# The overloads below are purely for type checkers. They spell out exactly what each +# format wants for each event (they're not all the same, ex. timed_action_expired), +# so passing with_timestamp to emoji or embed gets flagged. The real function at the +# bottom just looks the method up by name. + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["command_ran"], + ctx: LightningContext, + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["command_ran"], + ctx: LightningContext) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["command_ran"], + ctx: LightningContext) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["role_change"], + event: MemberRolesUpdateEvent, + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["role_change"], + event: MemberRolesUpdateEvent) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["role_change"], + event: MemberRolesUpdateEvent) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["nick_change"], + member: discord.Member, + previous: str, + current: Optional[str], + moderator: Any = ..., + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["nick_change"], + member: discord.Member, + previous: str, + current: Optional[str], + moderator: Any = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["nick_change"], + member: discord.Member, + previous: str, + current: Optional[str], + moderator: Any = ...) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["timed_action_expired"], + action: str, + user: Any, + mod: Any, + creation: datetime, + expiry: datetime, + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["timed_action_expired"], + action: str, + user: Any, + mod: Any, + creation: datetime) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["timed_action_expired"], + action: str, + moderator: Any, + user: Any, + created_at: datetime) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["join_leave"], + log_type: str, + member: discord.Member) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["join_leave"], + log_type: str, + member: discord.Member) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["join_leave"], + log_type: str, + member: discord.Member) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["completed_screening"], + member: discord.Member, + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["completed_screening"], + member: discord.Member) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["completed_screening"], + member: discord.Member) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["timeout_expired"], + event: AuditLogTimeoutEvent | MemberUpdateEvent, + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["timeout_expired"], + event: AuditLogTimeoutEvent | MemberUpdateEvent) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["timeout_expired"], + event: AuditLogTimeoutEvent | MemberUpdateEvent) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["infraction_update"], + event: InfractionUpdateEvent, + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["infraction_update"], + event: InfractionUpdateEvent) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["infraction_update"], + event: InfractionUpdateEvent) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["infraction_delete"], + event: InfractionDeleteEvent, + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["infraction_delete"], + event: InfractionDeleteEvent) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["infraction_delete"], + event: InfractionDeleteEvent) -> discord.Embed: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["bot_addition"], + bot: discord.Member, + mod: Any, + time: datetime) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["bot_addition"], + bot: discord.Member, + mod: Any) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["minimal"], event_name: Literal["from_action"], + action: Action, + *, + with_timestamp: bool = ...) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["emoji"], event_name: Literal["from_action"], + action: Action) -> str: + ... + + +@overload +def format_event(fmt_name: Literal["embed"], event_name: Literal["from_action"], + action: Action) -> discord.Embed: + ... + + +def format_event(fmt_name: FormatName, event_name: str, *args: Any, **kwargs: Any) -> Union[str, discord.Embed]: + cls = _formats[fmt_name] + if event_name == "from_action": + # Actions are formatted through an instance rather than a static method + action, *_ = args + return cls.from_action(action).format_message(**kwargs) + return getattr(cls, event_name)(*args, **kwargs) diff --git a/lightning/utils/modlogformats/embed.py b/lightning/utils/modlogformats/embed.py new file mode 100644 index 00000000..e57cf266 --- /dev/null +++ b/lightning/utils/modlogformats/embed.py @@ -0,0 +1,176 @@ +""" +Lightning.py - A Discord bot +Copyright (C) 2019-present LightSage + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation at version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +import discord + +from lightning.formatters import truncate_text +from lightning.utils.modlogformats.base import (BaseFormat, base_user_format, + log_actions, modlog_format) +from lightning.utils.time import natural_timedelta + +if TYPE_CHECKING: + from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, + InfractionUpdateEvent, + MemberRolesUpdateEvent, MemberUpdateEvent) + + +@modlog_format("embed") +class EmbedFormat(BaseFormat): + @staticmethod + def join_leave(log_type: str, member: discord.Member): + embed = discord.Embed() + + if log_type == "MEMBER_JOIN": + embed.title = "Member Join" + embed.color = discord.Color.green() + else: + embed.title = "Member Leave" + embed.color = discord.Color.red() + + embed.set_author(name=member, icon_url=member.display_avatar.url) + embed.description = f"**User**: {member.mention} ({member.id}) \n**Created at**: "\ + f"{natural_timedelta(member.created_at)}" + return embed + + def format_message(self) -> discord.Embed: + action = log_actions[str(self.log_action).lower()] + embed = discord.Embed(title=action.title, color=action.color) + reason = truncate_text(self.reason, 512) + + base = [f"**User**: {base_user_format(self.target)} <@!{self.target.id}>\n" + f"**Moderator**: {base_user_format(self.moderator)} <@!{self.moderator.id}>"] + + if self.expiry: + base.append(f"\n**Expiry**: {self.expiry}") + + base.append(f"\n**Reason**: {discord.utils.escape_markdown(reason)}") + embed.description = ''.join(base) + embed.set_footer(text=f"Infraction ID: {self.infraction_id}") + embed.timestamp = self.timestamp + return embed + + @staticmethod + def timed_action_expired(action, moderator, user, created_at) -> discord.Embed: + embed = discord.Embed(description=f"Time {action} for {base_user_format(user)} expired") + embed.add_field(name="Moderator", value=base_user_format(moderator)) + embed.timestamp = created_at + embed.set_footer(text=f"Time {action} was made at") + return embed + + @staticmethod + def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent) -> discord.Embed: + embed = discord.Embed(description=f"Timeout for {base_user_format(event.member)} expired") + + if hasattr(event, 'moderator'): + embed.add_field(name="Moderator", value=base_user_format(event.moderator)) + + return embed + + @staticmethod + def role_change(event: MemberRolesUpdateEvent) -> discord.Embed: + embed = discord.Embed(title="Role Change", color=discord.Color.dark_gold(), + description=f"User: {event.after.mention} ({event.after.id})") + + if event.entry: + removed = event.entry.changes.before.roles + added = event.entry.changes.after.roles + time = event.entry.created_at + else: + added = event.added_roles + removed = event.removed_roles + time = discord.utils.utcnow() + + if event.moderator: + embed.add_field(name="Moderator", value=base_user_format(event.moderator)) + + if added: + added = "".join(r.mention for r in added) + embed.description += f"\nAdded: {added}" + + if removed: + removed = "".join(r.mention for r in removed) + embed.description += f"\nRemoved: {removed}" + + embed.timestamp = time + return embed + + @staticmethod + def role_addition(event: MemberRolesUpdateEvent): + return EmbedFormat.role_change(event) + + @staticmethod + def command_ran(ctx) -> discord.Embed: + embed = discord.Embed(title="Command Ran", color=0xf74b06, timestamp=ctx.message.created_at) + user = ctx.author + embed.description = f"**Command**: {ctx.command.qualified_name}\n**User**: {user.mention} ({user.id})"\ + f"\n**Channel**: {ctx.channel.mention} ({ctx.channel.id})" + return embed + + @staticmethod + def nick_change(member, previous: str, current: Optional[str], moderator=None) -> discord.Embed: + embed = discord.Embed(color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) + + if current and previous: + embed.title = "Member Nickname Update" + embed.description = f"**Old Nickname**: {previous}\n**New Nickname**: {current}" + elif previous is None and current is not None: + embed.title = "Member Nickname Add" + embed.description = f"**New Nickname**: {current}" + elif current is None and previous is not None: + embed.title = "Member Nickname Removed" + embed.description = f"**Old Nickname**: {previous}" + + embed.description += f"\n**Member**: {base_user_format(member)}" + + if moderator: + embed.add_field(name="Moderator", value=base_user_format(moderator)) + + return embed + + @staticmethod + def completed_screening(member: discord.Member): + return discord.Embed(title="Member Passed Screening", description=f"**Member**: {base_user_format(member)}", + color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) + + @staticmethod + def infraction_update(event: InfractionUpdateEvent) -> discord.Embed: + embed = discord.Embed(color=discord.Color.yellow(), timestamp=discord.utils.utcnow(), title="Infraction Update") + embed.set_footer(text=f"Infraction ID: {event.after.id}") + + if event.before.moderator_id != event.after.moderator_id: + embed.add_field(name="Moderator", + value=f"**Old**: {base_user_format(event.before.moderator)}\n**New**: " + f"{base_user_format(event.after.moderator)}") + + if event.before.reason != event.after.reason: + embed.add_field(name="Reason", + value=f"**Old**: {truncate_text(event.before.reason, limit=200)}\n**New**: " + f"{truncate_text(event.after.reason, limit=200)}") + + return embed + + @staticmethod + def infraction_delete(event: InfractionDeleteEvent) -> discord.Embed: + embed = discord.Embed(color=discord.Color.red(), + title="Infraction Delete", + description=f"**ID**: {event.infraction.id}\n**Moderator**: " + f"{base_user_format(event.moderator)}") + + return embed diff --git a/lightning/utils/modlogformats/emoji.py b/lightning/utils/modlogformats/emoji.py new file mode 100644 index 00000000..87cc11bf --- /dev/null +++ b/lightning/utils/modlogformats/emoji.py @@ -0,0 +1,204 @@ +""" +Lightning.py - A Discord bot +Copyright (C) 2019-present LightSage + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation at version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import discord + +from lightning.formatters import truncate_text +from lightning.utils.helpers import Emoji +from lightning.utils.modlogformats.base import (BaseFormat, + escape_markdown_and_mentions, + log_actions, modlog_format) +from lightning.utils.time import get_utc_timestamp + +if TYPE_CHECKING: + from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, + InfractionUpdateEvent, + MemberRolesUpdateEvent, MemberUpdateEvent) + + +@modlog_format("emoji") +class EmojiFormat(BaseFormat): + def target_mention_and_safe_name(self): + if self.target is None: + return "" + if hasattr(self.target, 'mention'): + mention = self.target.mention + else: + mention = f"<@!{self.target.id}>" + if isinstance(self.target, discord.Object): + return mention + return f"{mention} | {discord.utils.escape_markdown(str(self.target))}" + + def temp_action_target(self, expiry): + if hasattr(self.target, 'mention'): + mention = self.target.mention + else: + mention = f"<@!{self.target.id}>" + if isinstance(self.target, discord.Object): + return mention + return f"{mention} for {expiry} | {discord.utils.escape_markdown(str(self.target))}" + + def format_message(self) -> str: + attrs = log_actions[str(self.log_action).lower()] + message = [f"{attrs.emoji} **{attrs.title}**: {self.moderator.mention}" + f" {attrs.tense} "] + + if self.expiry: + message.append(self.temp_action_target(self.expiry)) + else: + message.append(self.target_mention_and_safe_name()) + + if hasattr(self.target, 'id'): + message.append(f"\n\N{LABEL} __User ID__: {self.target.id}") + + message.append(f"\n\N{PENCIL}\N{VARIATION SELECTOR-16} __Reason__: \"{self.reason}\"") + return ''.join(message) + + @staticmethod + def bot_addition(bot: discord.Member, mod) -> str: + return f"\N{ROBOT FACE} **Bot Add** {mod.mention} added bot {bot.mention} | "\ + f"{escape_markdown_and_mentions(str(bot))}" + + @staticmethod + def completed_screening(member: discord.Member): + return f"\N{PASSPORT CONTROL} **Member Completed Screening** {member.mention} | "\ + f"({escape_markdown_and_mentions(str(member))}" + + @staticmethod + def role_change(event: MemberRolesUpdateEvent): + if event.entry: + removed = event.entry.changes.before.roles + added = event.entry.changes.after.roles + else: + removed = event.removed_roles + added = event.added_roles + + msg = ["\n👑__Role change__: "] + if len(added) != 0 or len(removed) != 0: + roles = [] + for role in removed: + roles.append(f"_~~{escape_markdown_and_mentions(role.name)}~~_") + + for role in added: + roles.append(f"__**{escape_markdown_and_mentions(role.name)}**__") + + for role in event.after.roles: + if role.name == "@everyone": + continue + + if role not in added and role not in removed: + roles.append(escape_markdown_and_mentions(role.name)) + + msg.append(", ".join(roles)) + msg = [f"\N{INFORMATION SOURCE} **Member update**: {escape_markdown_and_mentions(str(event.after))} | " + f"{event.after.id} {''.join(msg)}"] + + if event.moderator: + msg.append(f"\n\N{BLUE BOOK} __Moderator__: " + f"{escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") + + return ''.join(msg) + + @staticmethod + def timed_action_expired(action, user, mod, creation) -> str: + msg = [f"\N{WARNING SIGN} **{action.capitalize()} expired**: <@!{user.id}>"] + + if hasattr(user, 'name'): + msg.append(f" | {discord.utils.escape_mentions(str(user))}") + + msg.append(f"\nTime{action} was made by <@!{mod.id}>") + + if hasattr(mod, 'name'): + msg.append(f" | {discord.utils.escape_mentions(str(mod))}") + + msg.append(f" at {get_utc_timestamp(creation)}") + return ''.join(msg) + + @staticmethod + def join_leave(log_type: str, member) -> str: + safe_name = escape_markdown_and_mentions(str(member)) + if log_type == "MEMBER_JOIN": + msg = f"{Emoji.member_join}"\ + f" **Member Join**: {member.mention} | "\ + f"{safe_name}\n"\ + f"\N{CLOCK FACE FOUR OCLOCK} __Account Creation__: {discord.utils.format_dt(member.created_at)}\n"\ + f"\N{LABEL} __User ID__: {member.id}" + else: + msg = f"{Emoji.member_leave} "\ + f"**Member Leave**: {member.mention} | "\ + f"{safe_name}\n"\ + f"\N{LABEL} __User ID__: {member.id}" + return msg + + @staticmethod + def command_ran(ctx) -> str: + command = ctx.command + msg = f"\N{CLIPBOARD} **Command Used**: {ctx.author.mention} ran `{command.qualified_name}`\n"\ + f"__Channel__: {ctx.channel.mention} | {ctx.channel.name}" + return msg + + @staticmethod + def nick_change(member, previous, current, moderator=None): + if previous is None and current is not None: + msg = f"\N{LABEL} __Nickname added__: None -> {current}" + elif previous is not None and current is not None: + msg = f"\N{LABEL} __Nickname changed__: {previous} -> {current}" + elif previous is not None and current is None: + msg = f"\N{LABEL} __Nickname removed__: {previous} -> None" + + msg = [f"\N{INFORMATION SOURCE} **Member update**: {member} | " + f"{member.id} {msg}"] + + if moderator: + safe_mod = escape_markdown_and_mentions(str(moderator)) + msg.append(f"\n\N{BLUE BOOK} __Moderator__: " + f"{safe_mod} ({moderator.id})") + + return ''.join(msg) + + @staticmethod + def infraction_update(event: InfractionUpdateEvent) -> str: + base = [f"\N{MEMO} **Infraction update**: ID: {event.after.id}"] + + if event.before.moderator_id != event.after.moderator_id: + base.append(f"\n__Old Moderator__: {escape_markdown_and_mentions(event.before.moderator)}" + f"\n__New Moderator__: {escape_markdown_and_mentions(event.after.moderator)}") + + if event.before.reason != event.after.reason: + base.append(f"\n__Old Reason__: {truncate_text(event.before.reason, limit=200)}" + f"\n__New Reason__: {truncate_text(event.after.reason, limit=200)}") + + return ''.join(base) + + @staticmethod + def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent) -> str: + text = [f"\N{WARNING SIGN} **Timeout expired** <@!{event.member.id}>"] + + if hasattr(event, 'moderator'): + text.append(f"\N{BLUE BOOK} __Moderator__: " + f"{escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") + + return ''.join(text) + + @staticmethod + def infraction_delete(event: InfractionDeleteEvent): + msg = f"\N{PUT LITTER IN ITS PLACE SYMBOL} **Infraction deleted** "\ + f"{event.moderator.mention} deleted #{event.infraction.id}" + return msg diff --git a/lightning/utils/modlogformats/minimal.py b/lightning/utils/modlogformats/minimal.py new file mode 100644 index 00000000..5eb4f9ea --- /dev/null +++ b/lightning/utils/modlogformats/minimal.py @@ -0,0 +1,207 @@ +""" +Lightning.py - A Discord bot +Copyright (C) 2019-present LightSage + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation at version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Optional + +import discord + +from lightning.formatters import truncate_text +from lightning.utils.modlogformats.base import (BaseFormat, base_user_format, + escape_markdown_and_mentions, + format_timestamp, log_actions, + modlog_format) + +if TYPE_CHECKING: + from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, + InfractionUpdateEvent, + MemberRolesUpdateEvent, MemberUpdateEvent) + + +@modlog_format("minimal") +class MinimalisticFormat(BaseFormat): + @staticmethod + def format_user(user) -> str: + if hasattr(user, 'name'): + return f"{escape_markdown_and_mentions(str(user))} ({user.id})" + else: + return f"ID: {user.id}" + + @staticmethod + def role_change(event: MemberRolesUpdateEvent, *, with_timestamp=True) -> str: + time = event.entry.created_at if event.entry else discord.utils.utcnow() + + if with_timestamp: + base = [f"[{format_timestamp(time)}] **Role Change**\n" + f"**User**: {escape_markdown_and_mentions(str(event.after))} ({event.after.id})\n"] + else: + base = ["**Role Change**\n**User**:" + f"{escape_markdown_and_mentions(str(event.after))} ({event.after.id})\n"] + + if event.added_roles != 0: + for role in event.added_roles: + base.append(f"**Role Added**: {escape_markdown_and_mentions(str(role))} ({role.id})\n") + if event.removed_roles != 0: + for role in event.removed_roles: + base.append(f"**Role Removed**: {escape_markdown_and_mentions(str(role))} ({role.id})\n") + + if event.moderator is not None: + base.append(f"**Moderator**: {escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") + + if event.reason: + base.append(f"\n**Reason**: {escape_markdown_and_mentions(event.reason)}") + + return ''.join(base) + + @staticmethod + def timed_action_expired(action, user, mod, creation, expiry, *, with_timestamp: bool = True) -> str: + text = [f"[{format_timestamp(expiry)}] "] if with_timestamp else [] + + text.append(f"**{action.capitalize()} expired**\n**User**: " + f"{MinimalisticFormat.format_user(user)}\n**Moderator**: {MinimalisticFormat.format_user(mod)}" + f"\n**Created at**: {discord.utils.format_dt(creation)}") + return ''.join(text) + + @staticmethod + def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent, *, with_timestamp: bool = True) -> str: + text = [f"[{format_timestamp(datetime.now(timezone.utc))}] "] if with_timestamp else [] + + text.append(f"**Timeout expired**\n**User**: {MinimalisticFormat.format_user(event.member)}\n") + + if hasattr(event, 'moderator'): + text.append(f"**Moderator**: {MinimalisticFormat.format_user(event.moderator)}") + + return ''.join(text) + + @staticmethod + def bot_addition(bot, mod, time) -> str: + safe_name = escape_markdown_and_mentions(str(bot)) + safe_mod_name = escape_markdown_and_mentions(str(mod)) + return f"[{format_timestamp(time)}] **Bot Add**"\ + f"\n**Bot**: {safe_name} ({bot.id})\n"\ + f"**Moderator**: {safe_mod_name} ({mod.id})" + + @staticmethod + def join_leave(log_type: str, member) -> str: + if log_type == "MEMBER_JOIN": + msg = f"[{format_timestamp(member.joined_at)}]"\ + f" **Member Join**: {discord.utils.escape_markdown(str(member))} ({member.id})\n"\ + "__Account Creation Date__: "\ + f"{discord.utils.format_dt(member.created_at)}" + else: + msg = f"[{format_timestamp(discord.utils.utcnow())}]"\ + f" **Member Leave**: {discord.utils.escape_markdown(str(member))} ({member.id})" + return msg + + @staticmethod + def completed_screening(member, *, with_timestamp: bool = True): + if with_timestamp: + base = [f"[{format_timestamp(discord.utils.utcnow())}] "] + else: + base = [] + + base.append(f"**Member Passed Screening**: {discord.utils.escape_markdown(str(member))} ({member.id})") + return ''.join(base) + + @staticmethod + def command_ran(ctx, *, with_timestamp: bool = True) -> str: + if with_timestamp: + base = [f"[{format_timestamp(ctx.message.created_at)}] "] + else: + base = [] + + base.append(f"**Command Ran**\n**Command**: {ctx.command.qualified_name}\n**User**: " + f"{MinimalisticFormat.format_user(ctx.author)}\n" + f"**Channel**: {base_user_format(ctx.channel)}") + + return ''.join(base) + + @staticmethod + def nick_change(member, previous: str, current: Optional[str], moderator=None, *, with_timestamp: bool = True): + if with_timestamp: + base = [f"[{format_timestamp(discord.utils.utcnow())}] "] + else: + base = [] + + if current and previous: + base.append(f"**Member Nickname Update**\n**Old Nickname**: {previous}\n**New Nickname**: {current}") + elif previous is None and current is not None: + base.append(f"**Member Nickname Add**\n**New Nickname**: {current}") + elif current is None and previous is not None: + base.append(f"**Member Nickname Removed**\n**Old Nickname**: {previous}") + + base.append(f"\n**Member**: {MinimalisticFormat.format_user(member)}") + + if moderator: + base.append(f"\n**Moderator**: {MinimalisticFormat.format_user(moderator)}") + + return ''.join(base) + + @staticmethod + def infraction_update(event: InfractionUpdateEvent, *, + with_timestamp: bool = True) -> str: + if with_timestamp: + base = [f"[{format_timestamp(discord.utils.utcnow())}] **Infraction Update**\n**ID**: {event.after.id}"] + else: + base = [f"**Infraction Update**\n**ID**: {event.after.id}"] + + if event.before.moderator_id != event.after.moderator_id: + base.append(f"\n**Old Moderator**: {MinimalisticFormat.format_user(event.before.moderator)}" + f"\n**New Moderator**: {MinimalisticFormat.format_user(event.after.moderator)}") + + if event.before.reason != event.after.reason: + base.append(f"\n**Old Reason**: {truncate_text(event.before.reason, limit=200)}" + f"\n**New Reason**: {truncate_text(event.after.reason, limit=200)}") + + return ''.join(base) + + @staticmethod + def infraction_delete(event: InfractionDeleteEvent, *, with_timestamp: bool = True): + if with_timestamp: + base = [f"[{format_timestamp(discord.utils.utcnow())}] "] + else: + base = [] + + base.append(f"**Infraction Delete**\n**ID**: {event.infraction.id}\n" + f"**Moderator**: {MinimalisticFormat.format_user(event.moderator)}") + + return ''.join(base) + + def format_message(self, *, with_timestamp: bool = True) -> str: + """Formats a log entry.""" + log_action = log_actions[str(self.log_action).lower()] + + if with_timestamp: + base = [f"[{format_timestamp(self.timestamp)}] "] + else: + base = [] + + base.append(f"**{log_action.title}** | Infraction ID {self.infraction_id}" + f"\n**User**: {self.format_user(self.target)}\n**Moderator**: {self.format_user(self.moderator)}") + + if self.expiry: + if isinstance(self.expiry, datetime): + # TZINFO should not be stripped at this point. + expy = discord.utils.format_dt(self.expiry) + else: + expy = self.expiry + + base.append(f"\n**Expiry**: {expy}") + + base.append(f"\n**Reason**: {escape_markdown_and_mentions(self.reason)}") + return ''.join(base) From fd71436066b729987b60588144854a8ca2969c64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:00:03 +0000 Subject: [PATCH 2/8] Use current LICENSE.header in modlogformats package Co-authored-by: celveren <46062298+celveren@users.noreply.github.com> --- lightning/utils/modlogformats/__init__.py | 2 +- lightning/utils/modlogformats/base.py | 2 +- lightning/utils/modlogformats/embed.py | 2 +- lightning/utils/modlogformats/emoji.py | 2 +- lightning/utils/modlogformats/minimal.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lightning/utils/modlogformats/__init__.py b/lightning/utils/modlogformats/__init__.py index d183016c..bc5faefd 100644 --- a/lightning/utils/modlogformats/__init__.py +++ b/lightning/utils/modlogformats/__init__.py @@ -1,6 +1,6 @@ """ Lightning.py - A Discord bot -Copyright (C) 2019-present LightSage +Copyright (C) 2019-present Célveren This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published diff --git a/lightning/utils/modlogformats/base.py b/lightning/utils/modlogformats/base.py index 2ebc001f..187789ed 100644 --- a/lightning/utils/modlogformats/base.py +++ b/lightning/utils/modlogformats/base.py @@ -1,6 +1,6 @@ """ Lightning.py - A Discord bot -Copyright (C) 2019-present LightSage +Copyright (C) 2019-present Célveren This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published diff --git a/lightning/utils/modlogformats/embed.py b/lightning/utils/modlogformats/embed.py index e57cf266..52c78fa7 100644 --- a/lightning/utils/modlogformats/embed.py +++ b/lightning/utils/modlogformats/embed.py @@ -1,6 +1,6 @@ """ Lightning.py - A Discord bot -Copyright (C) 2019-present LightSage +Copyright (C) 2019-present Célveren This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published diff --git a/lightning/utils/modlogformats/emoji.py b/lightning/utils/modlogformats/emoji.py index 87cc11bf..856f983d 100644 --- a/lightning/utils/modlogformats/emoji.py +++ b/lightning/utils/modlogformats/emoji.py @@ -1,6 +1,6 @@ """ Lightning.py - A Discord bot -Copyright (C) 2019-present LightSage +Copyright (C) 2019-present Célveren This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published diff --git a/lightning/utils/modlogformats/minimal.py b/lightning/utils/modlogformats/minimal.py index 5eb4f9ea..941fe7f3 100644 --- a/lightning/utils/modlogformats/minimal.py +++ b/lightning/utils/modlogformats/minimal.py @@ -1,6 +1,6 @@ """ Lightning.py - A Discord bot -Copyright (C) 2019-present LightSage +Copyright (C) 2019-present Célveren This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published From ae9c4d8da3b0e04c64c424e9e1ded968741e1d1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:17:09 +0000 Subject: [PATCH 3/8] Event-keyed modlog renderers replace the per-format static method classes Co-authored-by: celveren <46062298+celveren@users.noreply.github.com> --- lightning/cogs/modlog/cog.py | 83 ++-- lightning/events.py | 43 ++- lightning/formatters.py | 52 +++ lightning/utils/modlogformats/__init__.py | 38 +- lightning/utils/modlogformats/base.py | 408 +++----------------- lightning/utils/modlogformats/commands.py | 47 +++ lightning/utils/modlogformats/embed.py | 176 --------- lightning/utils/modlogformats/emoji.py | 204 ---------- lightning/utils/modlogformats/member.py | 250 ++++++++++++ lightning/utils/modlogformats/minimal.py | 207 ---------- lightning/utils/modlogformats/moderation.py | 275 +++++++++++++ 11 files changed, 775 insertions(+), 1008 deletions(-) create mode 100644 lightning/utils/modlogformats/commands.py delete mode 100644 lightning/utils/modlogformats/embed.py delete mode 100644 lightning/utils/modlogformats/emoji.py create mode 100644 lightning/utils/modlogformats/member.py delete mode 100644 lightning/utils/modlogformats/minimal.py create mode 100644 lightning/utils/modlogformats/moderation.py diff --git a/lightning/cogs/modlog/cog.py b/lightning/cogs/modlog/cog.py index 0d6c7d6a..22ec534a 100644 --- a/lightning/cogs/modlog/cog.py +++ b/lightning/cogs/modlog/cog.py @@ -28,7 +28,9 @@ from lightning.cogs.modlog import ui from lightning.cogs.modlog.utils import human_friendly_log_names from lightning.constants import LIGHTNING_COLOR -from lightning.events import LightningAutoModInfractionEvent +from lightning.events import (CommandEvent, LightningAutoModInfractionEvent, + MemberJoinEvent, MemberLeaveEvent, + MemberScreeningEvent, TimedActionExpiredEvent) from lightning.formatters import truncate_text from lightning.models import LoggingConfig, PartialGuild from lightning.utils import modlogformats @@ -147,11 +149,15 @@ async def get_records(self, guild: Union[discord.Guild, int], feature: int): yield emitter, rec - # Every event ends up here once it's formatted. Text formats get sent as a message, embeds - # get sent as an embed, and anything extra (like the offending message) tags along as embeds. - async def _emit(self, emitter: TextChannelEmitter, fmt_name: str, result: str | discord.Embed, *, - extra_embeds: Optional[List[discord.Embed]] = None, mentions: Optional[list] = None) -> None: + # Every event ends up here. We find the renderer for the event, then send what it gives us as a + # message or an embed. Anything extra (like the offending message) tags along as embeds. + async def _emit(self, emitter: TextChannelEmitter, record, event, *, + extra_embeds: Optional[List[discord.Embed]] = None) -> None: + ctx = modlogformats.FormatContext.from_setting(record['format']) + renderer = modlogformats.get_renderer(event) + result = renderer.render(ctx) extra_embeds = extra_embeds or [] + if isinstance(result, discord.Embed): await emitter.put(embeds=[result, *extra_embeds]) return @@ -160,7 +166,8 @@ async def _emit(self, emitter: TextChannelEmitter, fmt_name: str, result: str | if extra_embeds: kwargs['embeds'] = extra_embeds # Only the emoji format actually pings people, so it's the only one that needs the allowed mentions - if fmt_name == "emoji" and mentions: + mentions = renderer.mentions() + if ctx.fmt == "emoji" and mentions: kwargs['allowed_mentions'] = discord.AllowedMentions(users=mentions) await emitter.put(result, **kwargs) @@ -171,9 +178,7 @@ async def on_command_completion(self, ctx: LightningContext) -> None: return async for emitter, record in self.get_records(ctx.guild, LoggingType.COMMAND_RAN): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "command_ran", ctx, **kwargs) - await self._emit(emitter, fmt_name, result, mentions=[ctx.author]) + await self._emit(emitter, record, CommandEvent(ctx)) async def handle_automod_events(self, event_name: str, event: LightningAutoModInfractionEvent): parts = [] @@ -191,10 +196,7 @@ async def handle_automod_events(self, event_name: str, event: LightningAutoModIn value=truncate_text(content, 1024)) async for emitter, record in self.get_records(event.guild, LoggingType(event_name)): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "from_action", event.action, **kwargs) - await self._emit(emitter, fmt_name, result, extra_embeds=[msg_embed] if msg_embed else [], - mentions=[event.action.target, event.action.moderator]) + await self._emit(emitter, record, event.action, extra_embeds=[msg_embed] if msg_embed else None) # Moderation @LightningCog.listener('on_lightning_member_warn') @@ -215,57 +217,41 @@ async def on_lightning_member_action(self, event: Union[AuditLogModAction, Infra return async for emitter, record in self.get_records(event.guild, LoggingType(event_name)): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "from_action", event.action, **kwargs) - await self._emit(emitter, fmt_name, result, mentions=[event.action.target, event.action.moderator]) + await self._emit(emitter, record, event.action) @LightningCog.listener() async def on_lightning_timed_moderation_action_done(self, action, guild_id, user, moderator, timer): async for emitter, record in self.get_records(guild_id, LoggingType(f"MEMBER_{action.upper()}")): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - # The args are a bit different for each format, so we sort that out here - if fmt_name == "minimal": - args = (action.lower(), user, moderator, timer.created_at, timer.expiry) - elif fmt_name == "embed": - args = (action.lower(), moderator, user, timer.created_at) - else: - args = (action.lower(), user, moderator, timer.created_at) - result = modlogformats.format_event(fmt_name, "timed_action_expired", *args, **kwargs) - await self._emit(emitter, fmt_name, result, mentions=[user, moderator]) + event = TimedActionExpiredEvent(action.lower(), user, moderator, timer.created_at, timer.expiry) + await self._emit(emitter, record, event) # Member events - async def _log_member_join_leave(self, member, event): + async def _log_member_join_leave(self, member, event, event_cls): await self.bot.wait_until_ready() guild = member.guild async for emitter, record in self.get_records(guild, event): - # join_leave has always skipped the timestampless minimal format, so we keep skipping it + # Join/leave has always skipped the timestampless minimal format, so we keep skipping it if record['format'] == "minimal without timestamp": continue - fmt_name, _ = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "join_leave", str(event), member) - await self._emit(emitter, fmt_name, result, mentions=[member]) + await self._emit(emitter, record, event_cls(member)) @LightningCog.listener() async def on_member_join(self, member): - await self._log_member_join_leave(member, LoggingType.MEMBER_JOIN) + await self._log_member_join_leave(member, LoggingType.MEMBER_JOIN, MemberJoinEvent) @LightningCog.listener() async def on_member_remove(self, member): - await self._log_member_join_leave(member, LoggingType.MEMBER_LEAVE) + await self._log_member_join_leave(member, LoggingType.MEMBER_LEAVE, MemberLeaveEvent) @LightningCog.listener() async def on_lightning_member_passed_screening(self, member): async for emitter, record in self.get_records(member.guild, LoggingType.MEMBER_SCREENING_COMPLETE): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "completed_screening", member, **kwargs) - await self._emit(emitter, fmt_name, result, mentions=[member]) + await self._emit(emitter, record, MemberScreeningEvent(member)) async def _log_role_changes(self, ltype: LoggingType, event: MemberRolesUpdateEvent) -> None: async for emitter, record in self.get_records(event.guild.id, ltype): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "role_change", event, **kwargs) - await self._emit(emitter, fmt_name, result) + await self._emit(emitter, record, event) @LightningCog.listener() async def on_lightning_member_role_change(self, event: MemberRolesUpdateEvent): @@ -279,35 +265,26 @@ async def on_lightning_member_role_change(self, event: MemberRolesUpdateEvent): async def on_lightning_member_nick_change(self, event: MemberUpdateEvent): guild = event.guild async for emitter, record in self.get_records(guild, LoggingType.MEMBER_NICK_CHANGE): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "nick_change", event.after, event.before.nick, - event.after.nick, event.moderator, **kwargs) - await self._emit(emitter, fmt_name, result, mentions=[event.after]) + await self._emit(emitter, record, event) @LightningCog.listener() async def on_lightning_infraction_update(self, event: InfractionUpdateEvent): async for emitter, record in self.get_records(event.after.guild, LoggingType.INFRACTION_UPDATE): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "infraction_update", event, **kwargs) - await self._emit(emitter, fmt_name, result) + await self._emit(emitter, record, event) @LightningCog.listener() async def on_lightning_infraction_delete(self, event: InfractionDeleteEvent): details = event.format_infraction() async for emitter, record in self.get_records(event.moderator.guild, LoggingType.INFRACTION_DELETE): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "infraction_delete", event, **kwargs) - await self._emit(emitter, fmt_name, result, extra_embeds=[details], mentions=[event.moderator]) + await self._emit(emitter, record, event, extra_embeds=[details]) @LightningCog.listener() - async def on_lightning_member_timeout_remove(self, event: AuditLogTimeoutEvent | MemberUpdateEvent): + async def on_lightning_member_timeout_remove(self, event: AuditLogTimeoutEvent): query = "UPDATE infractions SET active='f' WHERE action='10' AND guild_id=$1 AND user_id=$2;" await self.bot.pool.execute(query, event.guild.id, event.member.id) async for emitter, record in self.get_records(event.guild, LoggingType.MEMBER_TIMEOUT_REMOVE): - fmt_name, kwargs = modlogformats.parse_format_setting(record['format']) - result = modlogformats.format_event(fmt_name, "timeout_expired", event, **kwargs) - await self._emit(emitter, fmt_name, result, mentions=[event.member]) + await self._emit(emitter, record, event) @LightningCog.listener() async def on_lightning_guild_alert(self, guild_id: int, message: str): diff --git a/lightning/events.py b/lightning/events.py index 20226775..49b26722 100644 --- a/lightning/events.py +++ b/lightning/events.py @@ -16,14 +16,15 @@ """ from __future__ import annotations +from datetime import datetime from typing import List, Optional, Union import discord from lightning.enums import ActionType +from lightning.formatters import base_user_format from lightning.models import Action, InfractionRecord from lightning.utils.helpers import ticker -from lightning.utils.modlogformats import base_user_format from lightning.utils.time import add_tzinfo # These are event models that'll be passed for listeners cog @@ -233,8 +234,6 @@ def format_infraction(self) -> discord.Embed: return embed -# These won't be used yet until I do a refactor of modlogformats for single param events - # lightning_command_ran class CommandEvent: @@ -245,3 +244,41 @@ def __init__(self, ctx): self.user = ctx.author self.ran_at = ctx.message.created_at self.channel = ctx.channel # We can get guild from here... + + +# These are only ever used by the modlog renderers. Nothing dispatches them, the modlog cog +# builds them from the discord.py events it already gets so each renderer can take a single event. +class MemberEvent: + __slots__ = ("member",) + + def __init__(self, member: discord.Member) -> None: + self.member = member + + @property + def guild(self) -> discord.Guild: + return self.member.guild + + +class MemberJoinEvent(MemberEvent): + __slots__ = () + + +class MemberLeaveEvent(MemberEvent): + __slots__ = () + + +# lightning_member_passed_screening +class MemberScreeningEvent(MemberEvent): + __slots__ = () + + +# lightning_timed_moderation_action_done +class TimedActionExpiredEvent: + __slots__ = ("action", "user", "moderator", "created_at", "expiry") + + def __init__(self, action: str, user, moderator, created_at: datetime, expiry: Optional[datetime]) -> None: + self.action = action + self.user = user + self.moderator = moderator + self.created_at = created_at + self.expiry = expiry diff --git a/lightning/formatters.py b/lightning/formatters.py index d30badd7..458d7a7d 100644 --- a/lightning/formatters.py +++ b/lightning/formatters.py @@ -15,6 +15,10 @@ along with this program. If not, see . """ +from datetime import datetime + +import discord + def codeblock(text: str, *, language: str = "py") -> str: return f"```{language}\n{text}```" @@ -54,3 +58,51 @@ def human_join(seq, delim=', ', conj='or') -> str: return f'{seq[0]} {conj} {seq[1]}' return delim.join(seq[:-1]) + f' {conj} {seq[-1]}' + + +# These live here (instead of in modlogformats) so lightning.events can use them without +# importing the renderers, which import the events. That's what would cause a circular import. +def escape_markdown_and_mentions(text) -> str: + """Helper function to escape mentions and markdown from a string + + Parameters + ---------- + text : str + The string to remove markdown and mentions from + + Returns + ------- + str + The escaped text + """ + escaped_markdown = discord.utils.escape_markdown(text) + return discord.utils.escape_mentions(escaped_markdown) + + +def action_format(author, action_text="Action done by", *, reason=None) -> str: + if reason is None: + return f"{action_text} {str(author)} (ID: {author.id})" + else: + return f"{str(author)} (ID: {author.id}): {reason}" + + +def base_user_format(user, unknown_text="Unknown user with ID: ") -> str: + if hasattr(user, 'name'): + return f"{escape_markdown_and_mentions(str(user))} ({user.id})" + else: + return f"{unknown_text}{user.id if hasattr(user, 'id') else user}" + + +def format_timestamp(dt: datetime) -> str: + return discord.utils.format_dt(dt, style="T") + + +def construct_dm_message(member, action_verb, location, *, middle=None, reason=None, ending=None): + msg = [f"You were {action_verb} {location} {member.guild.name}"] + if middle: + msg.append(middle) + if reason: + msg.append(f"\n\n**Reason**: {reason}") + if ending: + msg.append(f"\n{ending}") + return ''.join(msg) diff --git a/lightning/utils/modlogformats/__init__.py b/lightning/utils/modlogformats/__init__.py index bc5faefd..4663de7d 100644 --- a/lightning/utils/modlogformats/__init__.py +++ b/lightning/utils/modlogformats/__init__.py @@ -14,21 +14,29 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -from lightning.utils.modlogformats.base import (BaseFormat, CompactModAction, - action_format, - base_user_format, - construct_dm_message, - escape_markdown_and_mentions, - format_event, format_timestamp, - log_actions, modlog_format, - parse_format_setting) -# Importing these is what registers them with @modlog_format -from lightning.utils.modlogformats.embed import EmbedFormat -from lightning.utils.modlogformats.emoji import EmojiFormat -from lightning.utils.modlogformats.minimal import MinimalisticFormat +from lightning.formatters import (action_format, base_user_format, + construct_dm_message, + escape_markdown_and_mentions, + format_timestamp) +from lightning.utils.modlogformats.base import (FormatContext, FormatName, + Renderer, get_renderer, + renders) +# Importing these is what registers the renderers with @renders +from lightning.utils.modlogformats.commands import CommandRan +from lightning.utils.modlogformats.member import (MemberJoin, MemberLeave, + MemberScreening, NickChange, + RoleChange) +from lightning.utils.modlogformats.moderation import (CompactModAction, + InfractionDelete, + InfractionUpdate, + ModAction, + TimedActionExpired, + TimeoutExpired, + log_actions) __all__ = ( - "BaseFormat", "CompactModAction", "EmbedFormat", "EmojiFormat", "MinimalisticFormat", - "action_format", "base_user_format", "construct_dm_message", "escape_markdown_and_mentions", - "format_event", "format_timestamp", "log_actions", "modlog_format", "parse_format_setting" + "CommandRan", "CompactModAction", "FormatContext", "FormatName", "InfractionDelete", "InfractionUpdate", + "MemberJoin", "MemberLeave", "MemberScreening", "ModAction", "NickChange", "Renderer", "RoleChange", + "TimedActionExpired", "TimeoutExpired", "action_format", "base_user_format", "construct_dm_message", + "escape_markdown_and_mentions", "format_timestamp", "get_renderer", "log_actions", "renders" ) diff --git a/lightning/utils/modlogformats/base.py b/lightning/utils/modlogformats/base.py index 187789ed..0f52fc65 100644 --- a/lightning/utils/modlogformats/base.py +++ b/lightning/utils/modlogformats/base.py @@ -17,385 +17,93 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime -from typing import (TYPE_CHECKING, Any, Callable, Literal, Optional, Type, - TypeVar, Union, overload) +from typing import Callable, Generic, Literal, Type, TypeVar, Union import discord -from lightning.models import Action - -if TYPE_CHECKING: - from lightning import LightningContext - from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, - InfractionUpdateEvent, - MemberRolesUpdateEvent, MemberUpdateEvent) +from lightning.formatters import format_timestamp FormatName = Literal["emoji", "minimal", "embed"] -class BaseFormat: - def __init__(self, log_action, target, moderator, infraction_id, reason=None, *, expiry=None, **kwargs): - self.log_action = log_action - self.target = target - self.moderator = moderator - self.infraction_id = infraction_id - self.reason = reason or "no reason given" - self.timestamp = kwargs.pop("timestamp", discord.utils.utcnow()) - self.expiry: datetime | None = expiry - self.kwargs = kwargs - - @classmethod - def from_action(cls, action: Action): - if not action.is_logged(): - raise # TODO - - return cls(action.action, action.target, action.moderator, action.infraction_id, action.reason, - timestamp=action.timestamp, expiry=action.expiry, **action.kwargs) - - def format_message(self): - raise NotImplementedError +@dataclass(frozen=True) +class FormatContext: + """How a guild wants its modlog entries to look. + with_timestamp only means something to the minimal format, so it lives here + instead of being a kwarg that the other two formats would have to ignore. + """ + fmt: FormatName + with_timestamp: bool = True -@dataclass -class CompactModAction: - tense: str - emoji: str - title: str - color: Union[discord.Color, int, hex] - - -log_actions = { - "ban": CompactModAction("banned", "\N{NO ENTRY}", "Ban", 0xFF0000), - "kick": CompactModAction("kicked", "\N{WOMANS BOOTS}", "Kick", 0xFF7F50), - "warn": CompactModAction("warned", "\N{WARNING SIGN}", "Warn", 0xFFDF00), - "temprole": CompactModAction("temporarily restricted role to", "\N{TIMER CLOCK}\N{VARIATION SELECTOR-16}", - "Temporary Role", 0x607d8b), - "unban": CompactModAction("unbanned", "\N{WARNING SIGN}", "Unban", 0xB22222), - "timed_restriction_removed": CompactModAction("", "\N{WARNING SIGN}", "Timed restriction expired", 0x6B8E23), - "timeban": CompactModAction("temporarily banned", "\N{NO ENTRY}", "Timed Ban", 0xC7031E), - "mute": CompactModAction("muted", "\N{SPEAKER WITH CANCELLATION STROKE}", "Mute", 0x7c7b82), - "timemute": CompactModAction("temporarily muted", "\N{SPEAKER WITH CANCELLATION STROKE}", "Timed Mute", 0x7c7b82), - "unmute": CompactModAction("umuted", "\N{SPEAKER}", "Unmute", 0xFFFFFF), - "timeout": CompactModAction("timed out", "\N{SPEAKER WITH CANCELLATION STROKE}", "Timeout", 0x7c7b82) -} - + @classmethod + def from_setting(cls, setting: str) -> FormatContext: + """Builds a context from the format string we store in the database""" + if setting in ("minimal with timestamp", "minimal without timestamp"): + return cls("minimal", setting != "minimal without timestamp") + return cls(setting) # type: ignore -def construct_dm_message(member, action_verb, location, *, middle=None, reason=None, ending=None): - msg = [f"You were {action_verb} {location} {member.guild.name}"] - if middle: - msg.append(middle) - if reason: - msg.append(f"\n\n**Reason**: {reason}") - if ending: - msg.append(f"\n{ending}") - return ''.join(msg) +E = TypeVar("E") -def escape_markdown_and_mentions(text) -> str: - """Helper function to escape mentions and markdown from a string - Parameters - ---------- - text : str - The string to remove markdown and mentions from +class Renderer(Generic[E]): + """Base class for a modlog renderer. Subclasses are registered with :func:`renders`. - Returns - ------- - str - The escaped text + One renderer handles one kind of event, and knows how to show it in every format. """ - escaped_markdown = discord.utils.escape_markdown(text) - return discord.utils.escape_mentions(escaped_markdown) + def __init__(self, event: E) -> None: + self.event = event + def mentions(self) -> list: + """Who the emoji format is allowed to ping.""" + return [] -def action_format(author, action_text="Action done by", *, reason=None) -> str: - if reason is None: - return f"{action_text} {str(author)} (ID: {author.id})" - else: - return f"{str(author)} (ID: {author.id}): {reason}" + def emoji(self) -> str: + raise NotImplementedError + def minimal(self, ctx: FormatContext) -> str: + raise NotImplementedError -def base_user_format(user, unknown_text="Unknown user with ID: ") -> str: - if hasattr(user, 'name'): - return f"{escape_markdown_and_mentions(str(user))} ({user.id})" - else: - return f"{unknown_text}{user.id if hasattr(user, 'id') else user}" + def embed(self) -> discord.Embed: + raise NotImplementedError + def stamp(self, ctx: FormatContext, when=None) -> str: + """The "[time] " prefix that the minimal format puts at the front, if the guild wants it.""" + if not ctx.with_timestamp: + return "" + return f"[{format_timestamp(when or discord.utils.utcnow())}] " -def format_timestamp(dt: datetime): - return discord.utils.format_dt(dt, style="T") + def render(self, ctx: FormatContext) -> Union[str, discord.Embed]: + # This is the only spot that knows which format maps to which method + if ctx.fmt == "emoji": + return self.emoji() + if ctx.fmt == "minimal": + return self.minimal(ctx) + return self.embed() -# Registry time! Each format class tags itself with @modlog_format("name") so we -# never have to hand-maintain a lookup table, and the cog doesn't need to know -# which classes exist. -_formats: dict[str, Type[BaseFormat]] = {} -_F = TypeVar("_F", bound=Type[BaseFormat]) +# Each renderer tags itself with @renders(SomeEvent) so there's no lookup table to keep up by hand, +# and the cog never has to know which renderers exist. +_renderers: dict[type, Type[Renderer]] = {} +_R = TypeVar("_R", bound=Type[Renderer]) -def modlog_format(name: FormatName) -> Callable[[_F], _F]: - def decorator(cls: _F) -> _F: - _formats[name] = cls +def renders(event_type: type) -> Callable[[_R], _R]: + def decorator(cls: _R) -> _R: + _renderers[event_type] = cls return cls return decorator -def parse_format_setting(setting: str) -> tuple[FormatName, dict[str, bool]]: - """Turns the format we store in the db into a format name + the kwargs it accepts. +def get_renderer(event) -> Renderer: + """Finds the renderer for an event. - Only the minimal format cares about timestamps, so that's the only one that gets a kwarg. + We walk the MRO so a subclass of an event with a renderer (ex. LightningAutoModInfractionEvent) + still works, but a renderer for the subclass itself (ex. MemberRolesUpdateEvent) wins. """ - if setting in ("minimal with timestamp", "minimal without timestamp"): - return "minimal", {"with_timestamp": setting != "minimal without timestamp"} - return setting, {} # type: ignore[return-value] - - -# The overloads below are purely for type checkers. They spell out exactly what each -# format wants for each event (they're not all the same, ex. timed_action_expired), -# so passing with_timestamp to emoji or embed gets flagged. The real function at the -# bottom just looks the method up by name. - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["command_ran"], - ctx: LightningContext, - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["command_ran"], - ctx: LightningContext) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["command_ran"], - ctx: LightningContext) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["role_change"], - event: MemberRolesUpdateEvent, - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["role_change"], - event: MemberRolesUpdateEvent) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["role_change"], - event: MemberRolesUpdateEvent) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["nick_change"], - member: discord.Member, - previous: str, - current: Optional[str], - moderator: Any = ..., - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["nick_change"], - member: discord.Member, - previous: str, - current: Optional[str], - moderator: Any = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["nick_change"], - member: discord.Member, - previous: str, - current: Optional[str], - moderator: Any = ...) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["timed_action_expired"], - action: str, - user: Any, - mod: Any, - creation: datetime, - expiry: datetime, - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["timed_action_expired"], - action: str, - user: Any, - mod: Any, - creation: datetime) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["timed_action_expired"], - action: str, - moderator: Any, - user: Any, - created_at: datetime) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["join_leave"], - log_type: str, - member: discord.Member) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["join_leave"], - log_type: str, - member: discord.Member) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["join_leave"], - log_type: str, - member: discord.Member) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["completed_screening"], - member: discord.Member, - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["completed_screening"], - member: discord.Member) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["completed_screening"], - member: discord.Member) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["timeout_expired"], - event: AuditLogTimeoutEvent | MemberUpdateEvent, - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["timeout_expired"], - event: AuditLogTimeoutEvent | MemberUpdateEvent) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["timeout_expired"], - event: AuditLogTimeoutEvent | MemberUpdateEvent) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["infraction_update"], - event: InfractionUpdateEvent, - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["infraction_update"], - event: InfractionUpdateEvent) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["infraction_update"], - event: InfractionUpdateEvent) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["infraction_delete"], - event: InfractionDeleteEvent, - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["infraction_delete"], - event: InfractionDeleteEvent) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["infraction_delete"], - event: InfractionDeleteEvent) -> discord.Embed: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["bot_addition"], - bot: discord.Member, - mod: Any, - time: datetime) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["bot_addition"], - bot: discord.Member, - mod: Any) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["minimal"], event_name: Literal["from_action"], - action: Action, - *, - with_timestamp: bool = ...) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["emoji"], event_name: Literal["from_action"], - action: Action) -> str: - ... - - -@overload -def format_event(fmt_name: Literal["embed"], event_name: Literal["from_action"], - action: Action) -> discord.Embed: - ... - + for cls in type(event).__mro__: + if cls in _renderers: + return _renderers[cls](event) -def format_event(fmt_name: FormatName, event_name: str, *args: Any, **kwargs: Any) -> Union[str, discord.Embed]: - cls = _formats[fmt_name] - if event_name == "from_action": - # Actions are formatted through an instance rather than a static method - action, *_ = args - return cls.from_action(action).format_message(**kwargs) - return getattr(cls, event_name)(*args, **kwargs) + raise LookupError(f"No modlog renderer registered for {type(event).__name__}") diff --git a/lightning/utils/modlogformats/commands.py b/lightning/utils/modlogformats/commands.py new file mode 100644 index 00000000..617e14d8 --- /dev/null +++ b/lightning/utils/modlogformats/commands.py @@ -0,0 +1,47 @@ +""" +Lightning.py - A Discord bot +Copyright (C) 2019-present Célveren + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation at version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import discord + +from lightning.events import CommandEvent +from lightning.formatters import base_user_format +from lightning.utils.modlogformats.base import FormatContext, Renderer, renders +from lightning.utils.modlogformats.member import format_user + + +@renders(CommandEvent) +class CommandRan(Renderer[CommandEvent]): + def mentions(self): + return [self.event.user] + + def emoji(self) -> str: + event = self.event + return f"\N{CLIPBOARD} **Command Used**: {event.user.mention} ran `{event.command}`\n"\ + f"__Channel__: {event.channel.mention} | {event.channel.name}" + + def minimal(self, ctx: FormatContext) -> str: + event = self.event + return f"{self.stamp(ctx, event.ran_at)}**Command Ran**\n**Command**: {event.command}\n"\ + f"**User**: {format_user(event.user)}\n**Channel**: {base_user_format(event.channel)}" + + def embed(self) -> discord.Embed: + event = self.event + embed = discord.Embed(title="Command Ran", color=0xf74b06, timestamp=event.ran_at) + embed.description = f"**Command**: {event.command}\n**User**: {event.user.mention} ({event.user.id})"\ + f"\n**Channel**: {event.channel.mention} ({event.channel.id})" + return embed diff --git a/lightning/utils/modlogformats/embed.py b/lightning/utils/modlogformats/embed.py deleted file mode 100644 index 52c78fa7..00000000 --- a/lightning/utils/modlogformats/embed.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Lightning.py - A Discord bot -Copyright (C) 2019-present Célveren - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published -by the Free Software Foundation at version 3 of the License. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -import discord - -from lightning.formatters import truncate_text -from lightning.utils.modlogformats.base import (BaseFormat, base_user_format, - log_actions, modlog_format) -from lightning.utils.time import natural_timedelta - -if TYPE_CHECKING: - from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, - InfractionUpdateEvent, - MemberRolesUpdateEvent, MemberUpdateEvent) - - -@modlog_format("embed") -class EmbedFormat(BaseFormat): - @staticmethod - def join_leave(log_type: str, member: discord.Member): - embed = discord.Embed() - - if log_type == "MEMBER_JOIN": - embed.title = "Member Join" - embed.color = discord.Color.green() - else: - embed.title = "Member Leave" - embed.color = discord.Color.red() - - embed.set_author(name=member, icon_url=member.display_avatar.url) - embed.description = f"**User**: {member.mention} ({member.id}) \n**Created at**: "\ - f"{natural_timedelta(member.created_at)}" - return embed - - def format_message(self) -> discord.Embed: - action = log_actions[str(self.log_action).lower()] - embed = discord.Embed(title=action.title, color=action.color) - reason = truncate_text(self.reason, 512) - - base = [f"**User**: {base_user_format(self.target)} <@!{self.target.id}>\n" - f"**Moderator**: {base_user_format(self.moderator)} <@!{self.moderator.id}>"] - - if self.expiry: - base.append(f"\n**Expiry**: {self.expiry}") - - base.append(f"\n**Reason**: {discord.utils.escape_markdown(reason)}") - embed.description = ''.join(base) - embed.set_footer(text=f"Infraction ID: {self.infraction_id}") - embed.timestamp = self.timestamp - return embed - - @staticmethod - def timed_action_expired(action, moderator, user, created_at) -> discord.Embed: - embed = discord.Embed(description=f"Time {action} for {base_user_format(user)} expired") - embed.add_field(name="Moderator", value=base_user_format(moderator)) - embed.timestamp = created_at - embed.set_footer(text=f"Time {action} was made at") - return embed - - @staticmethod - def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent) -> discord.Embed: - embed = discord.Embed(description=f"Timeout for {base_user_format(event.member)} expired") - - if hasattr(event, 'moderator'): - embed.add_field(name="Moderator", value=base_user_format(event.moderator)) - - return embed - - @staticmethod - def role_change(event: MemberRolesUpdateEvent) -> discord.Embed: - embed = discord.Embed(title="Role Change", color=discord.Color.dark_gold(), - description=f"User: {event.after.mention} ({event.after.id})") - - if event.entry: - removed = event.entry.changes.before.roles - added = event.entry.changes.after.roles - time = event.entry.created_at - else: - added = event.added_roles - removed = event.removed_roles - time = discord.utils.utcnow() - - if event.moderator: - embed.add_field(name="Moderator", value=base_user_format(event.moderator)) - - if added: - added = "".join(r.mention for r in added) - embed.description += f"\nAdded: {added}" - - if removed: - removed = "".join(r.mention for r in removed) - embed.description += f"\nRemoved: {removed}" - - embed.timestamp = time - return embed - - @staticmethod - def role_addition(event: MemberRolesUpdateEvent): - return EmbedFormat.role_change(event) - - @staticmethod - def command_ran(ctx) -> discord.Embed: - embed = discord.Embed(title="Command Ran", color=0xf74b06, timestamp=ctx.message.created_at) - user = ctx.author - embed.description = f"**Command**: {ctx.command.qualified_name}\n**User**: {user.mention} ({user.id})"\ - f"\n**Channel**: {ctx.channel.mention} ({ctx.channel.id})" - return embed - - @staticmethod - def nick_change(member, previous: str, current: Optional[str], moderator=None) -> discord.Embed: - embed = discord.Embed(color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) - - if current and previous: - embed.title = "Member Nickname Update" - embed.description = f"**Old Nickname**: {previous}\n**New Nickname**: {current}" - elif previous is None and current is not None: - embed.title = "Member Nickname Add" - embed.description = f"**New Nickname**: {current}" - elif current is None and previous is not None: - embed.title = "Member Nickname Removed" - embed.description = f"**Old Nickname**: {previous}" - - embed.description += f"\n**Member**: {base_user_format(member)}" - - if moderator: - embed.add_field(name="Moderator", value=base_user_format(moderator)) - - return embed - - @staticmethod - def completed_screening(member: discord.Member): - return discord.Embed(title="Member Passed Screening", description=f"**Member**: {base_user_format(member)}", - color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) - - @staticmethod - def infraction_update(event: InfractionUpdateEvent) -> discord.Embed: - embed = discord.Embed(color=discord.Color.yellow(), timestamp=discord.utils.utcnow(), title="Infraction Update") - embed.set_footer(text=f"Infraction ID: {event.after.id}") - - if event.before.moderator_id != event.after.moderator_id: - embed.add_field(name="Moderator", - value=f"**Old**: {base_user_format(event.before.moderator)}\n**New**: " - f"{base_user_format(event.after.moderator)}") - - if event.before.reason != event.after.reason: - embed.add_field(name="Reason", - value=f"**Old**: {truncate_text(event.before.reason, limit=200)}\n**New**: " - f"{truncate_text(event.after.reason, limit=200)}") - - return embed - - @staticmethod - def infraction_delete(event: InfractionDeleteEvent) -> discord.Embed: - embed = discord.Embed(color=discord.Color.red(), - title="Infraction Delete", - description=f"**ID**: {event.infraction.id}\n**Moderator**: " - f"{base_user_format(event.moderator)}") - - return embed diff --git a/lightning/utils/modlogformats/emoji.py b/lightning/utils/modlogformats/emoji.py deleted file mode 100644 index 856f983d..00000000 --- a/lightning/utils/modlogformats/emoji.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -Lightning.py - A Discord bot -Copyright (C) 2019-present Célveren - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published -by the Free Software Foundation at version 3 of the License. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -""" -from __future__ import annotations - -from typing import TYPE_CHECKING - -import discord - -from lightning.formatters import truncate_text -from lightning.utils.helpers import Emoji -from lightning.utils.modlogformats.base import (BaseFormat, - escape_markdown_and_mentions, - log_actions, modlog_format) -from lightning.utils.time import get_utc_timestamp - -if TYPE_CHECKING: - from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, - InfractionUpdateEvent, - MemberRolesUpdateEvent, MemberUpdateEvent) - - -@modlog_format("emoji") -class EmojiFormat(BaseFormat): - def target_mention_and_safe_name(self): - if self.target is None: - return "" - if hasattr(self.target, 'mention'): - mention = self.target.mention - else: - mention = f"<@!{self.target.id}>" - if isinstance(self.target, discord.Object): - return mention - return f"{mention} | {discord.utils.escape_markdown(str(self.target))}" - - def temp_action_target(self, expiry): - if hasattr(self.target, 'mention'): - mention = self.target.mention - else: - mention = f"<@!{self.target.id}>" - if isinstance(self.target, discord.Object): - return mention - return f"{mention} for {expiry} | {discord.utils.escape_markdown(str(self.target))}" - - def format_message(self) -> str: - attrs = log_actions[str(self.log_action).lower()] - message = [f"{attrs.emoji} **{attrs.title}**: {self.moderator.mention}" - f" {attrs.tense} "] - - if self.expiry: - message.append(self.temp_action_target(self.expiry)) - else: - message.append(self.target_mention_and_safe_name()) - - if hasattr(self.target, 'id'): - message.append(f"\n\N{LABEL} __User ID__: {self.target.id}") - - message.append(f"\n\N{PENCIL}\N{VARIATION SELECTOR-16} __Reason__: \"{self.reason}\"") - return ''.join(message) - - @staticmethod - def bot_addition(bot: discord.Member, mod) -> str: - return f"\N{ROBOT FACE} **Bot Add** {mod.mention} added bot {bot.mention} | "\ - f"{escape_markdown_and_mentions(str(bot))}" - - @staticmethod - def completed_screening(member: discord.Member): - return f"\N{PASSPORT CONTROL} **Member Completed Screening** {member.mention} | "\ - f"({escape_markdown_and_mentions(str(member))}" - - @staticmethod - def role_change(event: MemberRolesUpdateEvent): - if event.entry: - removed = event.entry.changes.before.roles - added = event.entry.changes.after.roles - else: - removed = event.removed_roles - added = event.added_roles - - msg = ["\n👑__Role change__: "] - if len(added) != 0 or len(removed) != 0: - roles = [] - for role in removed: - roles.append(f"_~~{escape_markdown_and_mentions(role.name)}~~_") - - for role in added: - roles.append(f"__**{escape_markdown_and_mentions(role.name)}**__") - - for role in event.after.roles: - if role.name == "@everyone": - continue - - if role not in added and role not in removed: - roles.append(escape_markdown_and_mentions(role.name)) - - msg.append(", ".join(roles)) - msg = [f"\N{INFORMATION SOURCE} **Member update**: {escape_markdown_and_mentions(str(event.after))} | " - f"{event.after.id} {''.join(msg)}"] - - if event.moderator: - msg.append(f"\n\N{BLUE BOOK} __Moderator__: " - f"{escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") - - return ''.join(msg) - - @staticmethod - def timed_action_expired(action, user, mod, creation) -> str: - msg = [f"\N{WARNING SIGN} **{action.capitalize()} expired**: <@!{user.id}>"] - - if hasattr(user, 'name'): - msg.append(f" | {discord.utils.escape_mentions(str(user))}") - - msg.append(f"\nTime{action} was made by <@!{mod.id}>") - - if hasattr(mod, 'name'): - msg.append(f" | {discord.utils.escape_mentions(str(mod))}") - - msg.append(f" at {get_utc_timestamp(creation)}") - return ''.join(msg) - - @staticmethod - def join_leave(log_type: str, member) -> str: - safe_name = escape_markdown_and_mentions(str(member)) - if log_type == "MEMBER_JOIN": - msg = f"{Emoji.member_join}"\ - f" **Member Join**: {member.mention} | "\ - f"{safe_name}\n"\ - f"\N{CLOCK FACE FOUR OCLOCK} __Account Creation__: {discord.utils.format_dt(member.created_at)}\n"\ - f"\N{LABEL} __User ID__: {member.id}" - else: - msg = f"{Emoji.member_leave} "\ - f"**Member Leave**: {member.mention} | "\ - f"{safe_name}\n"\ - f"\N{LABEL} __User ID__: {member.id}" - return msg - - @staticmethod - def command_ran(ctx) -> str: - command = ctx.command - msg = f"\N{CLIPBOARD} **Command Used**: {ctx.author.mention} ran `{command.qualified_name}`\n"\ - f"__Channel__: {ctx.channel.mention} | {ctx.channel.name}" - return msg - - @staticmethod - def nick_change(member, previous, current, moderator=None): - if previous is None and current is not None: - msg = f"\N{LABEL} __Nickname added__: None -> {current}" - elif previous is not None and current is not None: - msg = f"\N{LABEL} __Nickname changed__: {previous} -> {current}" - elif previous is not None and current is None: - msg = f"\N{LABEL} __Nickname removed__: {previous} -> None" - - msg = [f"\N{INFORMATION SOURCE} **Member update**: {member} | " - f"{member.id} {msg}"] - - if moderator: - safe_mod = escape_markdown_and_mentions(str(moderator)) - msg.append(f"\n\N{BLUE BOOK} __Moderator__: " - f"{safe_mod} ({moderator.id})") - - return ''.join(msg) - - @staticmethod - def infraction_update(event: InfractionUpdateEvent) -> str: - base = [f"\N{MEMO} **Infraction update**: ID: {event.after.id}"] - - if event.before.moderator_id != event.after.moderator_id: - base.append(f"\n__Old Moderator__: {escape_markdown_and_mentions(event.before.moderator)}" - f"\n__New Moderator__: {escape_markdown_and_mentions(event.after.moderator)}") - - if event.before.reason != event.after.reason: - base.append(f"\n__Old Reason__: {truncate_text(event.before.reason, limit=200)}" - f"\n__New Reason__: {truncate_text(event.after.reason, limit=200)}") - - return ''.join(base) - - @staticmethod - def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent) -> str: - text = [f"\N{WARNING SIGN} **Timeout expired** <@!{event.member.id}>"] - - if hasattr(event, 'moderator'): - text.append(f"\N{BLUE BOOK} __Moderator__: " - f"{escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") - - return ''.join(text) - - @staticmethod - def infraction_delete(event: InfractionDeleteEvent): - msg = f"\N{PUT LITTER IN ITS PLACE SYMBOL} **Infraction deleted** "\ - f"{event.moderator.mention} deleted #{event.infraction.id}" - return msg diff --git a/lightning/utils/modlogformats/member.py b/lightning/utils/modlogformats/member.py new file mode 100644 index 00000000..2d07b4c4 --- /dev/null +++ b/lightning/utils/modlogformats/member.py @@ -0,0 +1,250 @@ +""" +Lightning.py - A Discord bot +Copyright (C) 2019-present Célveren + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation at version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import discord + +from lightning.events import (MemberJoinEvent, MemberLeaveEvent, + MemberRolesUpdateEvent, MemberScreeningEvent, + MemberUpdateEvent) +from lightning.formatters import base_user_format, escape_markdown_and_mentions +from lightning.utils.helpers import Emoji +from lightning.utils.modlogformats.base import FormatContext, Renderer, renders +from lightning.utils.time import natural_timedelta + + +def format_user(user) -> str: + """Used by the minimal format. Users we can't resolve just show their ID.""" + if hasattr(user, 'name'): + return f"{escape_markdown_and_mentions(str(user))} ({user.id})" + return f"ID: {user.id}" + + +@renders(MemberJoinEvent) +class MemberJoin(Renderer[MemberJoinEvent]): + def mentions(self): + return [self.event.member] + + def emoji(self) -> str: + member = self.event.member + return f"{Emoji.member_join} **Member Join**: {member.mention} | "\ + f"{escape_markdown_and_mentions(str(member))}\n"\ + f"\N{CLOCK FACE FOUR OCLOCK} __Account Creation__: {discord.utils.format_dt(member.created_at)}\n"\ + f"\N{LABEL} __User ID__: {member.id}" + + def minimal(self, ctx: FormatContext) -> str: + member = self.event.member + return f"{self.stamp(ctx, member.joined_at)}**Member Join**: {discord.utils.escape_markdown(str(member))} "\ + f"({member.id})\n__Account Creation Date__: {discord.utils.format_dt(member.created_at)}" + + def embed(self) -> discord.Embed: + member = self.event.member + embed = discord.Embed(title="Member Join", color=discord.Color.green()) + embed.set_author(name=member, icon_url=member.display_avatar.url) + embed.description = f"**User**: {member.mention} ({member.id}) \n**Created at**: "\ + f"{natural_timedelta(member.created_at)}" + return embed + + +@renders(MemberLeaveEvent) +class MemberLeave(Renderer[MemberLeaveEvent]): + def mentions(self): + return [self.event.member] + + def emoji(self) -> str: + member = self.event.member + return f"{Emoji.member_leave} **Member Leave**: {member.mention} | "\ + f"{escape_markdown_and_mentions(str(member))}\n"\ + f"\N{LABEL} __User ID__: {member.id}" + + def minimal(self, ctx: FormatContext) -> str: + member = self.event.member + return f"{self.stamp(ctx)}**Member Leave**: {discord.utils.escape_markdown(str(member))} ({member.id})" + + def embed(self) -> discord.Embed: + member = self.event.member + embed = discord.Embed(title="Member Leave", color=discord.Color.red()) + embed.set_author(name=member, icon_url=member.display_avatar.url) + embed.description = f"**User**: {member.mention} ({member.id}) \n**Created at**: "\ + f"{natural_timedelta(member.created_at)}" + return embed + + +@renders(MemberScreeningEvent) +class MemberScreening(Renderer[MemberScreeningEvent]): + def mentions(self): + return [self.event.member] + + def emoji(self) -> str: + member = self.event.member + return f"\N{PASSPORT CONTROL} **Member Completed Screening** {member.mention} | "\ + f"({escape_markdown_and_mentions(str(member))}" + + def minimal(self, ctx: FormatContext) -> str: + member = self.event.member + return f"{self.stamp(ctx)}**Member Passed Screening**: {discord.utils.escape_markdown(str(member))} "\ + f"({member.id})" + + def embed(self) -> discord.Embed: + return discord.Embed(title="Member Passed Screening", + description=f"**Member**: {base_user_format(self.event.member)}", + color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) + + +@renders(MemberUpdateEvent) +class NickChange(Renderer[MemberUpdateEvent]): + def mentions(self): + return [self.event.after] + + @property + def previous(self): + return self.event.before.nick + + @property + def current(self): + return self.event.after.nick + + def emoji(self) -> str: + member, previous, current = self.event.after, self.previous, self.current + if previous is None and current is not None: + change = f"\N{LABEL} __Nickname added__: None -> {current}" + elif previous is not None and current is not None: + change = f"\N{LABEL} __Nickname changed__: {previous} -> {current}" + else: + change = f"\N{LABEL} __Nickname removed__: {previous} -> None" + + msg = [f"\N{INFORMATION SOURCE} **Member update**: {member} | {member.id} {change}"] + + if self.event.moderator: + msg.append(f"\n\N{BLUE BOOK} __Moderator__: " + f"{escape_markdown_and_mentions(str(self.event.moderator))} ({self.event.moderator.id})") + + return ''.join(msg) + + def minimal(self, ctx: FormatContext) -> str: + base = [self.stamp(ctx)] + + if self.current and self.previous: + base.append(f"**Member Nickname Update**\n**Old Nickname**: {self.previous}\n" + f"**New Nickname**: {self.current}") + elif self.previous is None and self.current is not None: + base.append(f"**Member Nickname Add**\n**New Nickname**: {self.current}") + elif self.current is None and self.previous is not None: + base.append(f"**Member Nickname Removed**\n**Old Nickname**: {self.previous}") + + base.append(f"\n**Member**: {format_user(self.event.after)}") + + if self.event.moderator: + base.append(f"\n**Moderator**: {format_user(self.event.moderator)}") + + return ''.join(base) + + def embed(self) -> discord.Embed: + embed = discord.Embed(color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) + + if self.current and self.previous: + embed.title = "Member Nickname Update" + embed.description = f"**Old Nickname**: {self.previous}\n**New Nickname**: {self.current}" + elif self.previous is None and self.current is not None: + embed.title = "Member Nickname Add" + embed.description = f"**New Nickname**: {self.current}" + elif self.current is None and self.previous is not None: + embed.title = "Member Nickname Removed" + embed.description = f"**Old Nickname**: {self.previous}" + + embed.description += f"\n**Member**: {base_user_format(self.event.after)}" + + if self.event.moderator: + embed.add_field(name="Moderator", value=base_user_format(self.event.moderator)) + + return embed + + +@renders(MemberRolesUpdateEvent) +class RoleChange(Renderer[MemberRolesUpdateEvent]): + # If we have the audit log entry, it's the best source for what changed + @property + def added(self): + return self.event.entry.changes.after.roles if self.event.entry else self.event.added_roles + + @property + def removed(self): + return self.event.entry.changes.before.roles if self.event.entry else self.event.removed_roles + + def emoji(self) -> str: + event, added, removed = self.event, self.added, self.removed + + roles = [] + for role in removed: + roles.append(f"_~~{escape_markdown_and_mentions(role.name)}~~_") + + for role in added: + roles.append(f"__**{escape_markdown_and_mentions(role.name)}**__") + + for role in event.after.roles: + if role.name == "@everyone": + continue + + if role not in added and role not in removed: + roles.append(escape_markdown_and_mentions(role.name)) + + msg = [f"\N{INFORMATION SOURCE} **Member update**: {escape_markdown_and_mentions(str(event.after))} | " + f"{event.after.id} \n👑__Role change__: {', '.join(roles)}"] + + if event.moderator: + msg.append(f"\n\N{BLUE BOOK} __Moderator__: " + f"{escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") + + return ''.join(msg) + + def minimal(self, ctx: FormatContext) -> str: + event = self.event + time = event.entry.created_at if event.entry else discord.utils.utcnow() + + base = [f"{self.stamp(ctx, time)}**Role Change**\n" + f"**User**: {escape_markdown_and_mentions(str(event.after))} ({event.after.id})\n"] + + for role in event.added_roles: + base.append(f"**Role Added**: {escape_markdown_and_mentions(str(role))} ({role.id})\n") + + for role in event.removed_roles: + base.append(f"**Role Removed**: {escape_markdown_and_mentions(str(role))} ({role.id})\n") + + if event.moderator is not None: + base.append(f"**Moderator**: {escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") + + if event.reason: + base.append(f"\n**Reason**: {escape_markdown_and_mentions(event.reason)}") + + return ''.join(base) + + def embed(self) -> discord.Embed: + event = self.event + embed = discord.Embed(title="Role Change", color=discord.Color.dark_gold(), + description=f"User: {event.after.mention} ({event.after.id})") + + if event.moderator: + embed.add_field(name="Moderator", value=base_user_format(event.moderator)) + + if self.added: + embed.description += f"\nAdded: {''.join(r.mention for r in self.added)}" + + if self.removed: + embed.description += f"\nRemoved: {''.join(r.mention for r in self.removed)}" + + embed.timestamp = event.entry.created_at if event.entry else discord.utils.utcnow() + return embed diff --git a/lightning/utils/modlogformats/minimal.py b/lightning/utils/modlogformats/minimal.py deleted file mode 100644 index 941fe7f3..00000000 --- a/lightning/utils/modlogformats/minimal.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Lightning.py - A Discord bot -Copyright (C) 2019-present Célveren - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published -by the Free Software Foundation at version 3 of the License. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -""" -from __future__ import annotations - -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Optional - -import discord - -from lightning.formatters import truncate_text -from lightning.utils.modlogformats.base import (BaseFormat, base_user_format, - escape_markdown_and_mentions, - format_timestamp, log_actions, - modlog_format) - -if TYPE_CHECKING: - from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, - InfractionUpdateEvent, - MemberRolesUpdateEvent, MemberUpdateEvent) - - -@modlog_format("minimal") -class MinimalisticFormat(BaseFormat): - @staticmethod - def format_user(user) -> str: - if hasattr(user, 'name'): - return f"{escape_markdown_and_mentions(str(user))} ({user.id})" - else: - return f"ID: {user.id}" - - @staticmethod - def role_change(event: MemberRolesUpdateEvent, *, with_timestamp=True) -> str: - time = event.entry.created_at if event.entry else discord.utils.utcnow() - - if with_timestamp: - base = [f"[{format_timestamp(time)}] **Role Change**\n" - f"**User**: {escape_markdown_and_mentions(str(event.after))} ({event.after.id})\n"] - else: - base = ["**Role Change**\n**User**:" - f"{escape_markdown_and_mentions(str(event.after))} ({event.after.id})\n"] - - if event.added_roles != 0: - for role in event.added_roles: - base.append(f"**Role Added**: {escape_markdown_and_mentions(str(role))} ({role.id})\n") - if event.removed_roles != 0: - for role in event.removed_roles: - base.append(f"**Role Removed**: {escape_markdown_and_mentions(str(role))} ({role.id})\n") - - if event.moderator is not None: - base.append(f"**Moderator**: {escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") - - if event.reason: - base.append(f"\n**Reason**: {escape_markdown_and_mentions(event.reason)}") - - return ''.join(base) - - @staticmethod - def timed_action_expired(action, user, mod, creation, expiry, *, with_timestamp: bool = True) -> str: - text = [f"[{format_timestamp(expiry)}] "] if with_timestamp else [] - - text.append(f"**{action.capitalize()} expired**\n**User**: " - f"{MinimalisticFormat.format_user(user)}\n**Moderator**: {MinimalisticFormat.format_user(mod)}" - f"\n**Created at**: {discord.utils.format_dt(creation)}") - return ''.join(text) - - @staticmethod - def timeout_expired(event: AuditLogTimeoutEvent | MemberUpdateEvent, *, with_timestamp: bool = True) -> str: - text = [f"[{format_timestamp(datetime.now(timezone.utc))}] "] if with_timestamp else [] - - text.append(f"**Timeout expired**\n**User**: {MinimalisticFormat.format_user(event.member)}\n") - - if hasattr(event, 'moderator'): - text.append(f"**Moderator**: {MinimalisticFormat.format_user(event.moderator)}") - - return ''.join(text) - - @staticmethod - def bot_addition(bot, mod, time) -> str: - safe_name = escape_markdown_and_mentions(str(bot)) - safe_mod_name = escape_markdown_and_mentions(str(mod)) - return f"[{format_timestamp(time)}] **Bot Add**"\ - f"\n**Bot**: {safe_name} ({bot.id})\n"\ - f"**Moderator**: {safe_mod_name} ({mod.id})" - - @staticmethod - def join_leave(log_type: str, member) -> str: - if log_type == "MEMBER_JOIN": - msg = f"[{format_timestamp(member.joined_at)}]"\ - f" **Member Join**: {discord.utils.escape_markdown(str(member))} ({member.id})\n"\ - "__Account Creation Date__: "\ - f"{discord.utils.format_dt(member.created_at)}" - else: - msg = f"[{format_timestamp(discord.utils.utcnow())}]"\ - f" **Member Leave**: {discord.utils.escape_markdown(str(member))} ({member.id})" - return msg - - @staticmethod - def completed_screening(member, *, with_timestamp: bool = True): - if with_timestamp: - base = [f"[{format_timestamp(discord.utils.utcnow())}] "] - else: - base = [] - - base.append(f"**Member Passed Screening**: {discord.utils.escape_markdown(str(member))} ({member.id})") - return ''.join(base) - - @staticmethod - def command_ran(ctx, *, with_timestamp: bool = True) -> str: - if with_timestamp: - base = [f"[{format_timestamp(ctx.message.created_at)}] "] - else: - base = [] - - base.append(f"**Command Ran**\n**Command**: {ctx.command.qualified_name}\n**User**: " - f"{MinimalisticFormat.format_user(ctx.author)}\n" - f"**Channel**: {base_user_format(ctx.channel)}") - - return ''.join(base) - - @staticmethod - def nick_change(member, previous: str, current: Optional[str], moderator=None, *, with_timestamp: bool = True): - if with_timestamp: - base = [f"[{format_timestamp(discord.utils.utcnow())}] "] - else: - base = [] - - if current and previous: - base.append(f"**Member Nickname Update**\n**Old Nickname**: {previous}\n**New Nickname**: {current}") - elif previous is None and current is not None: - base.append(f"**Member Nickname Add**\n**New Nickname**: {current}") - elif current is None and previous is not None: - base.append(f"**Member Nickname Removed**\n**Old Nickname**: {previous}") - - base.append(f"\n**Member**: {MinimalisticFormat.format_user(member)}") - - if moderator: - base.append(f"\n**Moderator**: {MinimalisticFormat.format_user(moderator)}") - - return ''.join(base) - - @staticmethod - def infraction_update(event: InfractionUpdateEvent, *, - with_timestamp: bool = True) -> str: - if with_timestamp: - base = [f"[{format_timestamp(discord.utils.utcnow())}] **Infraction Update**\n**ID**: {event.after.id}"] - else: - base = [f"**Infraction Update**\n**ID**: {event.after.id}"] - - if event.before.moderator_id != event.after.moderator_id: - base.append(f"\n**Old Moderator**: {MinimalisticFormat.format_user(event.before.moderator)}" - f"\n**New Moderator**: {MinimalisticFormat.format_user(event.after.moderator)}") - - if event.before.reason != event.after.reason: - base.append(f"\n**Old Reason**: {truncate_text(event.before.reason, limit=200)}" - f"\n**New Reason**: {truncate_text(event.after.reason, limit=200)}") - - return ''.join(base) - - @staticmethod - def infraction_delete(event: InfractionDeleteEvent, *, with_timestamp: bool = True): - if with_timestamp: - base = [f"[{format_timestamp(discord.utils.utcnow())}] "] - else: - base = [] - - base.append(f"**Infraction Delete**\n**ID**: {event.infraction.id}\n" - f"**Moderator**: {MinimalisticFormat.format_user(event.moderator)}") - - return ''.join(base) - - def format_message(self, *, with_timestamp: bool = True) -> str: - """Formats a log entry.""" - log_action = log_actions[str(self.log_action).lower()] - - if with_timestamp: - base = [f"[{format_timestamp(self.timestamp)}] "] - else: - base = [] - - base.append(f"**{log_action.title}** | Infraction ID {self.infraction_id}" - f"\n**User**: {self.format_user(self.target)}\n**Moderator**: {self.format_user(self.moderator)}") - - if self.expiry: - if isinstance(self.expiry, datetime): - # TZINFO should not be stripped at this point. - expy = discord.utils.format_dt(self.expiry) - else: - expy = self.expiry - - base.append(f"\n**Expiry**: {expy}") - - base.append(f"\n**Reason**: {escape_markdown_and_mentions(self.reason)}") - return ''.join(base) diff --git a/lightning/utils/modlogformats/moderation.py b/lightning/utils/modlogformats/moderation.py new file mode 100644 index 00000000..2d7011a8 --- /dev/null +++ b/lightning/utils/modlogformats/moderation.py @@ -0,0 +1,275 @@ +""" +Lightning.py - A Discord bot +Copyright (C) 2019-present Célveren + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation at version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Union + +import discord + +from lightning.events import (AuditLogTimeoutEvent, InfractionDeleteEvent, + InfractionUpdateEvent, TimedActionExpiredEvent) +from lightning.formatters import (base_user_format, + escape_markdown_and_mentions, truncate_text) +from lightning.models import Action +from lightning.utils.modlogformats.base import FormatContext, Renderer, renders +from lightning.utils.modlogformats.member import format_user +from lightning.utils.time import get_utc_timestamp + + +@dataclass +class CompactModAction: + tense: str + emoji: str + title: str + color: Union[discord.Color, int] + + +log_actions = { + "ban": CompactModAction("banned", "\N{NO ENTRY}", "Ban", 0xFF0000), + "kick": CompactModAction("kicked", "\N{WOMANS BOOTS}", "Kick", 0xFF7F50), + "warn": CompactModAction("warned", "\N{WARNING SIGN}", "Warn", 0xFFDF00), + "temprole": CompactModAction("temporarily restricted role to", "\N{TIMER CLOCK}\N{VARIATION SELECTOR-16}", + "Temporary Role", 0x607d8b), + "unban": CompactModAction("unbanned", "\N{WARNING SIGN}", "Unban", 0xB22222), + "timed_restriction_removed": CompactModAction("", "\N{WARNING SIGN}", "Timed restriction expired", 0x6B8E23), + "timeban": CompactModAction("temporarily banned", "\N{NO ENTRY}", "Timed Ban", 0xC7031E), + "mute": CompactModAction("muted", "\N{SPEAKER WITH CANCELLATION STROKE}", "Mute", 0x7c7b82), + "timemute": CompactModAction("temporarily muted", "\N{SPEAKER WITH CANCELLATION STROKE}", "Timed Mute", 0x7c7b82), + "unmute": CompactModAction("umuted", "\N{SPEAKER}", "Unmute", 0xFFFFFF), + "timeout": CompactModAction("timed out", "\N{SPEAKER WITH CANCELLATION STROKE}", "Timeout", 0x7c7b82) +} + + +# This one is keyed on Action instead of one of the event classes. Every moderation event +# (ban, kick, mute, automod, etc.) carries an Action, so the cog just hands that over. +@renders(Action) +class ModAction(Renderer[Action]): + def __init__(self, action: Action) -> None: + super().__init__(action) + if not action.is_logged(): + raise ValueError("Only actions that have been logged as an infraction can be rendered") + + self.info = log_actions[str(action.action).lower()] + self.reason = action.reason or "no reason given" + + def mentions(self): + return [self.event.target, self.event.moderator] + + def _target_with_expiry(self) -> str: + target = self.event.target + if self.event.expiry: + for_expiry = f" for {self.event.expiry}" + else: + for_expiry = "" + + if target is None: + return "" + + mention = target.mention if hasattr(target, 'mention') else f"<@!{target.id}>" + if isinstance(target, discord.Object): + return mention + return f"{mention}{for_expiry} | {discord.utils.escape_markdown(str(target))}" + + def emoji(self) -> str: + event = self.event + message = [f"{self.info.emoji} **{self.info.title}**: {event.moderator.mention} {self.info.tense} ", + self._target_with_expiry()] + + if hasattr(event.target, 'id'): + message.append(f"\n\N{LABEL} __User ID__: {event.target.id}") + + message.append(f"\n\N{PENCIL}\N{VARIATION SELECTOR-16} __Reason__: \"{self.reason}\"") + return ''.join(message) + + def minimal(self, ctx: FormatContext) -> str: + event = self.event + base = [f"{self.stamp(ctx, event.timestamp)}**{self.info.title}** | Infraction ID {event.infraction_id}" + f"\n**User**: {format_user(event.target)}\n**Moderator**: {format_user(event.moderator)}"] + + if event.expiry: + if isinstance(event.expiry, datetime): + # TZINFO should not be stripped at this point. + expy = discord.utils.format_dt(event.expiry) + else: + expy = event.expiry + + base.append(f"\n**Expiry**: {expy}") + + base.append(f"\n**Reason**: {escape_markdown_and_mentions(self.reason)}") + return ''.join(base) + + def embed(self) -> discord.Embed: + event = self.event + embed = discord.Embed(title=self.info.title, color=self.info.color) + reason = truncate_text(self.reason, 512) + + base = [f"**User**: {base_user_format(event.target)} <@!{event.target.id}>\n" + f"**Moderator**: {base_user_format(event.moderator)} <@!{event.moderator.id}>"] + + if event.expiry: + base.append(f"\n**Expiry**: {event.expiry}") + + base.append(f"\n**Reason**: {discord.utils.escape_markdown(reason)}") + embed.description = ''.join(base) + embed.set_footer(text=f"Infraction ID: {event.infraction_id}") + embed.timestamp = event.timestamp + return embed + + +@renders(TimedActionExpiredEvent) +class TimedActionExpired(Renderer[TimedActionExpiredEvent]): + def mentions(self): + return [self.event.user, self.event.moderator] + + def emoji(self) -> str: + event = self.event + action = event.action + msg = [f"\N{WARNING SIGN} **{action.capitalize()} expired**: <@!{event.user.id}>"] + + if hasattr(event.user, 'name'): + msg.append(f" | {discord.utils.escape_mentions(str(event.user))}") + + msg.append(f"\nTime{action} was made by <@!{event.moderator.id}>") + + if hasattr(event.moderator, 'name'): + msg.append(f" | {discord.utils.escape_mentions(str(event.moderator))}") + + msg.append(f" at {get_utc_timestamp(event.created_at)}") + return ''.join(msg) + + def minimal(self, ctx: FormatContext) -> str: + event = self.event + return f"{self.stamp(ctx, event.expiry)}**{event.action.capitalize()} expired**\n"\ + f"**User**: {format_user(event.user)}\n**Moderator**: {format_user(event.moderator)}"\ + f"\n**Created at**: {discord.utils.format_dt(event.created_at)}" + + def embed(self) -> discord.Embed: + event = self.event + embed = discord.Embed(description=f"Time {event.action} for {base_user_format(event.user)} expired") + embed.add_field(name="Moderator", value=base_user_format(event.moderator)) + embed.timestamp = event.created_at + embed.set_footer(text=f"Time {event.action} was made at") + return embed + + +@renders(AuditLogTimeoutEvent) +class TimeoutExpired(Renderer[AuditLogTimeoutEvent]): + def mentions(self): + return [self.event.member] + + def emoji(self) -> str: + event = self.event + text = [f"\N{WARNING SIGN} **Timeout expired** <@!{event.member.id}>"] + + if event.moderator: + text.append(f"\n\N{BLUE BOOK} __Moderator__: " + f"{escape_markdown_and_mentions(str(event.moderator))} ({event.moderator.id})") + + return ''.join(text) + + def minimal(self, ctx: FormatContext) -> str: + text = [f"{self.stamp(ctx)}**Timeout expired**\n**User**: {format_user(self.event.member)}\n"] + + if self.event.moderator: + text.append(f"**Moderator**: {format_user(self.event.moderator)}") + + return ''.join(text) + + def embed(self) -> discord.Embed: + embed = discord.Embed(description=f"Timeout for {base_user_format(self.event.member)} expired") + + if self.event.moderator: + embed.add_field(name="Moderator", value=base_user_format(self.event.moderator)) + + return embed + + +@renders(InfractionUpdateEvent) +class InfractionUpdate(Renderer[InfractionUpdateEvent]): + @property + def moderator_changed(self) -> bool: + return self.event.before.moderator_id != self.event.after.moderator_id + + @property + def reason_changed(self) -> bool: + return self.event.before.reason != self.event.after.reason + + def emoji(self) -> str: + event = self.event + base = [f"\N{MEMO} **Infraction update**: ID: {event.after.id}"] + + if self.moderator_changed: + base.append(f"\n__Old Moderator__: {escape_markdown_and_mentions(event.before.moderator)}" + f"\n__New Moderator__: {escape_markdown_and_mentions(event.after.moderator)}") + + if self.reason_changed: + base.append(f"\n__Old Reason__: {truncate_text(event.before.reason, limit=200)}" + f"\n__New Reason__: {truncate_text(event.after.reason, limit=200)}") + + return ''.join(base) + + def minimal(self, ctx: FormatContext) -> str: + event = self.event + base = [f"{self.stamp(ctx)}**Infraction Update**\n**ID**: {event.after.id}"] + + if self.moderator_changed: + base.append(f"\n**Old Moderator**: {format_user(event.before.moderator)}" + f"\n**New Moderator**: {format_user(event.after.moderator)}") + + if self.reason_changed: + base.append(f"\n**Old Reason**: {truncate_text(event.before.reason, limit=200)}" + f"\n**New Reason**: {truncate_text(event.after.reason, limit=200)}") + + return ''.join(base) + + def embed(self) -> discord.Embed: + event = self.event + embed = discord.Embed(color=discord.Color.yellow(), timestamp=discord.utils.utcnow(), title="Infraction Update") + embed.set_footer(text=f"Infraction ID: {event.after.id}") + + if self.moderator_changed: + embed.add_field(name="Moderator", + value=f"**Old**: {base_user_format(event.before.moderator)}\n**New**: " + f"{base_user_format(event.after.moderator)}") + + if self.reason_changed: + embed.add_field(name="Reason", + value=f"**Old**: {truncate_text(event.before.reason, limit=200)}\n**New**: " + f"{truncate_text(event.after.reason, limit=200)}") + + return embed + + +@renders(InfractionDeleteEvent) +class InfractionDelete(Renderer[InfractionDeleteEvent]): + def mentions(self): + return [self.event.moderator] + + def emoji(self) -> str: + return f"\N{PUT LITTER IN ITS PLACE SYMBOL} **Infraction deleted** "\ + f"{self.event.moderator.mention} deleted #{self.event.infraction.id}" + + def minimal(self, ctx: FormatContext) -> str: + return f"{self.stamp(ctx)}**Infraction Delete**\n**ID**: {self.event.infraction.id}\n"\ + f"**Moderator**: {format_user(self.event.moderator)}" + + def embed(self) -> discord.Embed: + return discord.Embed(color=discord.Color.red(), title="Infraction Delete", + description=f"**ID**: {self.event.infraction.id}\n**Moderator**: " + f"{base_user_format(self.event.moderator)}") From d08ae58103bb23af4263c68ddfbc15a96c384057 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:17:47 +0000 Subject: [PATCH 4/8] Address review feedback Co-authored-by: celveren <46062298+celveren@users.noreply.github.com> --- lightning/utils/modlogformats/base.py | 2 ++ lightning/utils/modlogformats/member.py | 4 ++-- lightning/utils/modlogformats/moderation.py | 7 ++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lightning/utils/modlogformats/base.py b/lightning/utils/modlogformats/base.py index 0f52fc65..97ed0fa4 100644 --- a/lightning/utils/modlogformats/base.py +++ b/lightning/utils/modlogformats/base.py @@ -41,6 +41,8 @@ def from_setting(cls, setting: str) -> FormatContext: """Builds a context from the format string we store in the database""" if setting in ("minimal with timestamp", "minimal without timestamp"): return cls("minimal", setting != "minimal without timestamp") + if setting not in ("emoji", "embed"): + raise ValueError(f"Unknown modlog format: {setting}") return cls(setting) # type: ignore diff --git a/lightning/utils/modlogformats/member.py b/lightning/utils/modlogformats/member.py index 2d07b4c4..581ac946 100644 --- a/lightning/utils/modlogformats/member.py +++ b/lightning/utils/modlogformats/member.py @@ -92,7 +92,7 @@ def mentions(self): def emoji(self) -> str: member = self.event.member return f"\N{PASSPORT CONTROL} **Member Completed Screening** {member.mention} | "\ - f"({escape_markdown_and_mentions(str(member))}" + f"{escape_markdown_and_mentions(str(member))}" def minimal(self, ctx: FormatContext) -> str: member = self.event.member @@ -154,7 +154,7 @@ def minimal(self, ctx: FormatContext) -> str: return ''.join(base) def embed(self) -> discord.Embed: - embed = discord.Embed(color=discord.Color.blurple(), timestamp=discord.utils.utcnow()) + embed = discord.Embed(color=discord.Color.blurple(), timestamp=discord.utils.utcnow(), description="") if self.current and self.previous: embed.title = "Member Nickname Update" diff --git a/lightning/utils/modlogformats/moderation.py b/lightning/utils/modlogformats/moderation.py index 2d7011a8..c5ec3f52 100644 --- a/lightning/utils/modlogformats/moderation.py +++ b/lightning/utils/modlogformats/moderation.py @@ -73,14 +73,11 @@ def mentions(self): def _target_with_expiry(self) -> str: target = self.event.target - if self.event.expiry: - for_expiry = f" for {self.event.expiry}" - else: - for_expiry = "" - if target is None: return "" + for_expiry = f" for {self.event.expiry}" if self.event.expiry else "" + mention = target.mention if hasattr(target, 'mention') else f"<@!{target.id}>" if isinstance(target, discord.Object): return mention From 6099beed8b0cc49a1b02e534921efc1c4b28cd47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:44:58 +0000 Subject: [PATCH 5/8] Add modlogformats tests Co-authored-by: celveren <46062298+celveren@users.noreply.github.com> --- tests/test_modlogformats.py | 192 ++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 tests/test_modlogformats.py diff --git a/tests/test_modlogformats.py b/tests/test_modlogformats.py new file mode 100644 index 00000000..0835dd53 --- /dev/null +++ b/tests/test_modlogformats.py @@ -0,0 +1,192 @@ +import unittest +from datetime import datetime, timezone +from types import SimpleNamespace + +import discord + +from lightning.events import (CommandEvent, InfractionEvent, + LightningAutoModInfractionEvent, MemberJoinEvent, + MemberLeaveEvent, MemberRolesUpdateEvent, + MemberScreeningEvent, MemberUpdateEvent, + TimedActionExpiredEvent) +from lightning.models import Action +from lightning.utils.modlogformats import (FormatContext, ModAction, + NickChange, Renderer, RoleChange, + get_renderer, renders) + +NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) +SETTINGS = ("emoji", "minimal with timestamp", "minimal without timestamp", "embed") + + +class FakeUser: + def __init__(self, id=1, name="Light", nick=None): + self.id = id + self.name = name + self.nick = nick + self.mention = f"<@{id}>" + self.created_at = NOW + self.joined_at = NOW + self.display_avatar = SimpleNamespace(url="https://example.com/avatar.png") + + def __str__(self): + return self.name + + +def make_action(**kwargs): + action = Action(1, "ban", FakeUser(2, "Target"), FakeUser(3, "Mod"), "being rude", **kwargs) + action.infraction_id = 10 + return action + + +class TestFormatContext(unittest.TestCase): + def test_from_setting(self): + # Only the minimal format cares about timestamps, so the others should always keep the default + cases = { + "emoji": ("emoji", True), + "embed": ("embed", True), + "minimal with timestamp": ("minimal", True), + "minimal without timestamp": ("minimal", False), + } + for setting, (fmt, with_timestamp) in cases.items(): + with self.subTest(setting=setting): + ctx = FormatContext.from_setting(setting) + self.assertEqual(ctx.fmt, fmt) + self.assertEqual(ctx.with_timestamp, with_timestamp) + + def test_unknown_setting_raises(self): + with self.assertRaises(ValueError): + FormatContext.from_setting("definitely not a format") + + +class TestRegistry(unittest.TestCase): + def test_every_event_has_a_renderer(self): + user = FakeUser() + events = ( + MemberJoinEvent(user), MemberLeaveEvent(user), MemberScreeningEvent(user), + MemberUpdateEvent(user, user, None), MemberRolesUpdateEvent(user, user, None), + TimedActionExpiredEvent("ban", user, user, NOW, NOW), make_action(), + CommandEvent(SimpleNamespace(command=SimpleNamespace(qualified_name="ping"), author=user, + message=SimpleNamespace(created_at=NOW), + channel=SimpleNamespace(id=5, name="general", mention="<#5>"))) + ) + for event in events: + with self.subTest(event=type(event).__name__): + self.assertIsInstance(get_renderer(event), Renderer) + + def test_unknown_event_raises(self): + with self.assertRaises(LookupError): + get_renderer(object()) + + def test_subclass_beats_parent(self): + # MemberRolesUpdateEvent is a MemberUpdateEvent, but it has its own renderer + user = FakeUser() + self.assertIsInstance(get_renderer(MemberUpdateEvent(user, user, None)), NickChange) + self.assertIsInstance(get_renderer(MemberRolesUpdateEvent(user, user, None)), RoleChange) + + def test_subclass_falls_back_to_parent(self): + class Child(MemberJoinEvent): + __slots__ = () + + self.assertEqual(type(get_renderer(Child(FakeUser()))).__name__, "MemberJoin") + + def test_renders_registers_class(self): + class Thing: + pass + + @renders(Thing) + class ThingRenderer(Renderer[Thing]): + pass + + self.assertIsInstance(get_renderer(Thing()), ThingRenderer) + + def test_automod_event_uses_action_renderer(self): + # The cog hands over event.action, so make sure automod events still end up at ModAction + self.assertTrue(issubclass(LightningAutoModInfractionEvent, InfractionEvent)) + self.assertIsInstance(get_renderer(make_action()), ModAction) + + +class TestRendering(unittest.TestCase): + def test_every_format_renders(self): + user = FakeUser(nick="Old") + renderers = ( + get_renderer(MemberJoinEvent(user)), get_renderer(MemberLeaveEvent(user)), + get_renderer(MemberScreeningEvent(user)), get_renderer(MemberUpdateEvent(user, user, None)), + get_renderer(TimedActionExpiredEvent("ban", user, user, NOW, NOW)), get_renderer(make_action()) + ) + for renderer in renderers: + for setting in SETTINGS: + with self.subTest(renderer=type(renderer).__name__, setting=setting): + result = renderer.render(FormatContext.from_setting(setting)) + expected = discord.Embed if setting == "embed" else str + self.assertIsInstance(result, expected) + + def test_timestamp_only_when_asked(self): + renderer = get_renderer(MemberLeaveEvent(FakeUser())) + with_timestamp = renderer.render(FormatContext("minimal", True)) + without_timestamp = renderer.render(FormatContext("minimal", False)) + self.assertTrue(with_timestamp.startswith("[")) + self.assertFalse(without_timestamp.startswith("[")) + self.assertTrue(with_timestamp.endswith(without_timestamp)) + + def test_mod_action_mentions(self): + # The cog decides to only use these for the emoji format, renderers just report who's involved + action = make_action() + self.assertEqual(get_renderer(action).mentions(), [action.target, action.moderator]) + + def test_timed_action_has_same_fields_in_every_format(self): + # Used to be a different argument order for the embed format + user, mod = FakeUser(2, "Target"), FakeUser(3, "Mod") + renderer = get_renderer(TimedActionExpiredEvent("ban", user, mod, NOW, NOW)) + self.assertIn("Target", renderer.minimal(FormatContext("minimal"))) + embed = renderer.embed() + self.assertIn("Target", embed.description) + self.assertIn("Mod", embed.fields[0].value) + + def test_mod_action_requires_logged_infraction(self): + action = Action(1, "ban", FakeUser(), FakeUser(2), "reason") + with self.assertRaises(ValueError): + ModAction(action) + + def test_mod_action_expiry_shows_in_minimal(self): + renderer = get_renderer(make_action(expiry=NOW)) + self.assertIn("**Expiry**", renderer.minimal(FormatContext("minimal"))) + + +class TestNickChange(unittest.TestCase): + def render(self, before, after, setting="embed"): + member = FakeUser(nick=after) + event = MemberUpdateEvent(FakeUser(nick=before), member, None) + return get_renderer(event).render(FormatContext.from_setting(setting)) + + def test_titles(self): + cases = { + ("old", "new"): "Member Nickname Update", + (None, "new"): "Member Nickname Add", + ("old", None): "Member Nickname Removed", + } + for (before, after), title in cases.items(): + with self.subTest(before=before, after=after): + self.assertEqual(self.render(before, after).title, title) + + def test_no_nicknames_does_not_raise(self): + # Neither nickname set used to blow up on a None description + for setting in SETTINGS: + with self.subTest(setting=setting): + self.assertIsNotNone(self.render(None, None, setting)) + + def test_moderator_is_shown(self): + member = FakeUser(nick="new") + event = MemberUpdateEvent(FakeUser(nick="old"), member, None) + event.moderator = FakeUser(3, "Mod") + embed = get_renderer(event).embed() + self.assertEqual(embed.fields[0].name, "Moderator") + + +class TestEmojiOutput(unittest.TestCase): + def test_screening_has_balanced_parentheses(self): + text = get_renderer(MemberScreeningEvent(FakeUser())).emoji() + self.assertEqual(text.count("("), text.count(")")) + + +if __name__ == "__main__": + unittest.main() From fb41b4607ea6e04724dee3cb9f6d0c02d5f527c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:50:34 +0000 Subject: [PATCH 6/8] List __all__ entries individually Co-authored-by: celveren <46062298+celveren@users.noreply.github.com> --- lightning/utils/modlogformats/__init__.py | 27 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/lightning/utils/modlogformats/__init__.py b/lightning/utils/modlogformats/__init__.py index 4663de7d..ee7c4e16 100644 --- a/lightning/utils/modlogformats/__init__.py +++ b/lightning/utils/modlogformats/__init__.py @@ -35,8 +35,27 @@ log_actions) __all__ = ( - "CommandRan", "CompactModAction", "FormatContext", "FormatName", "InfractionDelete", "InfractionUpdate", - "MemberJoin", "MemberLeave", "MemberScreening", "ModAction", "NickChange", "Renderer", "RoleChange", - "TimedActionExpired", "TimeoutExpired", "action_format", "base_user_format", "construct_dm_message", - "escape_markdown_and_mentions", "format_timestamp", "get_renderer", "log_actions", "renders" + "CommandRan", + "CompactModAction", + "FormatContext", + "FormatName", + "InfractionDelete", + "InfractionUpdate", + "MemberJoin", + "MemberLeave", + "MemberScreening", + "ModAction", + "NickChange", + "Renderer", + "RoleChange", + "TimedActionExpired", + "TimeoutExpired", + "action_format", + "base_user_format", + "construct_dm_message", + "escape_markdown_and_mentions", + "format_timestamp", + "get_renderer", + "log_actions", + "renders", ) From 9a19878ec9c1ec5fbd16cb16a168ff7b9c61a463 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:04:43 +0000 Subject: [PATCH 7/8] Move modlogformats from lightning/utils to lightning/ Co-authored-by: celveren <46062298+celveren@users.noreply.github.com> --- lightning/cogs/emoji.py | 2 +- lightning/cogs/infractions/cog.py | 2 +- lightning/cogs/infractions/ui.py | 2 +- lightning/cogs/mod/cog.py | 4 +-- lightning/cogs/modlog/cog.py | 4 +-- lightning/cogs/reports/ui.py | 2 +- lightning/cogs/stats.py | 2 +- .../{utils => }/modlogformats/__init__.py | 25 ++++++++----------- lightning/{utils => }/modlogformats/base.py | 0 .../{utils => }/modlogformats/commands.py | 4 +-- lightning/{utils => }/modlogformats/member.py | 2 +- .../{utils => }/modlogformats/moderation.py | 4 +-- tests/test_modlogformats.py | 6 ++--- 13 files changed, 28 insertions(+), 31 deletions(-) rename lightning/{utils => }/modlogformats/__init__.py (60%) rename lightning/{utils => }/modlogformats/base.py (100%) rename lightning/{utils => }/modlogformats/commands.py (92%) rename lightning/{utils => }/modlogformats/member.py (99%) rename lightning/{utils => }/modlogformats/moderation.py (98%) diff --git a/lightning/cogs/emoji.py b/lightning/cogs/emoji.py index c790ea3e..317964f2 100644 --- a/lightning/cogs/emoji.py +++ b/lightning/cogs/emoji.py @@ -26,8 +26,8 @@ from lightning import (CommandLevel, GuildContext, LightningBot, LightningCog, LightningContext, command, errors, group) from lightning.converters import EmojiRE, Whitelisted_URL +from lightning.modlogformats import action_format from lightning.utils.checks import has_guild_permissions -from lightning.utils.modlogformats import action_format log = logging.getLogger(__name__) diff --git a/lightning/cogs/infractions/cog.py b/lightning/cogs/infractions/cog.py index 9980b32b..5fe2756a 100644 --- a/lightning/cogs/infractions/cog.py +++ b/lightning/cogs/infractions/cog.py @@ -43,9 +43,9 @@ from lightning.errors import LightningCommandError from lightning.events import InfractionDeleteEvent, InfractionUpdateEvent from lightning.models import InfractionRecord +from lightning.modlogformats import base_user_format from lightning.utils.checks import hybrid_guild_permissions, is_server_manager from lightning.utils.helpers import ticker -from lightning.utils.modlogformats import base_user_format from lightning.utils.time import add_tzinfo matplotlib.use('Agg') diff --git a/lightning/cogs/infractions/ui.py b/lightning/cogs/infractions/ui.py index a23df6fa..214731e8 100644 --- a/lightning/cogs/infractions/ui.py +++ b/lightning/cogs/infractions/ui.py @@ -24,7 +24,7 @@ from lightning.enums import ActionType from lightning.formatters import truncate_text from lightning.models import InfractionRecord -from lightning.utils.modlogformats import base_user_format +from lightning.modlogformats import base_user_format from lightning.utils.paginator import Paginator from lightning.utils.time import add_tzinfo diff --git a/lightning/cogs/mod/cog.py b/lightning/cogs/mod/cog.py index 87947633..e60ade31 100644 --- a/lightning/cogs/mod/cog.py +++ b/lightning/cogs/mod/cog.py @@ -29,7 +29,7 @@ from lightning import (CommandLevel, GuildContext, LightningBot, LightningCog, LightningContext, cache, command, converters, group, - hybrid_command) + hybrid_command, modlogformats) from lightning.cogs.mod.converters import BannedMember from lightning.cogs.mod.flags import BanFlags, DefaultModFlags, PurgeFlags from lightning.constants import COMMON_HOIST_CHARACTERS @@ -38,7 +38,7 @@ from lightning.events import InfractionEvent from lightning.formatters import plural, truncate_text from lightning.models import GuildModConfig, PartialGuild, Timer -from lightning.utils import helpers, modlogformats +from lightning.utils import helpers from lightning.utils.checks import (has_channel_permissions, has_guild_permissions, hybrid_guild_permissions) diff --git a/lightning/cogs/modlog/cog.py b/lightning/cogs/modlog/cog.py index 22ec534a..5464034f 100644 --- a/lightning/cogs/modlog/cog.py +++ b/lightning/cogs/modlog/cog.py @@ -23,7 +23,8 @@ from discord.ext import commands from lightning import (CommandLevel, GuildContext, LightningBot, LightningCog, - LightningContext, LoggingType, hybrid_group) + LightningContext, LoggingType, hybrid_group, + modlogformats) from lightning.cache import Strategy, cached from lightning.cogs.modlog import ui from lightning.cogs.modlog.utils import human_friendly_log_names @@ -33,7 +34,6 @@ MemberScreeningEvent, TimedActionExpiredEvent) from lightning.formatters import truncate_text from lightning.models import LoggingConfig, PartialGuild -from lightning.utils import modlogformats from lightning.utils.checks import hybrid_guild_permissions, is_server_manager from lightning.utils.emitters import TextChannelEmitter from lightning.utils.time import ShortTime diff --git a/lightning/cogs/reports/ui.py b/lightning/cogs/reports/ui.py index 9ef51b6e..f487ad59 100644 --- a/lightning/cogs/reports/ui.py +++ b/lightning/cogs/reports/ui.py @@ -32,8 +32,8 @@ from lightning.enums import ActionType, AutoModPunishmentType from lightning.errors import LightningError from lightning.formatters import truncate_text +from lightning.modlogformats import construct_dm_message from lightning.ui import ExitableMenu, MenuLikeView, UpdateableMenu, _BaseView -from lightning.utils.modlogformats import construct_dm_message from lightning.utils.time import FutureTime, add_tzinfo if TYPE_CHECKING: diff --git a/lightning/cogs/stats.py b/lightning/cogs/stats.py index 7e4844a9..36953ef0 100644 --- a/lightning/cogs/stats.py +++ b/lightning/cogs/stats.py @@ -34,9 +34,9 @@ from lightning.constants import LIGHTNING_COLOR from lightning.converters import InbetweenNumber from lightning.models import ActionType +from lightning.modlogformats import base_user_format from lightning.utils.checks import has_guild_permissions from lightning.utils.emitters import WebhookEmbedEmitter -from lightning.utils.modlogformats import base_user_format from lightning.utils.time import natural_timedelta if TYPE_CHECKING: diff --git a/lightning/utils/modlogformats/__init__.py b/lightning/modlogformats/__init__.py similarity index 60% rename from lightning/utils/modlogformats/__init__.py rename to lightning/modlogformats/__init__.py index ee7c4e16..3cc80f1b 100644 --- a/lightning/utils/modlogformats/__init__.py +++ b/lightning/modlogformats/__init__.py @@ -18,21 +18,18 @@ construct_dm_message, escape_markdown_and_mentions, format_timestamp) -from lightning.utils.modlogformats.base import (FormatContext, FormatName, - Renderer, get_renderer, - renders) +from lightning.modlogformats.base import (FormatContext, FormatName, Renderer, + get_renderer, renders) # Importing these is what registers the renderers with @renders -from lightning.utils.modlogformats.commands import CommandRan -from lightning.utils.modlogformats.member import (MemberJoin, MemberLeave, - MemberScreening, NickChange, - RoleChange) -from lightning.utils.modlogformats.moderation import (CompactModAction, - InfractionDelete, - InfractionUpdate, - ModAction, - TimedActionExpired, - TimeoutExpired, - log_actions) +from lightning.modlogformats.commands import CommandRan +from lightning.modlogformats.member import (MemberJoin, MemberLeave, + MemberScreening, NickChange, + RoleChange) +from lightning.modlogformats.moderation import (CompactModAction, + InfractionDelete, + InfractionUpdate, ModAction, + TimedActionExpired, + TimeoutExpired, log_actions) __all__ = ( "CommandRan", diff --git a/lightning/utils/modlogformats/base.py b/lightning/modlogformats/base.py similarity index 100% rename from lightning/utils/modlogformats/base.py rename to lightning/modlogformats/base.py diff --git a/lightning/utils/modlogformats/commands.py b/lightning/modlogformats/commands.py similarity index 92% rename from lightning/utils/modlogformats/commands.py rename to lightning/modlogformats/commands.py index 617e14d8..1a0b12c6 100644 --- a/lightning/utils/modlogformats/commands.py +++ b/lightning/modlogformats/commands.py @@ -20,8 +20,8 @@ from lightning.events import CommandEvent from lightning.formatters import base_user_format -from lightning.utils.modlogformats.base import FormatContext, Renderer, renders -from lightning.utils.modlogformats.member import format_user +from lightning.modlogformats.base import FormatContext, Renderer, renders +from lightning.modlogformats.member import format_user @renders(CommandEvent) diff --git a/lightning/utils/modlogformats/member.py b/lightning/modlogformats/member.py similarity index 99% rename from lightning/utils/modlogformats/member.py rename to lightning/modlogformats/member.py index 581ac946..452c32eb 100644 --- a/lightning/utils/modlogformats/member.py +++ b/lightning/modlogformats/member.py @@ -22,8 +22,8 @@ MemberRolesUpdateEvent, MemberScreeningEvent, MemberUpdateEvent) from lightning.formatters import base_user_format, escape_markdown_and_mentions +from lightning.modlogformats.base import FormatContext, Renderer, renders from lightning.utils.helpers import Emoji -from lightning.utils.modlogformats.base import FormatContext, Renderer, renders from lightning.utils.time import natural_timedelta diff --git a/lightning/utils/modlogformats/moderation.py b/lightning/modlogformats/moderation.py similarity index 98% rename from lightning/utils/modlogformats/moderation.py rename to lightning/modlogformats/moderation.py index c5ec3f52..efec5ce7 100644 --- a/lightning/utils/modlogformats/moderation.py +++ b/lightning/modlogformats/moderation.py @@ -27,8 +27,8 @@ from lightning.formatters import (base_user_format, escape_markdown_and_mentions, truncate_text) from lightning.models import Action -from lightning.utils.modlogformats.base import FormatContext, Renderer, renders -from lightning.utils.modlogformats.member import format_user +from lightning.modlogformats.base import FormatContext, Renderer, renders +from lightning.modlogformats.member import format_user from lightning.utils.time import get_utc_timestamp diff --git a/tests/test_modlogformats.py b/tests/test_modlogformats.py index 0835dd53..517bfe5a 100644 --- a/tests/test_modlogformats.py +++ b/tests/test_modlogformats.py @@ -10,9 +10,9 @@ MemberScreeningEvent, MemberUpdateEvent, TimedActionExpiredEvent) from lightning.models import Action -from lightning.utils.modlogformats import (FormatContext, ModAction, - NickChange, Renderer, RoleChange, - get_renderer, renders) +from lightning.modlogformats import (FormatContext, ModAction, NickChange, + Renderer, RoleChange, get_renderer, + renders) NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) SETTINGS = ("emoji", "minimal with timestamp", "minimal without timestamp", "embed") From 2f93555f73eb14206b46be95f6aa0fd9ae57b8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9lveren?= Date: Wed, 7 Oct 2026 01:35:29 -0500 Subject: [PATCH 8/8] Update InfractionUpdate renderer to mention moderators in emoji format --- lightning/modlogformats/moderation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/modlogformats/moderation.py b/lightning/modlogformats/moderation.py index efec5ce7..7efcbb07 100644 --- a/lightning/modlogformats/moderation.py +++ b/lightning/modlogformats/moderation.py @@ -212,8 +212,8 @@ def emoji(self) -> str: base = [f"\N{MEMO} **Infraction update**: ID: {event.after.id}"] if self.moderator_changed: - base.append(f"\n__Old Moderator__: {escape_markdown_and_mentions(event.before.moderator)}" - f"\n__New Moderator__: {escape_markdown_and_mentions(event.after.moderator)}") + base.append(f"\n__Old Moderator__: <@!{event.before.moderator_id}>" + f"\n__New Moderator__: <@!{event.after.moderator_id}>") if self.reason_changed: base.append(f"\n__Old Reason__: {truncate_text(event.before.reason, limit=200)}"