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 415306a3..5464034f 100644
--- a/lightning/cogs/modlog/cog.py
+++ b/lightning/cogs/modlog/cog.py
@@ -23,15 +23,17 @@
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
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
from lightning.utils.checks import hybrid_guild_permissions, is_server_manager
from lightning.utils.emitters import TextChannelEmitter
from lightning.utils.time import ShortTime
@@ -147,6 +149,28 @@ async def get_records(self, guild: Union[discord.Guild, int], feature: int):
yield emitter, rec
+ # 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
+
+ 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
+ mentions = renderer.mentions()
+ if ctx.fmt == "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 +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):
- 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)
+ await self._emit(emitter, record, CommandEvent(ctx))
async def handle_automod_events(self, event_name: str, event: LightningAutoModInfractionEvent):
parts = []
@@ -181,22 +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)):
- 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)
+ await self._emit(emitter, record, event.action, extra_embeds=[msg_embed] if msg_embed else None)
# Moderation
@LightningCog.listener('on_lightning_member_warn')
@@ -217,91 +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)):
- 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)
+ 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()}")):
- 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)
+ 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):
- 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
+ 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):
- 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)
+ 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):
- 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)
+ await self._emit(emitter, record, event)
@LightningCog.listener()
async def on_lightning_member_role_change(self, event: MemberRolesUpdateEvent):
@@ -315,68 +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):
- 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)
+ 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):
- 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)
+ 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):
- 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])
+ 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):
- 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])
+ await self._emit(emitter, record, event)
@LightningCog.listener()
async def on_lightning_guild_alert(self, guild_id: int, message: str):
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/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/modlogformats/__init__.py b/lightning/modlogformats/__init__.py
new file mode 100644
index 00000000..3cc80f1b
--- /dev/null
+++ b/lightning/modlogformats/__init__.py
@@ -0,0 +1,58 @@
+"""
+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 lightning.formatters import (action_format, base_user_format,
+ construct_dm_message,
+ escape_markdown_and_mentions,
+ format_timestamp)
+from lightning.modlogformats.base import (FormatContext, FormatName, Renderer,
+ get_renderer, renders)
+# Importing these is what registers the renderers with @renders
+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",
+ "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/modlogformats/base.py b/lightning/modlogformats/base.py
new file mode 100644
index 00000000..97ed0fa4
--- /dev/null
+++ b/lightning/modlogformats/base.py
@@ -0,0 +1,111 @@
+"""
+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 typing import Callable, Generic, Literal, Type, TypeVar, Union
+
+import discord
+
+from lightning.formatters import format_timestamp
+
+FormatName = Literal["emoji", "minimal", "embed"]
+
+
+@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
+
+ @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")
+ if setting not in ("emoji", "embed"):
+ raise ValueError(f"Unknown modlog format: {setting}")
+ return cls(setting) # type: ignore
+
+
+E = TypeVar("E")
+
+
+class Renderer(Generic[E]):
+ """Base class for a modlog renderer. Subclasses are registered with :func:`renders`.
+
+ One renderer handles one kind of event, and knows how to show it in every format.
+ """
+ def __init__(self, event: E) -> None:
+ self.event = event
+
+ def mentions(self) -> list:
+ """Who the emoji format is allowed to ping."""
+ return []
+
+ def emoji(self) -> str:
+ raise NotImplementedError
+
+ def minimal(self, ctx: FormatContext) -> str:
+ raise NotImplementedError
+
+ 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 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()
+
+
+# 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 renders(event_type: type) -> Callable[[_R], _R]:
+ def decorator(cls: _R) -> _R:
+ _renderers[event_type] = cls
+ return cls
+ return decorator
+
+
+def get_renderer(event) -> Renderer:
+ """Finds the renderer for an event.
+
+ 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.
+ """
+ for cls in type(event).__mro__:
+ if cls in _renderers:
+ return _renderers[cls](event)
+
+ raise LookupError(f"No modlog renderer registered for {type(event).__name__}")
diff --git a/lightning/modlogformats/commands.py b/lightning/modlogformats/commands.py
new file mode 100644
index 00000000..1a0b12c6
--- /dev/null
+++ b/lightning/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.modlogformats.base import FormatContext, Renderer, renders
+from lightning.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/modlogformats/member.py b/lightning/modlogformats/member.py
new file mode 100644
index 00000000..452c32eb
--- /dev/null
+++ b/lightning/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.modlogformats.base import FormatContext, Renderer, renders
+from lightning.utils.helpers import Emoji
+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(), description="")
+
+ 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/modlogformats/moderation.py b/lightning/modlogformats/moderation.py
new file mode 100644
index 00000000..7efcbb07
--- /dev/null
+++ b/lightning/modlogformats/moderation.py
@@ -0,0 +1,272 @@
+"""
+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.modlogformats.base import FormatContext, Renderer, renders
+from lightning.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 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
+ 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__: <@!{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)}"
+ 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)}")
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/tests/test_modlogformats.py b/tests/test_modlogformats.py
new file mode 100644
index 00000000..517bfe5a
--- /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.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()