diff --git a/cogs/rickbot/slashcmds_botinfo.py b/cogs/rickbot/slashcmds_botinfo.py new file mode 100644 index 0000000..0b20e3c --- /dev/null +++ b/cogs/rickbot/slashcmds_botinfo.py @@ -0,0 +1,186 @@ +""" +(c) 2024 Zachariah Michael Lagden (All Rights Reserved) +You may not use, copy, distribute, modify, or sell this code without the express permission of the author. + +This cog provides commands to get information about the bot, this is a part of the RickBot default cog set. +""" + +# Python standard library +from datetime import datetime + +# Third-party libraries +from discord_timestamps import format_timestamp, TimestampType +from discord.ext import commands +from discord import app_commands +import discord +import requests + +# Helper functions +from helpers.colors import MAIN_EMBED_COLOR, ERROR_EMBED_COLOR + +# Config +from config import CONFIG + + +# Custom Exceptions +class InvalidGitHubURL(Exception): + def __init__(self, message="Invalid GitHub URL"): + self.message = message + super().__init__(self.message) + + +# Helper functions +def convert_repo_url_to_api(url: str) -> str: + """ + Converts a GitHub repository URL into the corresponding GitHub API URL to retrieve commits. + + Args: + url (str): The GitHub repository URL. + + Returns: + str: The corresponding GitHub API URL for commits. + """ + # Split the URL by slashes + parts = url.rstrip("/").split("/") + + if len(parts) < 2: + raise ValueError("Invalid GitHub URL") + + # Extract the owner and repository name + owner = parts[-2] + repo = parts[-1] + + # Construct the API URL + api_url = f"https://api.github.com/repos/{owner}/{repo}/commits" + + return api_url + + +# Cog +class RickBot_BotInfoSlashCommands(commands.Cog): + def __init__(self, bot): + self.bot = bot + + self.GITHUB_REPO = CONFIG["repo"]["url"] + + if self.GITHUB_REPO is not None: + self.GITHUB_API = convert_repo_url_to_api(self.GITHUB_REPO) + else: + self.GITHUB_API = None + + async def _send_embed(self, interaction, title, description, color): + """Helper to send formatted Discord embeds.""" + embed = discord.Embed(title=title, description=description, color=color) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="updates", description="Check GitHub for the latest commits." + ) + async def _updates(self, interaction: discord.Interaction): + """ + Check GitHub for the latest commits, provides the last 5 along with other relevant information. + """ + + if self.GITHUB_API is None: + await self._send_embed( + interaction, + "Sorry!", + "This command is disabled.", + ERROR_EMBED_COLOR, + ) + return + + try: + response = requests.get(self.GITHUB_API) + response.raise_for_status() # Raise an exception for HTTP errors + data = response.json() + + if not isinstance(data, list): + raise ValueError("Unexpected data format received from GitHub API") + + # Sort the commits by date (newest first) + sorted_commits = sorted( + data, + key=lambda x: datetime.strptime( + x["commit"]["author"]["date"], "%Y-%m-%dT%H:%M:%SZ" + ), + reverse=True, + ) + + # Extract required information + commit_list = [] + for commit in sorted_commits[:5]: # Only process the latest 5 commits + author_data = commit.get("author") + commit_info = { + "sha": commit["sha"], + "id": commit["sha"][:7], + "date": commit["commit"]["author"]["date"], + "author": commit["commit"]["author"]["name"], + "author_html_url": ( + author_data["html_url"] if author_data else "N/A" + ), + "email": commit["commit"]["author"]["email"], + "short_message": commit["commit"]["message"].split("\n")[0], + "full_message": commit["commit"]["message"], + "url": commit["url"], + "html_url": commit["html_url"], + } + commit_list.append(commit_info) + + # Create the embed + desc = "Here are the latest updates to the bot:\n\n" + + for commit in commit_list: + date = datetime.strptime(commit["date"], "%Y-%m-%dT%H:%M:%SZ") + + author_link = ( + f"[{commit['author'].split(' ')[0]}]({commit['author_html_url']})" + if commit["author_html_url"] != "N/A" + else commit["author"].split(" ")[0] + ) + desc += f"**[`{commit['id']}`]({commit['html_url']})** - {format_timestamp(date, TimestampType.RELATIVE)} by {author_link}\n{commit['short_message']}\n\n" + + embed = discord.Embed( + title="Latest Updates", + description=desc, + color=MAIN_EMBED_COLOR, + ) + + embed.set_footer(text="RickBot is a project by lagden.dev.") + + await interaction.response.send_message(embed=embed, ephemeral=False) + + except requests.exceptions.RequestException as e: + # Handle specific request errors like timeouts, connection errors, etc. + await self._send_embed( + interaction, + "Error", + "I'm sorry, there was an error fetching the latest commits. Please try again later.\nIf the problem persists, please contact the bot owner.", + ERROR_EMBED_COLOR, + ) + + except ValueError as e: + # Handle JSON decoding errors or any other data format issues + await self._send_embed( + interaction, + "Error", + str(e), + ERROR_EMBED_COLOR, + ) + + @app_commands.command(name="ping", description="Check the bot's latency.") + async def _ping(self, interaction: discord.Interaction): + """ + Check the bot's latency. + """ + embed = discord.Embed( + title="Pong!", + description=f"Latency: {round(self.bot.latency * 1000)}ms", + color=MAIN_EMBED_COLOR, + ) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + +async def setup(bot: commands.Bot): + await bot.add_cog(RickBot_BotInfoSlashCommands(bot)) diff --git a/cogs/rickbot/slashcmds_botutils.py b/cogs/rickbot/slashcmds_botutils.py new file mode 100644 index 0000000..4ab0134 --- /dev/null +++ b/cogs/rickbot/slashcmds_botutils.py @@ -0,0 +1,246 @@ +""" +(c) 2024 Zachariah Michael Lagden (All Rights Reserved) +You may not use, copy, distribute, modify, or sell this code without the express permission of the author. + +This cog provides utility commands for bot developers, such as evaluating code, executing commands, and testing errors. +These commands are restricted to bot developers only for security purposes. +""" + +# Python standard library +import subprocess + +# Third-party libraries +from discord.ext import commands +from discord import app_commands +import discord + +# Helper functions +from helpers.colors import MAIN_EMBED_COLOR, ERROR_EMBED_COLOR +from helpers.errors import handle_error + +# Config +from config import CONFIG + + +class RickBot_BotUtilsSlashCommands(commands.Cog): + """ + This cog contains utility commands intended for bot developers, allowing them to evaluate Python code, + execute system commands, and test error handling. + """ + + def __init__(self, bot): + """ + Initializes the cog with the bot instance. + + Args: + bot (commands.Bot): The instance of the bot to which this cog is added. + """ + self.bot = bot + + def botownercheck(interaction: discord.Interaction) -> bool: + """ + Checks if the user who invoked the command is a bot developer. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + + Returns: + bool: True if the user is a bot developer, False otherwise. + """ + return interaction.user.id in CONFIG["devs"] + + async def _send_embed(self, interaction, title, description, color): + """ + Helper function to send formatted Discord embeds. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + title (str): The title of the embed. + description (str): The description of the embed. + color (int): The color of the embed. + """ + embed = discord.Embed(title=title, description=description, color=color) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="eval", description="Evaluate Python code. Restricted to bot developers." + ) + @app_commands.check(botownercheck) + async def eval(self, interaction: discord.Interaction, *, code: str): + """ + Evaluates the provided Python code and returns the result. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + code (str): The Python code to evaluate. + """ + try: + # Evaluate the code and convert the output to a string + str_output = str(eval(code)) + except Exception as e: + # Capture and convert any exceptions to a string + str_output = str(e) + + # Create and send an embed with the output + await self._send_embed( + interaction, "Eval", f"```py\n{str_output}```", MAIN_EMBED_COLOR + ) + + @eval.error + async def eval_error(self, interaction: discord.Interaction, error): + """ + Handles errors for the eval command. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + error (commands.CommandError): The error that occurred. + """ + if isinstance(error, app_commands.CheckFailure): + # Notify the user that only bot developers can run this command + await self._send_embed( + interaction, + "Error", + "Only the bot developer can run this command as it is dangerous.", + ERROR_EMBED_COLOR, + ) + else: + # Handle other errors using the global error handler + await handle_error(interaction, error) + + @app_commands.command( + name="exec", description="Execute Python code. Restricted to bot developers." + ) + @app_commands.check(botownercheck) + async def exec(self, interaction: discord.Interaction, *, code: str): + """ + Executes the provided Python code. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + code (str): The Python code to execute. + """ + try: + # Execute the code block + exec(code) + str_output = "Executed successfully." + except Exception as e: + # Capture and convert any exceptions to a string + str_output = str(e) + + # Create and send an embed with the result + await self._send_embed( + interaction, "Exec", f"```py\n{str_output}```", MAIN_EMBED_COLOR + ) + + @exec.error + async def exec_error(self, interaction: discord.Interaction, error): + """ + Handles errors for the exec command. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + error (commands.CommandError): The error that occurred. + """ + if isinstance(error, app_commands.CheckFailure): + # Notify the user that only bot developers can run this command + await self._send_embed( + interaction, + "Error", + "Only the bot developer can run this command as it is dangerous.", + ERROR_EMBED_COLOR, + ) + else: + # Handle other errors using the global error handler + await handle_error(interaction, error) + + @app_commands.command( + name="cmd", description="Run a system command. Restricted to bot developers." + ) + @app_commands.check(botownercheck) + async def cmd(self, interaction: discord.Interaction, *, cmd: str): + """ + Runs the specified system command and returns the output. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + cmd (str): The system command to run. + """ + try: + # Run the system command and capture the output + str_output = subprocess.check_output(cmd, shell=True, text=True) + except subprocess.CalledProcessError as e: + # Capture and convert any errors during execution to a string + str_output = f"Error executing command: {e}" + + # Create and send an embed with the command output + await self._send_embed( + interaction, "Command", f"```{str_output}```", MAIN_EMBED_COLOR + ) + + @cmd.error + async def cmd_error(self, interaction: discord.Interaction, error): + """ + Handles errors for the cmd command. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + error (commands.CommandError): The error that occurred. + """ + if isinstance(error, app_commands.CheckFailure): + # Notify the user that only bot developers can run this command + await self._send_embed( + interaction, + "Error", + "Only the bot developer can run this command as it is dangerous.", + ERROR_EMBED_COLOR, + ) + else: + # Handle other errors using the global error handler + await handle_error(interaction, error) + + @app_commands.command( + name="testerror", + description="Test error handling. Restricted to bot developers.", + ) + @app_commands.check(botownercheck) + async def testerror(self, interaction: discord.Interaction): + """ + Raises a test error to verify error handling. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + """ + # React to the interaction message (This will raise an error since it is a slash command) + await interaction.response.send_message("Raising a test error...") + raise Exception("Test error raised.") + + @testerror.error + async def testerror_error(self, interaction: discord.Interaction, error): + """ + Handles errors for the testerror command. + + Args: + interaction (discord.Interaction): The interaction that triggered the command. + error (commands.CommandError): The error that occurred. + """ + if isinstance(error, app_commands.CheckFailure): + # Notify the user that only bot developers can run this command + await self._send_embed( + interaction, + "Error", + "Only the bot developer can run this command.", + ERROR_EMBED_COLOR, + ) + else: + # Handle other errors using the global error handler + await handle_error(interaction, error) + + +async def setup(bot: commands.Bot): + """ + Sets up the cog by adding it to the bot. + + Args: + bot (commands.Bot): The instance of the bot to which this cog is added. + """ + await bot.add_cog(RickBot_BotUtilsSlashCommands(bot))