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()