diff --git a/automod/__init__.py b/automod/__init__.py new file mode 100644 index 0000000..454bffd --- /dev/null +++ b/automod/__init__.py @@ -0,0 +1,12 @@ +import json +from pathlib import Path + +from .automod import AutoMod + +with open(Path(__file__).parent / "info.json") as fp: + __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] + + +async def setup(bot): + cog = AutoMod(bot) + await bot.add_cog(cog) diff --git a/automod/automod.py b/automod/automod.py new file mode 100644 index 0000000..fe131da --- /dev/null +++ b/automod/automod.py @@ -0,0 +1,244 @@ +from red_commons.logging import getLogger +from redbot.core import Config, commands + +from .converters import AutoModActionFlags, AutoModRuleFlags, AutoModTriggerFlags +from .menus import ( + AutoModActionsPages, + AutoModRulePages, + AutoModTriggersPages, + BaseMenu, + ConfirmView, +) + +log = getLogger("red.trusty-cogs.automod") + + +class AutoMod(commands.Cog): + """ + Interact with and view discord's automod + """ + + __author__ = ["TrustyJAID"] + __version__ = "1.0.4" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, 218773382617890828) + self.config.register_guild(actions={}, triggers={}, rules={}) + self._commit = "" + self._repo = "" + + def format_help_for_context(self, ctx: commands.Context) -> str: + """ + Thanks Sinbad! + """ + pre_processed = super().format_help_for_context(ctx) + ret = f"{pre_processed}\n\n- Cog Version: {self.__version__}\n" + # we'll only have a repo if the cog was installed through Downloader at some point + if self._repo: + ret += f"- Repo: {self._repo}\n" + # we should have a commit if we have the repo but just incase + if self._commit: + ret += f"- Commit: [{self._commit[:9]}]({self._repo}/tree/{self._commit})" + return ret + + async def cog_before_invoke(self, ctx: commands.Context): + await self._get_commit() + + async def _get_commit(self): + if self._repo: + return + downloader = self.bot.get_cog("Downloader") + if not downloader: + return + cogs = await downloader.installed_cogs() + for cog in cogs: + if cog.name == "automod": + if cog.repo is not None: + self._repo = cog.repo.clean_url + self._commit = cog.commit + + @commands.hybrid_group(name="automod") + @commands.guild_only() + async def automod(self, ctx: commands.Context): + """Commnads for interacting with automod""" + + @automod.command(name="rules", aliases=["list", "rule", "view"]) + @commands.bot_has_permissions(manage_guild=True) + @commands.mod_or_permissions(manage_guild=True) + async def view_automod(self, ctx: commands.Context): + """View the servers current automod rules""" + rules = await ctx.guild.fetch_automod_rules() + if len(rules) <= 0: + await ctx.send("There are no rules setup yet.") + return + pages = AutoModRulePages(rules, guild=ctx.guild) + await BaseMenu(pages, self).start(ctx) + + @automod.command(name="actions", aliases=["action"]) + @commands.mod_or_permissions(manage_guild=True) + async def view_automod_actions(self, ctx: commands.Context): + """View the servers saved automod actions""" + actions_dict = await self.config.guild(ctx.guild).actions() + actions = [] + for k, v in actions_dict.items(): + v.update({"name": k, "guild": ctx.guild}) + actions.append(v) + if len(actions) <= 0: + await ctx.send("There are no actions saved.") + return + pages = AutoModActionsPages(actions, guild=ctx.guild) + await BaseMenu(pages, self).start(ctx) + + @automod.command(name="triggers", aliases=["trigger"]) + @commands.mod_or_permissions(manage_guild=True) + async def view_automod_triggers(self, ctx: commands.Context): + """View the servers saved automod triggers""" + triggers_dict = await self.config.guild(ctx.guild).triggers() + triggers = [] + for k, v in triggers_dict.items(): + v.update({"name": k, "guild": ctx.guild}) + triggers.append(v) + if len(triggers) <= 0: + await ctx.send("There are no triggers saved.") + return + pages = AutoModTriggersPages(triggers, guild=ctx.guild) + await BaseMenu(pages, self).start(ctx) + + @automod.group(name="create", aliases=["c"]) + @commands.admin_or_permissions(manage_guild=True) + async def create(self, ctx: commands.Context): + """Create automod rules, triggers, and actions""" + + @create.command(name="rule") + @commands.bot_has_permissions(manage_guild=True) + @commands.admin_or_permissions(manage_guild=True) + async def create_automod_rule( + self, ctx: commands.Context, name: str, *, rule: AutoModRuleFlags + ): + """ + Create an automod rule in the server + - `` The name of the rule for future reference. + - `` What the rule will do using the following information. + Usage: + - `trigger:` The name of a saved trigger. + - `actions:` The name(s) of saved actions. + - `enabled:` yes/true/t to enable this rule right away. + - `roles:` The roles that are exempt from this rule. + - `channels:` The channels that are exempt from this rule. + - `reason:` An optional reason for creating this rule. + + Example: + `[p]automod create rule rule_name trigger: mytrigger actions: timeoutuser notifymods enabled: true roles: @mods` + Will create an automod rule with the saved trigger `mytrigger` and + the saved actions `timeoutuser` and `notifymods`. + """ + if not name: + await ctx.send_help() + return + log.debug(f"{rule.to_args()}") + if not rule.trigger: + await ctx.send("No trigger was provided for the rule.") + return + rule_args = rule.to_args() + name = name.lower() + if rule_args.get("reason") is not None: + rule_args["reason"] = f"Created by {ctx.author}\n" + rule_args["reason"] + try: + rule = await ctx.guild.create_automod_rule(name=name, **rule_args) + except Exception as e: + rule_args_str = "\n".join(f"- {k}: {v}" for k, v in rule_args.items()) + await ctx.send( + ( + "There was an error creating a rule with the following rules:\n" + f"Error: {e}\n" + f"Name: {name}\n" + f"Rules:\n{rule_args_str}" + ) + ) + return + pages = AutoModRulePages([rule], guild=ctx.guild) + await BaseMenu(pages, self).start(ctx) + + @create.command(name="action", aliases=["a"]) + @commands.admin_or_permissions(manage_guild=True) + async def create_automod_action( + self, ctx: commands.Context, name: str, *, action: AutoModActionFlags + ): + """ + Create a saved action for use in automod Rules. + + - `` The name of this action for reference later. + Usage: `` + - `message:` The message to send to a user when triggered. + - `channel:` The channel to send a notification to. + - `duration:` How long to timeout the user for. Max 28 days. + Only one of these options can be applied at a time. + Examples: + `[p]automod create action grumpyuser message: You're being too grumpy` + `[p]automod create action notifymods channel: #modlog` + `[p]automod create action 2hrtimeout duration: 2 hours` + + """ + try: + action.get_action() + except ValueError as e: + # d.py errors here are concise enough to explain the issue. + await ctx.send(e) + return + name = name.lower() + async with self.config.guild(ctx.guild).actions() as actions: + if name in actions: + pred = ConfirmView(ctx.author) + pred.message = await ctx.send( + f"An action with the name `{name}` already exists. Would you like to overwrite it?", + view=pred, + ) + await pred.wait() + if not pred.result: + await ctx.send("Please choose a different name then.") + return + actions[name] = action.to_json() + await ctx.send(f"Saving action `{name}` with the following settings:\n{action.to_str()}") + + @create.command(name="trigger") + @commands.admin_or_permissions(manage_guild=True) + async def create_automod_trigger( + self, ctx: commands.Context, name: str, *, trigger: AutoModTriggerFlags + ): + """ + Create a saved trigger for use in automod Rules. + + - `` The name of this trigger for reference later. + Usage: `` + - `allows:` A space separated list of words to allow. + - `keywords:` A space separated list of words to filter. + - `mentions:` The number of user/role mentions that would trigger this rule (0-50). + - `presets:` Any combination of discord presets. e.g. `profanity`, `sexual_content`, or `slurs`. + - `regex:` A space separated list of regex patterns to include. + Note: If you want to use `mentions` you cannot also use `presets`, `keywords` or + `regex` in the same trigger. Likewise if you use any `presets` you cannot + use `keywords`, `regex`, or `mentions`. + Examples: + `[p]automod create trigger mytrigger regex: ^b(a|@)dw(o|0)rd(s|5)$` + """ + try: + trigger.get_trigger() + except ValueError as e: + # d.py errors here are concise enough to explain the issue. + await ctx.send(e) + return + name = name.lower() + async with self.config.guild(ctx.guild).triggers() as triggers: + if name in triggers: + pred = ConfirmView(ctx.author) + pred.message = await ctx.send( + f"A trigger with the name `{name}` already exists. Would you like to overwrite it?", + view=pred, + ) + await pred.wait() + if not pred.result: + await ctx.send("Please choose a different name then.") + return + triggers[name] = trigger.to_json() + await ctx.send(f"Saving trigger `{name}` with the following settings:\n{trigger.to_str()}") diff --git a/automod/converters.py b/automod/converters.py new file mode 100644 index 0000000..7ec34b1 --- /dev/null +++ b/automod/converters.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import enum +from datetime import timedelta +from typing import List, Optional, Tuple + +import discord +from discord.ext.commands import Converter, FlagConverter, flag +from redbot.core import commands +from redbot.core.commands.converter import get_timedelta_converter + +TimedeltaConverter = get_timedelta_converter( + maximum=timedelta(days=28), allowed_units=["minutes", "seconds", "weeks", "days", "hours"] +) + + +class EnumConverter(Converter): + _enum: enum.Enum + + async def convert(self, ctx: commands.Context, argument: str): + for e in self._enum: + if e.name.lower() == argument.lower(): + return e + valid_choices = "\n".join(f"- {e.name}" for e in self._enum) + raise commands.BadArgument(f"`{argument}` is not valid. Choose from:\n{valid_choices}") + + +class AutoModRuleConverter(EnumConverter): + _enum = discord.AutoModRuleEventType + + +class StrListTransformer(discord.app_commands.Transformer): + async def convert(self, ctx: commands.Context, argument: str) -> List[str]: + return argument.split(" ") + + async def transform(self, interaction: discord.Interaction, argument: str) -> List[str]: + return argument.split(" ") + + +class RoleListTransformer(discord.app_commands.Transformer): + async def convert(self, ctx: commands.Context, argument: str) -> List[discord.Role]: + possible_roles = argument.split(" ") + roles = [] + for role in possible_roles: + if not role: + continue + try: + r = await commands.RoleConverter().convert(ctx, role.strip()) + roles.append(r) + except commands.BadArgument: + raise + return roles + + async def transform( + self, interaction: discord.Interaction, argument: str + ) -> List[discord.Role]: + ctx = await interaction.client.get_context(interaction) + return await self.convert(ctx, argument) + + +class ChannelListTransformer(discord.app_commands.Transformer): + async def convert( + self, ctx: commands.Context, argument: str + ) -> List[discord.abc.GuildChannel]: + possible_channels = argument.split(" ") + channels = [] + for channel in possible_channels: + if not channel: + continue + try: + c = await commands.GuildChannelConverter().convert(ctx, channel.strip()) + channels.append(c) + except commands.BadArgument: + raise + return channels + + async def transform( + self, interaction: discord.Interaction, argument: str + ) -> List[discord.abc.GuildChannel]: + ctx = await interaction.client.get_context(interaction) + return await self.convert(ctx, argument) + + +class AutoModTriggerConverter(discord.app_commands.Transformer): + async def convert(self, ctx: commands.Context, argument: str) -> discord.AutoModTrigger: + cog = ctx.bot.get_cog("AutoMod") + async with cog.config.guild(ctx.guild).triggers() as triggers: + if argument.lower() in triggers: + kwargs = triggers[argument.lower()].copy() + + passed_args = {} + if kwargs.get("presets") is not None: + # as far as I can tell this is the sanest way to manage + # this until d.py adds a better method of dealing with + # these awful flags + presets = discord.AutoModPresets.none() + saved_presets = kwargs.pop("presets", []) or [] + for p in saved_presets: + presets.value |= p + passed_args["presets"] = presets + for key, value in kwargs.items(): + if value is not None: + passed_args[key] = value + return discord.AutoModTrigger(**kwargs) + else: + raise commands.BadArgument( + ("Trigger with name `{name}` does not exist.").format(name=argument.lower()) + ) + + async def transform( + self, interaction: discord.Interaction, argument: str + ) -> discord.AutoModTrigger: + ctx = await interaction.client.get_context(interaction) + return await self.convert(ctx, argument) + + async def autocomplete( + self, interaction: discord.Interaction, current: str + ) -> List[discord.app_commands.Choice]: + cog = interaction.client.get_cog("AutoMod") + choices = [] + async with cog.config.guild(interaction.guild).triggers() as triggers: + for t in triggers.keys(): + choices.append(discord.app_commands.Choice(name=t, value=t)) + return [t for t in choices if current.lower() in t.name.lower()][:25] + + +class AutoModActionConverter(discord.app_commands.Transformer): + async def convert( + self, ctx: commands.Context, argument: str + ) -> List[discord.AutoModRuleAction]: + cog = ctx.bot.get_cog("AutoMod") + ret = [] + actions = await cog.config.guild(ctx.guild).actions() + for a in argument.split(" "): + if a.lower() in actions: + action_args = actions[a.lower()] + duration = action_args.pop("duration", None) + if duration: + duration = timedelta(seconds=duration) + ret.append(discord.AutoModRuleAction(**action_args, duration=duration)) + if not ret: + raise commands.BadArgument( + ("Action with name `{name}` does not exist.").format(name=argument.lower()) + ) + ret.append(discord.AutoModRuleAction()) + return ret + + async def transform( + self, interaction: discord.Interaction, argument: str + ) -> List[discord.AutoModRuleAction]: + ctx = await interaction.client.get_context(interaction) + return await self.convert(ctx, argument) + + async def autocomplete( + self, interaction: discord.Interaction, current: str + ) -> List[discord.app_commands.Choice]: + cog = interaction.client.get_cog("AutoMod") + ret = [] + supplied_actions = "" + new_action = "" + actions = await cog.config.guild(interaction.guild).actions() + for sup in current.lower().split(" "): + if sup in actions: + supplied_actions += f"{sup} " + else: + new_action = sup + ret = [ + discord.app_commands.Choice( + name=f"{supplied_actions} {g}", value=f"{supplied_actions} {g}" + ) + for g in actions.keys() + if new_action in g + ] + if supplied_actions: + ret.insert( + 0, discord.app_commands.Choice(name=supplied_actions, value=supplied_actions) + ) + return ret[:25] + + +class AutoModRuleFlags(FlagConverter, case_insensitive=True): + """AutoMod Rule converter""" + + """ + # remove the event_type for now since there's only one possible option + event_type: Optional[AutoModRuleConverter] = flag( + name="event", + aliases=[], + default=discord.AutoModRuleEventType.message_send, + description="", + ) + """ + trigger: Optional[AutoModTriggerConverter] = flag( + name="trigger", + aliases=[], + default=None, + description="The name of the trigger you have setup.", + ) + actions: AutoModActionConverter = flag( + name="actions", + aliases=[], + default=[], + description="The name(s) of the action(s) you have setup.", + ) + enabled: bool = flag( + name="enabled", + aliases=[], + default=False, + description="Wheter to immediately enable this rule.", + ) + exempt_roles: RoleListTransformer = flag( + name="roles", + aliases=["exempt_roles"], + default=[], + description="The roles to be exempt from this rule.", + ) + exempt_channels: ChannelListTransformer = flag( + name="channels", + aliases=[], + default=[], + description="The channels to be exempt from this rule.", + ) + reason: Optional[str] = flag( + name="reason", + aliases=[], + default=None, + description="The reason for creating this rule.", + ) + + def to_str(self): + ret = "" + for k, v in self.to_json().items(): + if v is None: + continue + if k == "presets" and self.presets: + v = "\n".join(f" - {k}" for k, v in dict(self.presets).items() if v) + ret += f"- {k}:\n{v}\n" + continue + ret += f"- {k}: {v}\n" + return ret + + def to_args(self): + actions = self.actions + if not actions: + actions = [discord.AutoModRuleAction()] + return { + "event_type": discord.AutoModRuleEventType.message_send, + "trigger": self.trigger, + "actions": actions, + "enabled": self.enabled, + "exempt_roles": self.exempt_roles, + "exempt_channels": self.exempt_channels, + "reason": self.reason, + } + + +class AutoModPresetsConverter(discord.app_commands.Transformer): + async def convert(self, ctx: commands.Context, argument: str) -> discord.AutoModPresets: + ret = discord.AutoModPresets.none() + for possible in argument.lower().split(" "): + if possible in dict(discord.AutoModPresets.all()): + ret |= discord.AutoModPresets(**{possible.lower(): True}) + if ret is discord.AutoModPresets.none(): + valid_choices = "\n".join(f"- {e}" for e in dict(discord.AutoModPresets.all()).keys()) + raise commands.BadArgument(f"`{argument}` is not valid. Choose from:\n{valid_choices}") + return ret + + async def transform( + self, interaction: discord.Interaction, argument: str + ) -> discord.AutoModPresets: + ctx = await interaction.client.get_context(interaction) + return await self.convert(ctx, argument) + + +class AutoModTriggerFlags(FlagConverter, case_insensitive=True): + allow_list: List[str] = flag( + name="allows", + aliases=[], + default=None, + converter=StrListTransformer, + description="A space separated list of words to allow.", + ) + keyword_filter: List[str] = flag( + name="keywords", + aliases=[], + default=None, + converter=StrListTransformer, + description="A space separated list of words to filter.", + ) + mention_limit: Optional[commands.Range[int, 0, 50]] = flag( + name="mentions", + aliases=[], + default=None, + description="The number of mentions to allow (0-50).", + ) + presets: Optional[discord.AutoModPresets] = flag( + name="presets", + aliases=[], + default=None, + converter=AutoModPresetsConverter, + description="Use any combination of discords default presets.", + ) + regex_patterns: List[str] = flag( + name="regex", + aliases=[], + default=None, + converter=StrListTransformer, + description="A space separated list of regex patterns to include.", + ) + + def to_str(self): + ret = "" + for k, v in self.to_json().items(): + if v is None: + continue + if k == "presets" and self.presets: + v = "\n".join(f" - {k}" for k, v in dict(self.presets).items() if v) + ret += f"- {k}:\n{v}\n" + continue + ret += f"- {k}: {v}\n" + return ret + + def to_json(self): + return { + "allow_list": self.allow_list, + "keyword_filter": self.keyword_filter, + "mention_limit": self.mention_limit, + "regex_patterns": self.regex_patterns, + "presets": self.presets.to_array() if self.presets else None, + } + + def get_trigger(self): + return discord.AutoModTrigger( + keyword_filter=self.keyword_filter, + presets=self.presets, + allow_list=self.allow_list, + mention_limit=self.mention_limit, + regex_patterns=self.regex_patterns, + ) + + +class AutoModActionFlags(FlagConverter, case_insensitive=True): + custom_message: Optional[commands.Range[str, 1, 150]] = flag( + name="message", + aliases=[], + default=None, + description="A custom message to send to the user.", + ) + channel_id: Optional[discord.TextChannel] = flag( + name="channel", + aliases=[], + default=None, + description="The channel to send a notification to.", + ) + duration: Optional[timedelta] = flag( + name="duration", + aliases=[], + default=None, + description="How long to timeout the user for.", + converter=TimedeltaConverter, + ) + + def to_str(self): + ret = "" + for k, v in self.to_json().items(): + if v is None: + continue + ret += f"- {k}: {v}\n" + return ret + + def to_json(self): + return { + "custom_message": self.custom_message, + "channel_id": self.channel_id.id if self.channel_id else None, + "duration": int(self.duration.total_seconds()) if self.duration else None, + } + + def get_action(self): + return discord.AutoModRuleAction( + custom_message=self.custom_message, + channel_id=self.channel_id.id if self.channel_id else None, + duration=self.duration, + ) diff --git a/automod/info.json b/automod/info.json new file mode 100644 index 0000000..66d150c --- /dev/null +++ b/automod/info.json @@ -0,0 +1,28 @@ +{ + "author": [ + "TrustyJAID" + ], + "description": "A cog to interact with Discord Automod.", + "disabled": false, + "end_user_data_statement": "This cog does not persistently store end user data.", + "hidden": false, + "install_msg": "See `[p]automod` for available commands.", + "max_bot_version": "0.0.0", + "min_bot_version": "3.5.0", + "min_python_version": [ + 3, + 9, + 0 + ], + "name": "Automod", + "permissions": [], + "required_cogs": {}, + "requirements": [], + "short": "Discord Automod", + "tags": [ + "automod", + "moderation", + "mod" + ], + "type": "COG" +} diff --git a/automod/menus.py b/automod/menus.py new file mode 100644 index 0000000..d7827f8 --- /dev/null +++ b/automod/menus.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +import discord +from red_commons.logging import getLogger +from redbot.core import commands +from redbot.core.i18n import Translator +from redbot.core.utils.chat_formatting import humanize_timedelta, inline +from redbot.vendored.discord.ext import menus + +log = getLogger("red.trusty-cogs.automod") +_ = Translator("This string isn't used for anything", __file__) + + +class AutoModRulePages(menus.ListPageSource): + def __init__(self, pages: List[discord.AutoModRule], *, guild: discord.Guild): + super().__init__(pages, per_page=1) + self.pages = pages + self.current_item: discord.AutoModRule = None + self.guild = guild + + async def delete(self, view: BaseMenu, author: discord.Member) -> None: + try: + await self.current_item.delete(reason=f"Deleted by {author}") + except Exception: + return + + async def toggle(self, view: BaseMenu, author: discord.Member) -> bool: + try: + self.current_itme = await self.current_item.edit( + enabled=not self.current_item.enabled, reason=f"Toggled by {author}" + ) + except Exception: + return False + return True + + async def format_page(self, view: discord.ui.View, page: discord.AutoModRule): + # fetch the most recently edited version since we can toggle it through this + # menu and our cached ones may be out of date + self.current_item = page = await self.guild.fetch_automod_rule(page.id) + title = ( + f"\N{WHITE HEAVY CHECK MARK} {page.name}" + if page.enabled + else f"\N{CROSS MARK} {page.name}" + ) + em = discord.Embed( + title=title, colour=await view.cog.bot.get_embed_colour(view.ctx.channel) + ) + em.set_author(name=f"AutoMod Rules for {page.guild.name}", icon_url=page.guild.icon) + trigger = page.trigger + trigger_type_str = trigger.type.name.replace("_", " ").title() + description = f"Type: {trigger_type_str}\n" + if trigger.mention_limit: + description += f"Mention Limit: {trigger.mention_limit}\n" + if trigger.presets: + description += "Discord Preset Triggers:\n" + description += ( + "\n".join(f" - {k}" for k, v in dict(trigger.presets).items() if v) + "\n" + ) + trigger_keys = ( + "allow_list", + "keyword_filter", + "regex_patterns", + ) + for key in trigger_keys: + if triggers := getattr(trigger, key, None): + key_name = key.replace("_", " ").title() + description += f"- {key_name}:\n" + description += "\n".join(f" - {inline(t)}" for t in triggers) + "\n" + em.description = description[:4096] + + # em.add_field(name="Enabled", value=str(page.enabled)) + actions_str = "" + for action in page.actions: + if action.type is discord.AutoModRuleActionType.block_message: + actions_str += "- Block Message\n" + if action.custom_message: + actions_str += f"- Send this Message to the user:\n - {action.custom_message}" + elif action.type is discord.AutoModRuleActionType.timeout: + actions_str += f"- Timeout for {humanize_timedelta(timedelta=action.duration)}\n" + else: + actions_str += f"- Send alert to <#{action.channel_id}>\n" + em.add_field(name="Actions", value=actions_str, inline=False) + em.add_field( + name="Creator", + value=page.creator.mention if page.creator else inline(str(page.creator_id)), + ) + if page.exempt_roles: + em.add_field( + name="Exempt Roles", + value="\n".join(f"- {r.mention}" for r in page.exempt_roles), + ) + if page.exempt_channels: + em.add_field( + name="Exempt Channels", + value="\n".join(f"- {c.mention}" for c in page.exempt_channels), + ) + em.add_field(name="ID", value=inline(str(page.id))) + em.set_footer(text=f"Page {view.current_page + 1}/{self.get_max_pages()}") + return em + + +class AutoModActionsPages(menus.ListPageSource): + def __init__(self, pages: List[dict], *, guild: discord.Guild): + super().__init__(pages, per_page=1) + self.pages = pages + self.current_item: dict = None + self.guild = guild + + async def delete(self, view: BaseMenu, author: discord.User) -> None: + name = self.current_item.get("name", None) + async with view.cog.config.guild(self.guild).actions() as actions: + if name in actions: + del actions[name] + + async def format_page(self, view: discord.ui.View, page: dict): + self.current_item = page + guild = page["guild"] + name = page["name"] + em = discord.Embed( + title=name, colour=await view.cog.bot.get_embed_colour(view.ctx.channel) + ) + ret = "" + for k, v in page.items(): + if v is None or k in ("guild", "name"): + continue + ret += f"- {k}: {v}\n" + em.description = ret + em.set_author(name=f"AutoMod Actions for {guild.name}", icon_url=guild.icon) + em.set_footer(text=f"Page {view.current_page + 1}/{self.get_max_pages()}") + return em + + +class AutoModTriggersPages(menus.ListPageSource): + def __init__(self, pages: List[dict], *, guild: discord.Guild): + super().__init__(pages, per_page=1) + self.pages = pages + self.current_item: dict = None + self.guild: discord.Guild = guild + + async def delete(self, view: BaseMenu, author: discord.User) -> None: + name = self.current_item.get("name", None) + async with view.cog.config.guild(self.guild).triggers() as triggers: + if name in triggers: + del triggers[name] + + async def format_page(self, view: discord.ui.View, page: dict): + self.current_item = page + guild = page["guild"] + self.guild = guild + name = page["name"] + em = discord.Embed( + title=name, colour=await view.cog.bot.get_embed_colour(view.ctx.channel) + ) + ret = "" + for k, v in page.items(): + if v is None or k in ("guild", "name"): + continue + if k == "presets": + presets = dict(discord.AutoModPresets._from_value(value=v)).items() + v = "\n".join(f" - {x}" for x, y in presets if y) + name = "Discord Presets" + ret += f"- {name}:\n{v}\n" + continue + name = k.replace("_", " ").title() + v = "\n".join(f" - {inline(x)}" for x in v) + ret += f"- {name}:\n{v}\n" + em.description = ret + em.set_author(name=f"AutoMod Triggers for {guild.name}", icon_url=guild.icon) + em.set_footer(text=f"Page {view.current_page + 1}/{self.get_max_pages()}") + return em + + +class StopButton(discord.ui.Button): + def __init__( + self, + style: discord.ButtonStyle, + row: Optional[int], + ): + self.view: BaseMenu + super().__init__(style=style, row=row) + self.style = style + self.emoji = "\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}" + + async def callback(self, interaction: discord.Interaction): + self.view.stop() + await self.view.message.delete() + + +class ForwardButton(discord.ui.Button): + def __init__( + self, + style: discord.ButtonStyle, + row: Optional[int], + ): + self.view: BaseMenu + super().__init__(style=style, row=row) + self.style = style + self.emoji = "\N{BLACK RIGHT-POINTING TRIANGLE}\N{VARIATION SELECTOR-16}" + + async def callback(self, interaction: discord.Interaction): + await self.view.show_checked_page(self.view.current_page + 1, interaction) + + +class BackButton(discord.ui.Button): + def __init__( + self, + style: discord.ButtonStyle, + row: Optional[int], + ): + self.view: BaseMenu + super().__init__(style=style, row=row) + self.style = style + self.emoji = "\N{BLACK LEFT-POINTING TRIANGLE}\N{VARIATION SELECTOR-16}" + + async def callback(self, interaction: discord.Interaction): + await self.view.show_checked_page(self.view.current_page - 1, interaction) + + +class LastItemButton(discord.ui.Button): + def __init__( + self, + style: discord.ButtonStyle, + row: Optional[int], + ): + self.view: BaseMenu + super().__init__(style=style, row=row) + self.style = style + self.emoji = ( + "\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\N{VARIATION SELECTOR-16}" + ) + + async def callback(self, interaction: discord.Interaction): + await self.view.show_page(self.view._source.get_max_pages() - 1, interaction) + + +class FirstItemButton(discord.ui.Button): + def __init__( + self, + style: discord.ButtonStyle, + row: Optional[int], + ): + self.view: BaseMenu + super().__init__(style=style, row=row) + self.style = style + self.emoji = ( + "\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\N{VARIATION SELECTOR-16}" + ) + + async def callback(self, interaction: discord.Interaction): + await self.view.show_page(0, interaction) + + +class ToggleRuleButton(discord.ui.Button): + def __init__( + self, + row: int, + ): + self.view: BaseMenu + super().__init__(style=discord.ButtonStyle.secondary, row=row) + + def modify(self): + item: discord.AutoModRule = self.view.source.current_item + self.emoji = ( + "\N{NEGATIVE SQUARED CROSS MARK}" if item.enabled else "\N{WHITE HEAVY CHECK MARK}" + ) + self.label = _("Disable Rule") if item.enabled else _("Enable Rule") + + async def callback(self, interaction: discord.Interaction): + """Enables and disables triggers""" + member = interaction.user + await self.view.source.toggle(self.view, member) + self.modify() + await self.view.show_page(self.view.current_page, interaction) + + +class DeleteButton(discord.ui.Button): + def __init__( + self, + row: int, + ): + self.view: BaseMenu + super().__init__(label=_("Delete"), style=discord.ButtonStyle.red, row=row) + + async def callback(self, interaction: discord.Interaction): + """Enables and disables triggers""" + member = interaction.user + await self.view.source.delete(self.view, member) + await interaction.response.edit_message(content="This item has been deleted.", view=None) + + +class BaseMenu(discord.ui.View): + def __init__( + self, + source: menus.PageSource, + cog: commands.Cog, + clear_reactions_after: bool = True, + delete_message_after: bool = False, + timeout: int = 180, + message: discord.Message = None, + **kwargs: Any, + ) -> None: + super().__init__( + timeout=timeout, + ) + self.cog = cog + self.bot = None + self.message = message + self._source = source + self.ctx = None + self.current_page = kwargs.get("page_start", 0) + self.forward_button = ForwardButton(discord.ButtonStyle.grey, 0) + self.back_button = BackButton(discord.ButtonStyle.grey, 0) + self.first_item = FirstItemButton(discord.ButtonStyle.grey, 0) + self.last_item = LastItemButton(discord.ButtonStyle.grey, 0) + self.stop_button = StopButton(discord.ButtonStyle.red, 0) + self.toggle_button: Optional[ToggleRuleButton] = None + self.add_item(self.stop_button) + self.add_item(self.first_item) + self.add_item(self.back_button) + self.add_item(self.forward_button) + self.add_item(self.last_item) + self.delete_button = DeleteButton(1) + self.add_item(self.delete_button) + if isinstance(source, AutoModRulePages): + self.toggle_button = ToggleRuleButton(1) + self.add_item(self.toggle_button) + + @property + def source(self): + return self._source + + async def on_timeout(self): + await self.message.edit(view=None) + + async def start(self, ctx: commands.Context): + self.ctx = ctx + self.bot = self.cog.bot + # await self.source._prepare_once() + self.message = await self.send_initial_message(ctx) + + def check_paginating(self): + if not self.source.is_paginating(): + self.forward_button.disabled = True + self.back_button.disabled = True + self.first_item.disabled = True + self.last_item.disabled = True + else: + self.forward_button.disabled = False + self.back_button.disabled = False + self.first_item.disabled = False + self.last_item.disabled = False + + async def _get_kwargs_from_page(self, page): + self.check_paginating() + value = await discord.utils.maybe_coroutine(self._source.format_page, self, page) + if self.toggle_button is not None: + self.toggle_button.modify() + if isinstance(value, dict): + return value + elif isinstance(value, str): + return {"content": value, "embed": None} + elif isinstance(value, discord.Embed): + return {"embed": value, "content": None} + + async def send_initial_message(self, ctx: commands.Context): + """|coro| + The default implementation of :meth:`Menu.send_initial_message` + for the interactive pagination session. + This implementation shows the first page of the source. + """ + self.author = ctx.author + if self.ctx is None: + self.ctx = ctx + page = await self._source.get_page(self.current_page) + kwargs = await self._get_kwargs_from_page(page) + self.message = await ctx.send(**kwargs, view=self) + return self.message + + async def show_page(self, page_number: int, interaction: discord.Interaction): + page = await self._source.get_page(page_number) + self.current_page = self.source.pages.index(page) + kwargs = await self._get_kwargs_from_page(page) + if interaction.response.is_done(): + await interaction.followup.edit(**kwargs, view=self) + else: + await interaction.response.edit_message(**kwargs, view=self) + # await self.message.edit(**kwargs) + + async def show_checked_page(self, page_number: int, interaction: discord.Interaction) -> None: + max_pages = self._source.get_max_pages() + try: + if max_pages is None: + # If it doesn't give maximum pages, it cannot be checked + await self.show_page(page_number, interaction) + elif page_number >= max_pages: + await self.show_page(0, interaction) + elif page_number < 0: + await self.show_page(max_pages - 1, interaction) + elif max_pages > page_number >= 0: + await self.show_page(page_number, interaction) + except IndexError: + # An error happened that can be handled, so ignore it. + pass + + async def interaction_check(self, interaction: discord.Interaction): + """Just extends the default reaction_check to use owner_ids""" + if interaction.user.id not in ( + *interaction.client.owner_ids, + self.author.id, + ): + await interaction.response.send_message( + content=_("You are not authorized to interact with this."), ephemeral=True + ) + return False + return True + + +class ConfirmView(discord.ui.View): + """ + This is just a copy of my version from Red to be removed later possibly + https://github.com/Cog-Creators/Red-DiscordBot/pull/6176 + """ + + def __init__( + self, + author: Optional[discord.abc.User] = None, + *, + timeout: float = 180.0, + disable_buttons: bool = False, + ): + if timeout is None: + raise TypeError("This view should not be used as a persistent view.") + super().__init__(timeout=timeout) + self.result: Optional[bool] = None + self.author: Optional[discord.abc.User] = author + self.message: Optional[discord.Message] = None + self.disable_buttons = disable_buttons + + async def on_timeout(self): + if self.message is None: + # we can't do anything here if message is none + return + + if self.disable_buttons: + self.confirm_button.disabled = True + self.dismiss_button.disabled = True + await self.message.edit(view=self) + else: + await self.message.edit(view=None) + + @discord.ui.button(label=_("Yes"), style=discord.ButtonStyle.green) + async def confirm_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.result = True + self.stop() + # respond to the interaction so the user does not see "interaction failed". + await interaction.response.defer() + # call `on_timeout` explicitly here since it's not called when `stop()` is called. + await self.on_timeout() + + @discord.ui.button(label=_("No"), style=discord.ButtonStyle.secondary) + async def dismiss_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.result = False + self.stop() + # respond to the interaction so the user does not see "interaction failed". + await interaction.response.defer() + # call `on_timeout` explicitly here since it's not called when `stop()` is called. + await self.on_timeout() + + async def interaction_check(self, interaction: discord.Interaction): + if self.message is None: + self.message = interaction.message + if self.author and interaction.user.id != self.author.id: + await interaction.response.send_message( + content=_("You are not authorized to interact with this."), ephemeral=True + ) + return False + return True