From c1f173fdeb3e2698ffbdbe72aa17f63b54c97f95 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:46:44 -0700 Subject: [PATCH] Make factoid all into a slash command (#1036) Makes a /factoid all slash command, reduces functionality and deprecates .factoid all --- techsupport_bot/commands/factoids.py | 465 ++++++++++++++++++++------- 1 file changed, 341 insertions(+), 124 deletions(-) diff --git a/techsupport_bot/commands/factoids.py b/techsupport_bot/commands/factoids.py index 70c83490..83b5add9 100644 --- a/techsupport_bot/commands/factoids.py +++ b/techsupport_bot/commands/factoids.py @@ -19,6 +19,7 @@ import json import re from dataclasses import dataclass +from enum import Enum from socket import gaierror from typing import TYPE_CHECKING, Self @@ -32,6 +33,7 @@ from botlogging import LogContext, LogLevel from core import auxiliary, cogs, custom_errors, extensionconfig from croniter import CroniterBadCronError +from discord import app_commands from discord.ext import commands if TYPE_CHECKING: @@ -103,7 +105,7 @@ async def has_manage_factoids_role(ctx: commands.Context) -> bool: """ config = ctx.bot.guild_configs[str(ctx.guild.id)] return await has_given_factoids_role( - ctx, config.extensions.factoids.manage_roles.value + ctx.guild, ctx.author, config.extensions.factoids.manage_roles.value ) @@ -118,18 +120,19 @@ async def has_admin_factoids_role(ctx: commands.Context) -> bool: """ config = ctx.bot.guild_configs[str(ctx.guild.id)] return await has_given_factoids_role( - ctx, config.extensions.factoids.admin_roles.value + ctx.guild, ctx.author, config.extensions.factoids.admin_roles.value ) async def has_given_factoids_role( - ctx: commands.Context, check_roles: list[str] + guild: discord.Guild, invoker: discord.Member, check_roles: list[str] ) -> bool: """-COMMAND CHECK- Checks if the invoker has a factoid management role Args: - ctx (commands.Context): Context used for getting the config file + guild (discord.Guild): The guild the factoids command was called in + invoker (discord.Member): This is the member who called the factoids command check_roles (list[str]): The list of string names of roles Raises: @@ -142,7 +145,7 @@ async def has_given_factoids_role( factoid_roles = [] # Gets permitted roles for name in check_roles: - factoid_role = discord.utils.get(ctx.guild.roles, name=name) + factoid_role = discord.utils.get(guild.roles, name=name) if not factoid_role: continue factoid_roles.append(factoid_role) @@ -153,8 +156,7 @@ async def has_given_factoids_role( ) # Checking against the user to see if they have the roles specified in the config if not any( - factoid_role in getattr(ctx.author, "roles", []) - for factoid_role in factoid_roles + factoid_role in getattr(invoker, "roles", []) for factoid_role in factoid_roles ): raise commands.MissingAnyRole(factoid_roles) @@ -175,12 +177,30 @@ class CalledFactoid: factoid_db_entry: bot.models.Factoid +class Properties(Enum): + """ + This enum is for the new factoid all to be able to handle dynamic properties + + Attrs: + HIDDEN (str): Representation of hidden + DISABLED (str): Representation of disabled + RESTRICTED (str): Representation of restricted + PROTECTED (str): Representation of protected + """ + + HIDDEN = "hidden" + DISABLED = "disabled" + RESTRICTED = "restricted" + PROTECTED = "protected" + + class FactoidManager(cogs.MatchCog): """ Manages all factoid features Attrs: - CRON_REGEX: The regex to check if a cronjob is correct + CRON_REGEX (str): The regex to check if a cronjob is correct + factoid_app_group (app_commands.Group): Group for /factoid commands """ CRON_REGEX = ( @@ -189,6 +209,10 @@ class FactoidManager(cogs.MatchCog): + r")|\*\/[1-9])$" ) + factoid_app_group = app_commands.Group( + name="factoid", description="Command Group for the Factoids Extension" + ) + async def preconfig(self: Self) -> None: """Preconfig for factoid jobs""" self.factoid_cache = expiringdict.ExpiringDict( @@ -1494,39 +1518,284 @@ async def info( # Finally, sends the factoid await ctx.send(embed=embed) + @factoid_app_group.command( + name="all", + description="Sends a configurable list of all factoids.", + extras={ + "brief": "Sets a note for a user", + "usage": "[file] [property] [true_all] [ignore_hidden]", + "module": "factoids", + }, + ) + async def app_command_all( + self: Self, + interaction: discord.Interaction, + force_file: bool = False, + property: Properties = "", + true_all: bool = False, + show_hidden: bool = False, + ) -> None: + """This is the more feature full version of factoid all + This is an application command + + Args: + interaction (discord.Interaction): The interaction that started this command + force_file (bool, optional): Whether this should be forced as a yml file. + Defaults to False. + property (Properties, optional): What property to look for. Defaults to "". + true_all (bool, optional): Whether this should force every factoid. Defaults to False. + show_hidden (bool, optional): If set to true will show hidden factoids. + Defaults to False. + """ + guild = str(interaction.guild.id) + # Check for admin roles if ignoring hidden + if true_all or show_hidden: + config = self.bot.guild_configs[str(interaction.guild.id)] + await has_given_factoids_role( + interaction.guild, + interaction.user, + config.extensions.factoids.admin_roles.value, + ) + + if true_all: + factoids = await self.build_list_of_factoids(guild, include_hidden=True) + else: + factoids = await self.build_list_of_factoids( + guild, exclusive_property=property, include_hidden=show_hidden + ) + + aliases = self.build_alias_dict_for_given_factoids(factoids) + + # If the linx server isn't configured, we must make it a file + if not self.bot.file_config.api.api_url.linx: + force_file = True + + cachable = bool( + not force_file and not property and not true_all and not show_hidden + ) + + if cachable and guild in self.factoid_all_cache: + url = self.factoid_all_cache[guild]["url"] + embed = auxiliary.prepare_confirm_embed(url) + await interaction.response.send_message(embed=embed) + return + + factoid_all = await self.build_factoid_all( + interaction.guild, factoids, aliases, force_file, cachable + ) + + if not factoid_all: + embed = auxiliary.prepare_deny_embed( + "No factoids could be found matching your filter" + ) + await interaction.response.send_message(embed=embed) + return + + # If we know it's a file, or it's fallen back to a file, send it as a file + if force_file or isinstance(factoid_all, discord.File): + await interaction.response.send_message(file=factoid_all) + return + + embed = auxiliary.prepare_confirm_embed(factoid_all) + await interaction.response.send_message(embed=embed) + + async def build_list_of_factoids( + self: Self, + guild: discord.Guild, + exclusive_property: Properties = "", + include_hidden: bool = False, + ) -> list[munch.Munch]: + """This builds a list of database objects that match the factoid all requests + + Args: + guild (discord.Guild): The guild to pull factoids from + exclusive_property (Properties, optional): What property to exclusivly get. + Defaults to "". + include_hidden (bool, optional): Whether this query should ignore the hidden property. + Defaults to False. + + Returns: + list[munch.Munch]: The filtered list of factoids + """ + factoids = await self.get_all_factoids(guild, list_hidden=True) + # If there are no factoids for the guild, return None + if not factoids: + return None + # If exclusive property is set, then that property as the only one + # This obeys include_hidden + if exclusive_property: + filtered_factoids = [ + factoid + for factoid in factoids + if getattr(factoid, exclusive_property.value) + and (include_hidden or not factoid.hidden) + ] + return filtered_factoids + # If no specific property is set, see if we have to filter out hidden factoids + if not include_hidden: + filtered_factoids = [factoid for factoid in factoids if not factoid.hidden] + return filtered_factoids + # Otherwise just return every factoid + return factoids + + def build_alias_dict_for_given_factoids( + self: Self, factoids: list[munch.Munch] + ) -> dict[str, list[str]]: + """This builds a dict of parent to aliases for a given list of factoids + + Args: + factoids (list[munch.Munch]): The factoid list to find aliases for + + Returns: + dict[str, list[str]]: The dict of parent to list of aliases + """ + aliases = {} + for factoid in factoids: + if factoid.alias not in [None, ""]: + # Append to aliases + if factoid.alias in aliases: + aliases[factoid.alias].append(factoid.name) + continue + + aliases[factoid.alias] = [factoid.name] + return aliases + + async def build_factoid_all( + self: Self, + guild: discord.Guild, + factoids: list[munch.Munch], + aliases: dict[str, list[str]], + use_file: bool, + cachable: bool, + ) -> discord.File | str: + """This builds the factoid all url or the yaml file + + Args: + guild (discord.Guild): The guild to build factoid all for + factoids (list[munch.Munch]): The factoids to include in the all + aliases (dict[str, list[str]]): Aliases for the given factoids + use_file (bool): Whether to force the use of a file or not + cachable (bool): Whether this request is cachable + + Returns: + discord.File | str: The final formatted factoid all + """ + + if use_file: + return await self.send_factoids_as_file(guild, factoids, aliases) + + try: + # -Tries calling the api- + html = await self.generate_html(guild, factoids, aliases) + # If there are no applicable factoids + if html is None: + # Something must go wrong to get here + return None + + headers = { + "Content-Type": "text/plain", + } + response = await self.bot.http_functions.http_call( + "put", + self.bot.file_config.api.api_url.linx, + headers=headers, + data=io.StringIO(html), + get_raw_response=True, + ) + url = response["text"] + filename = url.split("/")[-1] + url = url.replace(filename, f"selif/{filename}") + + if cachable: + self.factoid_all_cache[str(guild.id)] = {} + self.factoid_all_cache[str(guild.id)]["url"] = url + + return url + + # If an error happened while calling the api + except (gaierror, InvalidURL) as exception: + config = self.bot.guild_configs[str(guild.id)] + log_channel = config.get("logging_channel") + await self.bot.logger.send_log( + message="Could not render/send all-factoid HTML", + level=LogLevel.ERROR, + context=LogContext(guild=guild), + channel=log_channel, + exception=exception, + ) + + return await self.send_factoids_as_file(guild, factoids, aliases) + + def build_formatted_factoid_data( + self: Self, factoids: list[munch.Munch], aliases: dict[str, list[str]] + ) -> dict[str, dict[str, str]]: + """This builds a nicely formatted, sorted, and processed dict of factoids + Ready to be put into factoid all + + Args: + factoids (list[munch.Munch]): The list of all parent factoids to be included + aliases (dict[str, list[str]]): The list of all aliases, if any, + for the factoids in the main factoids list + + Returns: + dict[str, dict[str, str]]: The formatted list of factoids with all the information + """ + output_data = [] + for factoid in factoids: + # Skips aliases + if factoid.alias not in [None, ""]: + continue + + # Default name to the actual factoid name + name = factoid.name + + # If not aliased + if factoid.name in aliases: + all_aliases = [factoid.name] + aliases[factoid.name] + all_aliases.sort() + name = all_aliases[0] + data = { + "message": factoid.message, + "embed": bool(factoid.embed_config), + "aliases": all_aliases[1:], + } + + # If aliased + else: + data = {"message": factoid.message, "embed": bool(factoid.embed_config)} + + output_data.append({name: data}) + + # Sort output alphabetically + output_data = sorted(output_data, key=lambda x: list(x.keys())[0]) + return output_data + @auxiliary.with_typing @commands.guild_only() @factoid.command( name="all", aliases=["lsf"], brief="List all factoids", - description="Sends a list of all factoids, can take a file and hidden flag.", - usage="[optional-flag]", + description="Sends a list of all factoids as a url.", ) - async def all_(self: Self, ctx: commands.Context, *, flag: str = "") -> None: + async def all_(self: Self, ctx: commands.Context) -> None: """Command to list all factoids + DEPREACTED, /factoid all is the main one now Args: ctx (commands.Context): Context of the invocation - flag (str, optional): Can be "file", which will return a .yaml instead of a paste. - Can also be "hidden", which will return only hidden factoids. - Defaults to an empty string. - - Raises: - MissingPermissions: Raised when someone tries to call .factoid all with - the hidden flag without administrator permissions """ - flags = flag.lower().split() guild = str(ctx.guild.id) # Gets the url from the cache if the invokation doesn't contain flags - if ( - "file" not in flags - and "hidden" not in flags - and guild in self.factoid_all_cache - ): + if guild in self.factoid_all_cache: url = self.factoid_all_cache[guild]["url"] - await auxiliary.send_confirm_embed(message=url, channel=ctx.channel) + embed = auxiliary.prepare_confirm_embed(message=url) + embed.title = ( + "WARNING: This command is deprecated, " + "please use /factoid all going forward" + ) + await ctx.send(embed=embed) return factoids = await self.get_all_factoids(guild, list_hidden=True) @@ -1548,22 +1817,9 @@ async def all_(self: Self, ctx: commands.Context, *, flag: str = "") -> None: aliases[factoid.alias] = [factoid.name] - list_only_hidden = False - if "hidden" in flags: - if not ctx.author.guild_permissions.administrator: - raise commands.MissingPermissions(["administrator"]) - - list_only_hidden = True - - if "file" in flags or not self.bot.file_config.api.api_url.linx: - await self.send_factoids_as_file( - ctx, factoids, aliases, list_only_hidden, flag - ) - return - try: # -Tries calling the api- - html = await self.generate_html(ctx, factoids, aliases, list_only_hidden) + html = await self.generate_html(ctx.guild, factoids, aliases) # If there are no applicable factoids if html is None: await auxiliary.send_deny_embed( @@ -1586,12 +1842,14 @@ async def all_(self: Self, ctx: commands.Context, *, flag: str = "") -> None: url = url.replace(filename, f"selif/{filename}") # Returns the url - await auxiliary.send_confirm_embed(message=url, channel=ctx.channel) - - # Creates cache if hidden factoids weren't called - if not list_only_hidden: - self.factoid_all_cache[str(ctx.guild.id)] = {} - self.factoid_all_cache[str(ctx.guild.id)]["url"] = url + embed = auxiliary.prepare_confirm_embed(message=url) + embed.title = ( + "WARNING: This command is deprecated, " + "please use /factoid all going forward" + ) + await ctx.send(embed=embed) + self.factoid_all_cache[str(ctx.guild.id)] = {} + self.factoid_all_cache[str(ctx.guild.id)]["url"] = url # If an error happened while calling the api except (gaierror, InvalidURL) as exception: @@ -1605,59 +1863,48 @@ async def all_(self: Self, ctx: commands.Context, *, flag: str = "") -> None: exception=exception, ) - await self.send_factoids_as_file( - ctx, factoids, aliases, list_only_hidden, flag - ) + await self.send_factoids_as_file(ctx, factoids, aliases) async def generate_html( self: Self, - ctx: commands.Context, - factoids: list, - aliases: dict, - list_only_hidden: bool, + guild: discord.Guild, + factoids: list[munch.Munch], + aliases: dict[str, list[str]], ) -> str: """Method to generate the html file contents Args: - ctx (commands.Context): The context, used for the guild name - factoids (list): List of all factoids - aliases (dict): A dictionary containing factoids and their aliases - list_only_hidden (bool): Whether to list only hidden factoids + guild (discord.Guild): The guild the factoids are being pulled from + factoids (list[munch.Munch]): List of all factoids + aliases (dict[str, list[str]]): A dictionary containing factoids and their aliases Returns: str: The result html file """ body_contents = "" - for factoid in factoids: - if ( - list_only_hidden - and not factoid.hidden - or not list_only_hidden - and factoid.hidden - ): - continue - # Formatting - embed_text = " (embed)" if factoid.embed_config else "" + output_data = self.build_formatted_factoid_data(factoids, aliases) - # Skips aliases - if factoid.alias not in [None, ""]: - continue + if not output_data: + # Something is wrong with the database if we are ever here + return None - # If aliased - if factoid.name in aliases: + for factoid in output_data: + name, data = next(iter(factoid.items())) + embed_text = " (embed)" if data["embed"] else "" + + if "aliases" in data: body_contents += ( - f"
  • {factoid.name} [{', '.join(aliases[factoid.name])}]{embed_text}" - + f" - {factoid.message}
  • " + f"
  • {name} [{', '.join(data['aliases'])}]{embed_text}" + + f" - {data['message']}
  • " ) - - # If not aliased else: body_contents += ( - f"
  • {factoid.name}{embed_text}" - + f" - {factoid.message}
  • " + f"
  • {name}{embed_text}" + + f" - {data['message']}
  • " ) + if body_contents == "": return None @@ -1667,7 +1914,7 @@ async def generate_html( -

    Factoids for {ctx.guild.name}

    +

    Factoids for {guild.name}

    {body_contents}