From 01f002600cd896a391142100301ef5b286e6997e Mon Sep 17 00:00:00 2001 From: Kami Date: Wed, 12 Feb 2025 23:14:40 +0100 Subject: [PATCH] Bug tracking, minor tweaks - Moved some constants to the globals.Constants class - config_data: returns the configuration dictionary - bot_start_time: returns the epoch bot start time - primary_discord_guild: returns a dict like `{"object": primary_guild_object, "id": primary_guild_int}` *These is initiated under globals.constants* - Improved on docstrings. Google format now standard - Reverted all commands to purely textual until further notice - `/help` is still additionally available as a slash command --- bot_discord.py | 70 ++++++- bot_twitch.py | 8 +- bots.py | 6 +- cmd_discord.py | 300 ++++++++++++------------------ config.json | 2 +- globals.py | 138 ++++++++++++-- modules/utility.py | 449 ++++++++++++++++++++++++++++++++++----------- 7 files changed, 649 insertions(+), 324 deletions(-) diff --git a/bot_discord.py b/bot_discord.py index a0d9058..e0ceaf7 100644 --- a/bot_discord.py +++ b/bot_discord.py @@ -11,11 +11,14 @@ import modules import modules.utility from modules.db import log_message, lookup_user, log_bot_event + +primary_guild = globals.constants.primary_discord_guild()["object"] + class DiscordBot(commands.Bot): - def __init__(self, config): + def __init__(self): super().__init__(command_prefix="!", intents=discord.Intents.all()) self.remove_command("help") # Remove built-in help function - self.config = config + self.config = globals.constants.config_data self.log = globals.log # Use the logging function from bots.py self.db_conn = None # We'll set this later self.help_data = None # We'll set this later @@ -58,8 +61,29 @@ class DiscordBot(commands.Bot): is_discord=True ) except Exception as e: - globals.log(f"Error loading Discord commands: {e}", "ERROR", True) + _result = f"Error loading Discord commands: {e}" + globals.log(_result, "ERROR", True) + @commands.command(name="cmd_reload") + @commands.is_owner() + async def cmd_reload(self, ctx: commands.Context): + try: + importlib.reload(cmd_discord) # Reload the commands file + cmd_discord.setup(self) # Ensure commands are registered + + # Load help info + help_json_path = "dictionary/help_discord.json" + modules.utility.initialize_help_data( + bot=self, + help_json_path=help_json_path, + is_discord=True + ) + _result = "Commands reloaded successfully" + globals.log("Discord commands reloaded successfully.") + except Exception as e: + _result = f"Error reloading Discord commands: {e}" + globals.log(_result, "ERROR", True) + await ctx.reply(_result) async def on_message(self, message): globals.log(f"Message detected by '{message.author.name}' in '{message.author.guild.name}' - #'{message.channel.name}'", "DEBUG") @@ -128,6 +152,30 @@ class DiscordBot(commands.Bot): globals.log(f"Command '{ctx.command}' (Discord) initiated by {ctx.author} in #{channel_name}", "DEBUG") if len(_cmd_args) > 1: globals.log(f"!{ctx.command} arguments: {_cmd_args}", "DEBUG") + async def on_interaction(interaction: discord.Interaction): + # Only log application command (slash command) interactions. + if interaction.type == discord.InteractionType.application_command: + # Get the command name from the interaction data. + command_name = interaction.data.get("name") + # Get the options (arguments) if any. + options = interaction.data.get("options", []) + # Convert options to a list of values or key-value pairs. + option_values = [f'{opt.get("name")}: {opt.get("value")}' for opt in options] + + # Determine the channel name (or DM). + if interaction.channel and hasattr(interaction.channel, "name"): + channel_name = interaction.channel.name + else: + channel_name = "Direct Message" + + globals.log( + f"Command '{command_name}' (Discord) initiated by {interaction.user} in #{channel_name}", + "DEBUG" + ) + + if option_values: + globals.log(f"Command '{command_name}' arguments: {option_values}", "DEBUG") + async def on_ready(self): """Runs when the bot successfully logs in.""" # Sync Slash Commands @@ -136,9 +184,19 @@ class DiscordBot(commands.Bot): #await self.tree.sync() #globals.log("Discord slash commands synced.") primary_guild_int = int(self.config["discord_guilds"][0]) - primary_guild = discord.Object(id=primary_guild_int) - await self.tree.sync(guild=primary_guild) - globals.log(f"Discord slash commands force synced to guild: {primary_guild_int}") + num_guilds = len(self.config["discord_guilds"]) + cmd_tree_result = (await self.tree.sync(guild=primary_guild)) + command_names = [command.name for command in cmd_tree_result] if cmd_tree_result else None + try: + guild_info = await modules.utility.get_guild_info(self, primary_guild_int) + primary_guild_name = guild_info["name"] + except Exception as e: + primary_guild_name = f"{primary_guild_int} (id)" + globals.log(f"Guild lookup failed: {e}", "ERROR") + + _log_message = f"{num_guilds} guilds (global)" if num_guilds > 1 else f"guild: {primary_guild_name}" + globals.log(f"Discord slash commands force synced to {_log_message}") + globals.log(f"Discord slash commands that got synced: {command_names}") except Exception as e: globals.log(f"Unable to sync Discord slash commands: {e}") diff --git a/bot_twitch.py b/bot_twitch.py index 6798ecd..3202f97 100644 --- a/bot_twitch.py +++ b/bot_twitch.py @@ -12,14 +12,16 @@ import modules import modules.utility from modules.db import log_message, lookup_user, log_bot_event +twitch_channels = globals.constants.config_data["twitch_channels"] + class TwitchBot(commands.Bot): - def __init__(self, config): + def __init__(self): self.client_id = os.getenv("TWITCH_CLIENT_ID") self.client_secret = os.getenv("TWITCH_CLIENT_SECRET") self.token = os.getenv("TWITCH_BOT_TOKEN") self.refresh_token = os.getenv("TWITCH_REFRESH_TOKEN") self.log = globals.log # Use the logging function from bots.py - self.config = config + self.config = globals.constants.config_data self.db_conn = None # We'll set this self.help_data = None # We'll set this later @@ -27,7 +29,7 @@ class TwitchBot(commands.Bot): super().__init__( token=self.token, prefix="!", - initial_channels=config["twitch_channels"] + initial_channels=twitch_channels ) globals.log("Twitch bot initiated") diff --git a/bots.py b/bots.py index edb8231..d7f52ed 100644 --- a/bots.py +++ b/bots.py @@ -26,7 +26,7 @@ load_dotenv() globals.reset_curlogfile() # Load bot configuration -config_data = globals.load_config_file() +config_data = globals.Constants.config_data ############################### # Main Event Loop @@ -66,8 +66,8 @@ async def main(): globals.log("Initializing bots...") # Create both bots - discord_bot = DiscordBot(config_data) - twitch_bot = TwitchBot(config_data) + discord_bot = DiscordBot() + twitch_bot = TwitchBot() # Log startup utility.log_bot_startup(db_conn) diff --git a/cmd_discord.py b/cmd_discord.py index 99105ce..bc478e9 100644 --- a/cmd_discord.py +++ b/cmd_discord.py @@ -1,204 +1,72 @@ # cmd_discord.py import discord from discord.ext import commands -from discord import app_commands from typing import Optional +from discord import app_commands from cmd_common import common_commands as cc from modules.permissions import has_permission from modules.utility import handle_help_command import globals +import os +import json +import random + +# Retrieve primary guild info if needed (for logging or other purposes) +primary_guild = globals.constants.primary_discord_guild() # e.g., {"object": discord.Object(id=1234567890), "id": 1234567890} def setup(bot): """ - Attach commands to the Discord bot, store references to db/log. + Attach commands to the Discord bot and register textual commands. + + This function loads configuration data, then registers several commands: + - sync_commands: (OWNER ONLY) Manually syncs the bot’s command tree. + - available: Lists commands available to the user. + - help: Provides detailed help information for commands. + - greet: Makes the bot greet the user. + - ping: Checks the bot's uptime and latency. + - howl: Makes the bot attempt a howl. + - hi: A development command to test permission handling. + - quote: Interact with the quotes system (multiple subcommands supported). + + All commands here are invoked with your text command prefix (e.g. "!") """ - + config_data = globals.load_config_file() - - @bot.command() - @commands.is_owner() - async def sync_commands(ctx): - """ - Trigger manual command sync. OWNER ONLY - """ - if commands.is_owner(): - primary_guild_int = int(config_data["discord_guilds"][0]) - primary_guild = discord.Object(id=primary_guild_int) - await ctx.send(f"Initiating slash sync to Discord Guild '{primary_guild_int}' ...") - try: - await bot.tree.sync(guild=primary_guild) - reply = "... Commands synced!" - except Exception as e: - reply = f"... Commands failed to sync! Error message:\n{e}" - globals.log(f"'sync_commands' failed to sync command tree\n{e}", "ERROR") - else: - reply = "You're not the registered owner of me!" - await ctx.send(reply) - - @bot.hybrid_command(name="available", description="List commands available to you") - async def available(ctx): - available_cmds = [] - for command in bot.commands: - try: - # This will return True if the command's checks pass for the given context. - if await command.can_run(ctx): - available_cmds.append(command.name) - except commands.CheckFailure: - # The command's checks did not pass. - pass - except Exception as e: - # In case some commands fail unexpectedly during checks. - globals.log(f"Error checking command {command.name}: {e}", "ERROR") - if available_cmds: - await ctx.send("Available commands: " + ", ".join(sorted(available_cmds))) - else: - await ctx.send("No commands are available to you at this time.") - - @bot.hybrid_command(name="help", description="Get information about commands") - @app_commands.describe( - command="The command to get help info about. Defaults to 'help'" - ) - async def cmd_help(ctx: commands.Context, *, command: str = ""): - """ - e.g. !help - !help quote - """ - result = await handle_help_command(ctx, command, bot, is_discord=True) - await ctx.send(result) - - @bot.hybrid_command(name="greet", description="Make me greet you") - async def cmd_greet(ctx): - result = cc.greet(ctx.author.display_name, "Discord") - await ctx.send(result) - - @bot.hybrid_command(name="ping", description="Check my uptime") - async def cmd_ping(ctx): - result = cc.ping() - # Get heartbeat latency. Discord only - latency = round(float(bot.latency) * 1000) - result += f" (*latency: {latency}ms*)" - await ctx.send(result) - - @bot.hybrid_command(name="howl", description="Attempt a howl") - async def cmd_howl(ctx): - response = cc.handle_howl_command(ctx) - await ctx.send(response) - - # @monitor_cmds(bot.log) - # @bot.hybrid_command(name="reload", description="Dynamically reload commands (INOP)") - # async def cmd_reload(ctx): - # """ Dynamically reloads Discord commands. """ - # try: - # import cmd_discord - # import importlib - # importlib.reload(cmd_discord) - # cmd_discord.setup(bot) - # await ctx.send("Commands reloaded on first try!") - # except Exception as e: - # try: - # await bot.reload_extension("cmd_discord") - # await ctx.send("Commands reloaded on second try!") - # except Exception as e: - # try: - # await bot.unload_extension("cmd_discord") - # await bot.load_extension("cmd_discord") - # await ctx.send("Commands reloaded on third try!") - # except Exception as e: - # await ctx.send(f"Fallback reload failed: {e}") - - @bot.hybrid_command(name="hi", description="Dev command for testing permissions system") - async def cmd_hi(ctx): - user_id = str(ctx.author.id) - user_roles = [role.name.lower() for role in ctx.author.roles] # Normalize to lowercase - - if not has_permission("hi", user_id, user_roles, "discord"): - await ctx.send("You don't have permission to use this command.") - return - - await ctx.send("Hello there!") - - # @monitor_cmds(bot.log) - # @bot.hybrid_command(name="quote", description="Interact with the quotes system") - # async def cmd_quote(ctx, query: str = None): - # """ - # !quote - # !quote add - # !quote remove - # !quote - # """ - # if not bot.db_conn: - # return await ctx.send("Database is unavailable, sorry.") - - # args = query.split() - - # # Send to our shared logic - # await cc.handle_quote_command( - # db_conn=bot.db_conn, - # log_func=bot.log, - # is_discord=True, - # ctx=ctx, - # args=list(args), - # get_twitch_game_for_channel=None # None for Discord - # ) - - # @monitor_cmds(bot.log) - # @bot.hybrid_group(name="quote", description="Interact with the quotes system", with_app_command=True) - # async def cmd_quote(ctx, query: str = None): - # """ - # Usage: - # !quote -> get a random quote - # !quote -> get a specific quote by number - # As a slash command, leave the query blank for a random quote or type a number. - # """ - # if not bot.db_conn: - # return await ctx.send("Database is unavailable, sorry.") - - # # Only process the base command if no subcommand was invoked. - # # When query is provided, split it into arguments (for a specific quote lookup). - # args = query.split() if query else [] - - # await cc.handle_quote_command( - # db_conn=bot.db_conn, - # log_func=bot.log, - # is_discord=True, - # ctx=ctx, - # args=args, - # get_twitch_game_for_channel=None # None for Discord - # ) + # ------------------------------------------------------------------------- + # TEXTUAL COMMAND: quote + # ------------------------------------------------------------------------- @bot.command(name="quote") - async def cmd_quote(ctx, *, arg_str: str = ""): + async def cmd_quote_text(ctx, *, arg_str: str = ""): """ - Handles the !quote command with multiple subcommands. - + Handle the '!quote' command with multiple subcommands. + Usage: - - `!quote` - -> Retrieves a random (non-removed) quote. - - `!quote ` - -> Retrieves the specific quote by ID. - - `!quote add ` - -> Adds a new quote and replies with its quote number. - - `!quote remove ` - -> Removes the specified quote. - - `!quote restore ` - -> Restores a previously removed quote. - - `!quote info ` - -> Displays stored information about the quote (as an embed on Discord). - - `!quote search [keywords]` - -> Searches for the best matching quote based on the provided keywords. - - `!quote last/latest/newest` - -> Retrieves the latest (most recent) non-removed quote. + - !quote + -> Retrieves a random (non-removed) quote. + - !quote + -> Retrieves a specific quote by its ID. + - !quote add + -> Adds a new quote and replies with its quote number. + - !quote remove + -> Removes the specified quote. + - !quote restore + -> Restores a previously removed quote. + - !quote info + -> Displays stored information about the quote. + - !quote search [keywords] + -> Searches for the best matching quote. + - !quote latest + -> Retrieves the latest (most recent) non-removed quote. """ if not globals.init_db_conn: - await ctx.send("Database is unavailable, sorry.") + await ctx.reply("Database is unavailable, sorry.") return - # Parse the arguments from the message text args = arg_str.split() if arg_str else [] globals.log(f"'quote' command initiated with arguments: {args}", "DEBUG") - result = await cc.handle_quote_command( db_conn=globals.init_db_conn, is_discord=True, @@ -206,22 +74,82 @@ def setup(bot): args=args, get_twitch_game_for_channel=None ) - globals.log(f"'quote' result: {result}", "DEBUG") - - # If the result is a discord.Embed, send it as an embed; otherwise, send plain text. if hasattr(result, "to_dict"): - await ctx.send(embed=result) + await ctx.reply(embed=result) else: - await ctx.send(result) + await ctx.reply(result) - ###################### - # The following log entry must be last in the file to verify commands loading as they should - ###################### - # Debug: Print that commands are being registered + # ------------------------------------------------------------------------- + # TEXTUAL COMMAND: howl + # ------------------------------------------------------------------------- + @bot.command(name="howl") + async def cmd_howl_text(ctx): + """ + Handle the '!howl' command. + + Usage: + - !howl + -> Attempts a howl. + - !howl stat + -> Looks up howling stats for a user (defaults to self if not provided). + """ + result = cc.handle_howl_command(ctx) + await ctx.reply(result) + + + # ------------------------------------------------------------------------- + # TEXTUAL COMMAND: help + # ------------------------------------------------------------------------- + @bot.command(name="help") + async def cmd_help_text(ctx, *, command: str = ""): + """ + Get help information about commands. + + Usage: + - !help + -> Provides a list of all commands with brief descriptions. + - !help + -> Provides detailed help information for the specified command. + """ + result = await handle_help_command(ctx, command, bot, is_discord=True) + await ctx.reply(result) + + + # ------------------------------------------------------------------------- + # SLASH COMMAND: help + # ------------------------------------------------------------------------- + @bot.tree.command(name="help", description="Get information about commands", guild=primary_guild["object"]) + @app_commands.describe(command="The command to get help info about. Defaults to 'help'") + async def cmd_help_slash(interaction: discord.Interaction, command: Optional[str] = ""): + result = await handle_help_command(interaction, command, bot, is_discord=True) + await interaction.response.send_message(result) + + + # ------------------------------------------------------------------------- + # TEXTUAL COMMAND: ping + # ------------------------------------------------------------------------- + @bot.command(name="ping") + async def cmd_ping_text(ctx): + """ + Check the bot's uptime and latency. + + Usage: + - !ping + -> Returns the bot's uptime along with its latency in milliseconds. + """ + result = cc.ping() + latency = round(float(bot.latency) * 1000) + result += f" (*latency: {latency}ms*)" + await ctx.reply(result) + + + # ------------------------------------------------------------------------- + # Final logging: list registered commands. + # ------------------------------------------------------------------------- try: command_names = [cmd.name for cmd in bot.commands] # Extract command names globals.log(f"Registering commands for Discord: {command_names}", "DEBUG") except Exception as e: - globals.log(f"An error occured while printing registered commands for Discord: {e}", "WARNING") \ No newline at end of file + globals.log(f"An error occurred while printing registered commands for Discord: {e}", "WARNING") diff --git a/config.json b/config.json index df9e4d1..d6fa04b 100644 --- a/config.json +++ b/config.json @@ -1,5 +1,5 @@ { - "discord_guilds": [896713616089309184, 1011543769344135168], + "discord_guilds": [896713616089309184], "twitch_channels": ["OokamiKunTV", "ookamipup"], "command_modules": ["cmd_discord", "cmd_twitch", "cmd_common"], "logging": { diff --git a/globals.py b/globals.py index 1f4348f..51fdad4 100644 --- a/globals.py +++ b/globals.py @@ -2,15 +2,39 @@ import time import json import sys import traceback +import discord -# Store the start time globally +# Store the start time globally. _bot_start_time = time.time() def get_bot_start_time(): - """Retrieve the bot's start time globally.""" + """ + Retrieve the bot's start time. + + This function returns the Unix timestamp (in seconds) when the bot was started. + The timestamp is stored in the global variable `_bot_start_time`, which is set + when the module is first imported. + + Returns: + float: The Unix timestamp representing the bot's start time. + """ return _bot_start_time def load_config_file(): + """ + Load the configuration file. + + This function attempts to read the JSON configuration from 'config.json' + in the current directory and return its contents as a dictionary. If the + file is not found or if the file contains invalid JSON, an error message + is printed and the program terminates with a non-zero exit code. + + Returns: + dict: The configuration data loaded from 'config.json'. + + Raises: + SystemExit: If 'config.json' is missing or cannot be parsed. + """ CONFIG_PATH = "config.json" try: with open(CONFIG_PATH, "r") as f: @@ -23,6 +47,7 @@ def load_config_file(): print(f"Error parsing config.json: {e}") sys.exit(1) + # Load configuration file config_data = load_config_file() @@ -30,19 +55,32 @@ config_data = load_config_file() # Simple Logging System ############################### -def log(message, level="INFO", exec_info=False): +def log(message: str, level="INFO", exec_info=False, linebreaks=False): """ - A simple logging function with adjustable log levels. - Logs messages in a structured format. + Log a message with the specified log level. - Available levels:\n - DEBUG = Information useful for debugging\n - INFO = Informational messages\n - WARNING = Something happened that may lead to issues\n - ERROR = A non-critical error has happened\n - CRITICAL = A critical, but non-fatal, error\n - FATAL = Fatal error. Program exits after logging this\n\n - See 'config.json' for disabling/enabling logging levels + Capable of logging individual levels to the terminal and/or logfile separately. + Can also append traceback information if needed, and is capable of preserving/removing linebreaks from log messages as needed. + + Args: + message (str): The message to log. + level (str, optional): Log level of the message. Defaults to "INFO". + exec_info (bool, optional): If True, append traceback information. Defaults to False. + linebreaks (bool, optional): If True, preserve line breaks in the log. Defaults to False. + + Available levels: + DEBUG - Information useful for debugging. + INFO - Informational messages. + WARNING - Something happened that may lead to issues. + ERROR - A non-critical error has occurred. + CRITICAL - A critical, but non-fatal, error occurred. + FATAL - Fatal error; program exits after logging this. + + See: + config.json for further configuration options under "logging". + + Example: + log("An error occured during processing", "ERROR", exec_info=True, linebreaks=False) """ # Initiate logfile @@ -99,26 +137,88 @@ def log(message, level="INFO", exec_info=False): sys.exit(1) def reset_curlogfile(): + """ + Clear the current log file. + + This function constructs the current log file path by prepending 'cur_' + to the log file path specified in the configuration data under the "logging" + section. It then opens the file in write mode, effectively truncating and + clearing its contents. + + If an exception occurs while attempting to clear the log file, the error is + silently ignored. + """ # Initiate logfile - lfp = config_data["logging"]["logfile_path"] # Log File Path - clfp = f"cur_{lfp}" # Current Log File Path + lfp = config_data["logging"]["logfile_path"] # Log File Path + clfp = f"cur_{lfp}" # Current Log File Path try: open(clfp, "w") - #log(f"Current-run logfile cleared", "DEBUG") + # log(f"Current-run logfile cleared", "DEBUG") except Exception as e: - #log(f"Failed to clear current-run logfile: {e}") + # log(f"Failed to clear current-run logfile: {e}") pass + def init_db_conn(): + """ + Initialize and return a database connection. + + This function reads the configuration settings and attempts to establish a + connection to the database by invoking `modules.db.init_db_connection()`. If + no valid connection is obtained (i.e. if the connection is None), it logs a + fatal error and terminates the program using sys.exit(1). If an exception is + raised during the initialization process, the error is logged and the function + returns None. + + Returns: + DatabaseConnection or None: A valid database connection object if + successfully established; otherwise, None (or the program may exit if the + connection is missing). + """ try: import modules.db db_conn = modules.db.init_db_connection(config_data) if not db_conn: - # If we get None, it means FATAL. We might sys.exit(1) or handle it differently. + # If we get None, it means a fatal error occurred. log("Terminating bot due to no DB connection.", "FATAL") sys.exit(1) return db_conn except Exception as e: log(f"Unable to initialize database!: {e}", "FATAL") - return None \ No newline at end of file + return None + +class Constants: + @property + def config_data(self) -> dict: + """Returns a dictionary of the contents of the config.json config file""" + return load_config_file() + + def bot_start_time(self) -> float: + """Returns the bot epoch start time""" + return _bot_start_time + + def primary_discord_guild(self) -> object | None: + """ + Retrieve the primary Discord guild from the configuration. + + This function attempts to obtain the primary Discord guild based on the + configuration data stored in `config_data["discord_guilds"]`. It converts the first + guild ID in the list to an integer and then creates a `discord.Object` from it. If the + configuration defines more than one (or fewer than the expected number of) guilds, the function + returns `None` for the guild ID. + + Returns: + dict: A dictionary with the following keys: + - "object": A `discord.Object` representing the primary Discord guild if exactly one + guild is defined; otherwise, `None`. + - "id": The integer ID of the primary guild if available; otherwise, `None`. + """ + primary_guild_object = None + primary_guild_int = int(config_data["discord_guilds"][0]) if len(config_data["discord_guilds"]) == 1 else None + if primary_guild_int: + primary_guild_object = discord.Object(id=primary_guild_int) + return_dict = {"object": primary_guild_object, "id": primary_guild_int} + return return_dict + +constants = Constants() \ No newline at end of file diff --git a/modules/utility.py b/modules/utility.py index 169d07c..ad24713 100644 --- a/modules/utility.py +++ b/modules/utility.py @@ -6,6 +6,7 @@ import re import functools import inspect import uuid +from typing import Union from modules.db import run_db_operation, lookup_user, log_message import modules.utility as utility import discord @@ -156,23 +157,39 @@ DICTIONARY_PATH = "dictionary/" # Path to dictionary files def format_uptime(seconds: float) -> tuple[str, int]: """ - Convert seconds into a human-readable string: - - Example outputs: - "32 minutes" - "8 days, 4 hours" - "1 year, 3 months" - - Returns a tuple: - (Human-readable string, total seconds) + Convert a duration in seconds into a human-readable string using up to two significant time units. + + This function breaks down the given number of seconds into larger time units (years, months, + days, hours, minutes, and seconds) and returns a string representing the duration using only the + two most significant non-zero units. For example: + - 192000 seconds might be formatted as "2 days, 5 hours" + - 32 minutes will be formatted as "32 minutes" + - 37000000 seconds could be formatted as "1 year, 3 months" + + Args: + seconds (float): The total number of seconds to convert. + + Returns: + tuple(str, int): + A tuple containing: + - A human-readable string representing the duration (using up to two time units). + - The original duration in seconds as an integer. + + Examples: + >>> format_uptime(192000) + ('2 days, 5 hours', 192000) + >>> format_uptime(60) + ('1 minute', 60) """ seconds = int(seconds) # Ensure integer seconds seconds_int = seconds - # Define time units + # Define time units as tuples of (unit name, seconds per unit) units = [ ("year", 31536000), # 365 days - ("month", 2592000), # 30 days - ("day", 86400), # 24 hours - ("hour", 3600), # 60 minutes + ("month", 2592000), # 30 days + ("day", 86400), # 24 hours + ("hour", 3600), # 60 minutes ("minute", 60), ("second", 1) ] @@ -184,18 +201,28 @@ def format_uptime(seconds: float) -> tuple[str, int]: if value > 0: time_values.append(f"{value} {unit_name}{'s' if value > 1 else ''}") # Auto pluralize - # Return only the **two most significant** time units (e.g., "3 days, 4 hours") + # Return only the two most significant time units (e.g., "3 days, 4 hours") return (", ".join(time_values[:2]), seconds_int) if time_values else ("0 seconds", 0) + def get_random_reply(dictionary_name: str, category: str, **variables) -> str: """ - Fetches a random string from a given dictionary and category. - Supports variable substitution using keyword arguments. + Fetch a random reply from a specified dictionary file and category. - :param dictionary_name: The name of the dictionary file (without .json) - :param category: The category (key) inside the dictionary to fetch a response from - :param variables: Keyword arguments to replace placeholders in the string - :return: A formatted string with the variables replaced + This function loads a JSON file—named after the given dictionary (without the .json extension) + and located in the DICTIONARY_PATH directory—and retrieves a random reply from the list of + responses under the specified category. It then substitutes any placeholders in the reply + string with the provided keyword arguments. If the file does not exist, the JSON is invalid, + or the category is missing or not a list, the function returns an appropriate error message. + + Args: + dictionary_name (str): The base name of the dictionary file (without the .json extension). + category (str): The key within the JSON file representing the category of replies. + **variables: Arbitrary keyword arguments for substituting placeholders in the reply string. + + Returns: + str: A formatted reply string with the variables substituted, or an error message if the + file, category, or JSON data is invalid. """ file_path = os.path.join(DICTIONARY_PATH, f"{dictionary_name}.json") @@ -219,6 +246,7 @@ def get_random_reply(dictionary_name: str, category: str, **variables) -> str: # Replace placeholders with provided variables return response.format(**variables) + ############################## # Basic sanitization # DO NOT RELY SOLELY ON THIS @@ -229,34 +257,47 @@ def sanitize_user_input( max_length: int = 500 ): """ - A whitelisting-based function for sanitizing user input. + Sanitize user input using a whitelisting approach. - Returns a tuple of: - (sanitized_str, sanitization_applied_bool, sanitization_reason, original_str) + This function sanitizes the provided user input by applying a whitelist filter based on + the specified usage context. For inputs intended for calculation ("CALC"), it retains only + digits, mathematical operators, parentheses, and related characters. For general text + ("GENERAL"), it permits a broader range of characters including letters, numbers, punctuation, + symbols, and separators. The function also truncates the input to a maximum length, removes + unwanted whitespace characters, and applies additional filtering using either a regex library + (if available) or a fallback method. - :param user_input: The raw string from the user (e.g., from Twitch or Discord). - :param usage: - - 'CALC': Keep digits, math operators, parentheses, etc. - - 'GENERAL': Keep typical readable characters & punctuation. - :param max_length: Truncate the input if it exceeds this length. - :return: (sanitized_str, bool, reason_string, original_str) + Args: + user_input (str): The raw input string from the user (e.g., from Twitch or Discord). + usage (str, optional): The context for sanitization. Accepted values are: + - "CALC": Retain only characters relevant for mathematical expressions. + - "GENERAL": Allow typical readable characters and punctuation. + Defaults to "GENERAL". + max_length (int, optional): The maximum allowed length of the input. Any characters beyond + this limit will be truncated. Defaults to 500. - ====================== - SECURITY RECOMMENDATIONS - ====================== - 1) For database storage (MariaDB, etc.): - - **Always** use parameterized queries or an ORM with bound parameters. - - Do not rely solely on string sanitization to prevent SQL injection. - - 2) For code execution (e.g., 'eval'): - - Avoid using eval/exec on user input. - - If you must, consider a restricted math parser or an audited sandbox. - - 3) For HTML sanitization: - - Bleach is deprecated; research modern alternatives or frameworks that - safely sanitize HTML output. This function does *not* sanitize HTML tags. + Returns: + tuple: A tuple in the form: + (sanitized_str, sanitization_applied, reason_string, original_str) + where: + - sanitized_str (str): The sanitized version of the input. + - sanitization_applied (bool): True if any modifications were applied; otherwise, False. + - reason_string (str): A semicolon-separated string explaining the sanitization steps. + - original_str (str): The original, unmodified input string. + + Security Recommendations: + 1) For Database Storage (e.g., MariaDB): + - Always use parameterized queries or an ORM with bound parameters. + - Do not rely solely on string sanitization to prevent SQL injection. + + 2) For Code Execution (e.g., using eval): + - Avoid using eval/exec on user input. + - If execution of user input is required, consider a restricted math parser or an audited sandbox. + + 3) For HTML Sanitization: + - Bleach is deprecated; consider modern alternatives or frameworks that safely sanitize HTML output. + - Note: This function does not sanitize HTML tags. """ - original_string = str(user_input) reasons = [] sanitization_applied = False @@ -284,8 +325,8 @@ def sanitize_user_input( if USE_REGEX_LIB: # Remove ASCII control chars (0-31, 127) first step1 = re.sub(r"[\x00-\x1F\x7F]", "", sanitized) - # Then apply a fairly broad whitelist: - # \p{L}: letters; \p{N}: numbers; \p{P}: punctuation; \p{S}: symbols; \p{Z}: separators (including spaces). + # Then apply a broad whitelist: + # \p{L}: letters; \p{N}: numbers; \p{P}: punctuation; \p{S}: symbols; \p{Z}: separators (including spaces) # This keeps emojis, foreign characters, typical punctuation, etc. pattern = r"[^\p{L}\p{N}\p{P}\p{S}\p{Z}]" new_sanitized = regex.sub(pattern, "", step1) @@ -297,7 +338,7 @@ def sanitize_user_input( else: # Fallback: If 'regex' is not installed, remove control chars and keep ASCII printable only. step1 = re.sub(r"[\x00-\x1F\x7F]", "", sanitized) - pattern = r"[^ -~]" # Keep only ASCII 32-126 + pattern = r"[^ -~]" # Keep only ASCII characters 32-126 new_sanitized = re.sub(pattern, "", step1) if new_sanitized != sanitized: @@ -312,43 +353,66 @@ def sanitize_user_input( reason_string = "; ".join(reasons) return (sanitized, sanitization_applied, reason_string, original_string) + ##################### # Help command logic ##################### async def handle_help_command(ctx, command_name, bot, is_discord): """ - Called by the platform-specific help commands to provide the help text. - :param ctx: discord.py or twitchio context - :param command_name: e.g. "quote" or None if user typed just "!help" - :param bot: The current bot instance - :param is_discord: True for Discord, False for Twitch - """ + Provide help text for a specific command or list all commands. + This asynchronous function is called by platform-specific help commands to generate and + return the appropriate help message. It retrieves the help data stored on the bot (parsed + from a JSON file) and checks whether the requested command exists in that data. If no + specific command is provided (i.e. `command_name` is None), it returns a list of all loaded + commands. Depending on the platform (Discord or Twitch), it formats the help message using + the corresponding builder function. + + Args: + ctx: The context from the command invocation (from discord.py or twitchio). + command_name (str): The name of the command for which help is requested (e.g., "quote"). If + None, a list of all available commands is returned. + bot: The current bot instance, which should have a `help_data` attribute containing the help + information. + is_discord (bool): True if the bot is running on Discord; False if running on Twitch. + + Returns: + str: A formatted help message string. This message may indicate one of the following: + - A list of all loaded commands (if no command name is provided), + - Detailed help for a specific command (if help data exists for that command), + - A notice that the command is loaded but lacks help data, + - A notice that the command is deprecated/unloaded, or + - An error message if the help data is missing or invalid. + + Side Effects: + Logs warnings or errors if the help data is missing, improperly structured, or if the + command is not found. + """ # If there's no loaded help_data, we can't do much if not hasattr(bot, "help_data") or not bot.help_data: return await "No help data found." help_data = bot.help_data # The parsed JSON from e.g. help_discord.json if "commands" not in help_data: - return "Invalid help data structure (no 'commands' key).\n*This is due to an error with the help file.*" + return "Invalid help data structure (no 'commands' key).\n*This is due to an error with the help file.*" if not command_name: # User typed just "!help" => list all known commands from this bot loaded_cmds = get_loaded_commands(bot, is_discord) if not loaded_cmds: - return "I have no commands loaded." + return "I have no commands loaded." else: if is_discord: help_str = f"I currently offer these commands:" for cmd in loaded_cmds: - help_str += f"\n- !{cmd}" - help_str += f"\n*Use '!help ' for more details.*" + help_str += f"\n- `{cmd}`" + help_str += f"\n*Use `!help ` for more details.*" return help_str else: short_list = ", ".join(loaded_cmds) # We can also mention "Use !help [command] for more info." - return f"I currently offer these commands:{short_list}. \nUse '!help ' for details." + return f"I currently offer these commands: {short_list}. \nUse '!help ' for details." # 1) Check if the command is loaded loaded = (command_name in get_loaded_commands(bot, is_discord)) @@ -374,15 +438,33 @@ async def handle_help_command(ctx, command_name, bot, is_discord): return f"I'm sorry, I don't offer a command named '{command_name}'." + def initialize_help_data(bot, help_json_path, is_discord): """ - Loads help data from a JSON file, stores it in bot.help_data, - then verifies each loaded command vs. the help_data. - Logs any mismatches: - - Commands in help file but not loaded => "deprecated" - - Loaded commands not in help file => "missing help" - """ + Load help data from a JSON file and verify bot commands against it. + This function loads help data from the specified JSON file and stores the parsed data + in the bot's `help_data` attribute. After loading the data, it cross-checks the commands + loaded on the bot with the commands defined in the help file. The function logs warnings + for any discrepancies: + - If a command is present in the help file but not loaded on the bot, it logs a warning + (indicating the command may be deprecated). + - If a command is loaded on the bot but missing from the help file, it logs a warning + (indicating missing help information). + + Args: + bot: The bot instance, which will have its `help_data` attribute updated. + help_json_path (str): The file path to the JSON file containing help data. + is_discord (bool): A flag indicating whether the bot is a Discord bot (True) or a Twitch bot (False). + + Returns: + None + + Side Effects: + - Updates the bot's `help_data` attribute with the contents of the JSON file. + - Logs warnings or errors if the help file is missing, cannot be parsed, or if there are mismatches + between the loaded commands and the help data. + """ platform_name = "Discord" if is_discord else "Twitch" if not os.path.exists(help_json_path): @@ -419,7 +501,29 @@ def initialize_help_data(bot, help_json_path, is_discord): globals.log(f"Command '{cmd}' is loaded on {platform_name} but no help info is provided in {help_json_path}.", "WARNING") + def get_loaded_commands(bot, is_discord): + """ + Retrieve and sort the list of loaded commands from a bot instance. + + This function examines the provided bot instance and extracts its registered commands. + For a Discord bot (when `is_discord` is True), it iterates over `bot.commands` and collects + the command names. For a Twitch bot (when `is_discord` is False), it iterates over `bot._commands` + and collects the command objects. Throughout the process, the function logs debug and error + messages to help trace the execution flow. Finally, it returns the list of commands in sorted order. + + Args: + bot: The bot instance from which to retrieve commands. This may be either a Discord or Twitch bot. + is_discord (bool): Indicates whether the bot is a Discord bot. If False, the function assumes the bot + is a Twitch bot. + + Returns: + list: A sorted list of commands. For a Discord bot, this list contains command names (strings); + for a Twitch bot, it contains command objects as stored in `bot._commands`. + + Side Effects: + Logs debug, warning, and error messages regarding the command processing. + """ from discord.ext import commands as discord_commands from twitchio.ext import commands as twitch_commands @@ -433,12 +537,11 @@ def get_loaded_commands(bot, is_discord): # For Discord if is_discord: - #if isinstance(bot, discord_commands.Bot): try: - # 'bot.commands' is a set of Command objects + # 'bot.commands' is a set of Command objects. for cmd_obj in bot.commands: commands_list.append(cmd_obj.name) - debug_level ="DEBUG" if len(commands_list) > 0 else "WARNING" + debug_level = "DEBUG" if len(commands_list) > 0 else "WARNING" globals.log(f"Discord commands body: {commands_list}", f"{debug_level}") except Exception as e: globals.log(f"Error retrieving Discord commands: {e}", "ERROR") @@ -446,7 +549,7 @@ def get_loaded_commands(bot, is_discord): try: for cmd_obj in bot._commands: commands_list.append(cmd_obj) - debug_level ="DEBUG" if len(commands_list) > 0 else "WARNING" + debug_level = "DEBUG" if len(commands_list) > 0 else "WARNING" globals.log(f"Twitch commands body: {commands_list}", f"{debug_level}") except Exception as e: globals.log(f"Error retrieving Twitch commands: {e}", "ERROR") @@ -459,7 +562,29 @@ def get_loaded_commands(bot, is_discord): def build_discord_help_message(cmd_name, cmd_help_dict): """ - A verbose multi-line string for Discord. + Build a verbose, multi-line help message for Discord. + + This function constructs a detailed help message for a Discord command using the + provided command name and a dictionary of help information. The dictionary may include + a description, subcommands with their arguments and descriptions, and example usage + scenarios. The generated message is formatted as a multi-line string with Markdown + styling for use on Discord. + + Args: + cmd_name (str): The name of the command. + cmd_help_dict (dict): A dictionary containing help details for the command. + Expected keys include: + - "description" (str): A detailed description of the command. + - "subcommands" (dict, optional): A mapping of subcommand names to a dictionary + with keys "args" (str) and "desc" (str) representing the arguments and a short + description of the subcommand. + - "examples" (list, optional): A list of example strings demonstrating how to use + the command. Each example should be a string that optionally contains a description + after a " : " separator. + + Returns: + str: A multi-line string formatted for Discord that includes the command description, + subcommands (if any), and example usage. """ description = cmd_help_dict.get("description", "No description available.\n") subcommands = cmd_help_dict.get("subcommands", {}) @@ -484,16 +609,36 @@ def build_discord_help_message(cmd_name, cmd_help_dict): # Handle missing description case ex_cmd = ex_arr[0] - ex_note = str(f"\n {ex_arr[1]}") if len(ex_arr) > 1 else "" - + ex_note = f"\n {ex_arr[1]}" if len(ex_arr) > 1 else "" lines.append(f"- `{ex_cmd}`{ex_note}") return "\n".join(lines) + def build_twitch_help_message(cmd_name, cmd_help_dict): """ - A concise, possibly single-line help for Twitch. + Build a concise Twitch help message for a command. + + This function constructs a help message string for a Twitch command based on the + provided command name and a dictionary of help details. The help dictionary should + contain a "description" of the command and may include a "subcommands" mapping. For + each subcommand, the function builds usage examples by appending the subcommand name + and its arguments (if any) to the base command. If no subcommands are defined, the + usage defaults to the base command. + + Args: + cmd_name (str): The name of the command (without the "!" prefix). + cmd_help_dict (dict): A dictionary containing help details for the command. + Expected keys include: + - "description" (str): A brief description of the command. + - "subcommands" (dict, optional): A mapping of subcommand names to their details. + Each value should be a dictionary that may contain an "args" key (str) representing + additional command arguments. + + Returns: + str: A formatted help message string that includes the command description and a usage + line with examples of how to invoke the command and its subcommands. """ description = cmd_help_dict.get("description", "No description available.") subcommands = cmd_help_dict.get("subcommands", {}) @@ -513,6 +658,7 @@ def build_twitch_help_message(cmd_name, cmd_help_dict): return f"Help for !{cmd_name}: {description}. Usage: {usage_line}" + async def send_message(ctx, text): """ Minimal helper to send a message to either Discord or Twitch. @@ -522,38 +668,53 @@ async def send_message(ctx, text): def track_user_activity( db_conn, platform: str, - user_id: str, + user_id: str|int, username: str, display_name: str, user_is_bot: bool = False ): """ - Checks or creates/updates a user in the 'users' table for the given platform's message. + Create or update a user record in the database for a given platform. - :param db_conn: The active DB connection - :param platform: "discord" or "twitch" - :param user_id: e.g., Discord user ID or Twitch user ID - :param username: The raw username (no #discriminator for Discord) - :param display_name: The user’s display name - :param user_is_bot: Boolean if the user is recognized as a bot on that platform + This function checks whether a user with the specified user ID exists in the 'users' + table for the provided platform (either "discord" or "twitch"). If a matching record is found, + the function compares the provided username, display name, and bot status with the stored values, + updating the record if any discrepancies are detected. If no record exists, a new user record is + created with a generated UUID. + + Args: + db_conn: The active database connection used to perform database operations. + platform (str): The platform to which the user belongs. Expected values are "discord" or "twitch". + user_id (str): The unique identifier of the user on the given platform. + username (str): The raw username of the user (for Discord, this excludes the discriminator). + display_name (str): The display name of the user. + user_is_bot (bool, optional): Indicates whether the user is a bot on the platform. + Defaults to False. + + Returns: + None + + Side Effects: + - Logs debugging and error messages via the global logger. + - Updates an existing user record if discrepancies are found. + - Inserts a new user record if no existing record is found. """ globals.log(f"UUI Lookup for: {username} - {user_id} ({platform.lower()}) ...", "DEBUG") - # Decide which column we use for the ID lookup - # "discord_user_id" or "twitch_user_id" + # Decide which column we use for the ID lookup ("discord_user_id" or "twitch_user_id") if platform.lower() in ("discord", "twitch"): identifier_type = f"{platform.lower()}_user_id" else: globals.log(f"Unknown platform '{platform}' in track_user_activity!", "WARNING") return - # 1) Try to find an existing user row + # 1) Try to find an existing user row. user_data = lookup_user(db_conn, identifier=user_id, identifier_type=identifier_type) if user_data: - # Found an existing row for that user ID on this platform - # Check if the username or display_name is different => if so, update + # Found an existing row for that user ID on this platform. + # Check if the username or display_name is different and update if necessary. need_update = False column_updates = [] params = [] @@ -571,10 +732,6 @@ def track_user_activity( column_updates.append("discord_user_display_name = ?") params.append(display_name) - # Possibly check user_is_bot - # If it's different than what's stored, update - # (We must add a column in your table for that if you want it stored per-platform.) - # For demonstration, let's store it in "user_is_bot" if user_data["user_is_bot"] != user_is_bot: need_update = True column_updates.append("user_is_bot = ?") @@ -604,7 +761,6 @@ def track_user_activity( column_updates.append("twitch_user_display_name = ?") params.append(display_name) - # Possibly store is_bot in user_is_bot if user_data["user_is_bot"] != user_is_bot: need_update = True column_updates.append("user_is_bot = ?") @@ -624,8 +780,7 @@ def track_user_activity( globals.log(f"Updated Twitch user '{username}' (display '{display_name}') in 'users'.", "DEBUG") else: - # 2) No row found => create a new user row - # Generate a new UUID for this user + # 2) No row found => create a new user row. new_uuid = str(uuid.uuid4()) if platform.lower() == "discord": @@ -640,8 +795,7 @@ def track_user_activity( VALUES (?, ?, ?, ?, ?) """ params = (new_uuid, user_id, username, display_name, int(user_is_bot)) - - else: # "twitch" + else: # platform is "twitch" insert_sql = """ INSERT INTO users ( UUID, @@ -660,6 +814,7 @@ def track_user_activity( else: globals.log(f"Failed to create new user row for {platform} user '{username}'", "ERROR") + from modules.db import log_bot_event def log_bot_startup(db_conn): @@ -681,19 +836,40 @@ def generate_link_code(): def time_since(start, end=None, format=None): """ - Returns the epoch time since the start value. + Calculate and format the elapsed time between two epoch timestamps. - :param start: The epoch time to check against. - :param end: The epoch time to compare with. - Defaults to current time. - :param format: One of 's', 'm', 'h', or 'd' corresponding to seconds, minutes, hours, or days. - Defaults to "s" (seconds). - :return: A tuple (x, y, total_elapsed) where: - - For "s": x is whole seconds and y is the remaining milliseconds. - - For "m": x is whole minutes and y is the remaining seconds. - - For "h": x is whole hours and y is the remaining minutes. - - For "d": x is whole days and y is the remaining hours. - - total_elapsed is the complete elapsed time in seconds. + This function computes the elapsed time between a starting epoch timestamp and an ending + epoch timestamp. If no end time is provided, the current time is used. The elapsed time is + formatted according to the specified unit: + - 's': whole seconds and remaining milliseconds. + - 'm': whole minutes and remaining seconds. + - 'h': whole hours and remaining minutes. + - 'd': whole days and remaining hours. + If an invalid format is given, a warning is logged and the default format 's' is used. + + Args: + start (float): The starting epoch timestamp (in seconds). If None, an error is logged and + the function returns None. + end (float, optional): The ending epoch timestamp (in seconds). Defaults to the current time. + format (str, optional): A single character indicating the desired format of the elapsed time. + Must be one of: + 's' for seconds (with milliseconds), + 'm' for minutes (with seconds), + 'h' for hours (with minutes), or + 'd' for days (with hours). + Defaults to 's' if an invalid value is provided. + + Returns: + tuple: A tuple (x, y, total_elapsed) where: + - For 's': x is a string representing whole seconds (with "s" appended) and y is a string + representing the remaining milliseconds (with "ms" appended). + - For 'm': x is a string representing whole minutes (with "m" appended) and y is a string + representing the remaining seconds (with "s" appended). + - For 'h': x is a string representing whole hours (with "h" appended) and y is a string + representing the remaining minutes (with "m" appended). + - For 'd': x is a string representing whole days (with "d" appended) and y is a string + representing the remaining hours (with "h" appended). + - total_elapsed (float): The total elapsed time in seconds. """ # Ensure a start time is provided. if start is None: @@ -740,13 +916,14 @@ def time_since(start, end=None, format=None): return (x, y, since) + def wfstl(): """ Write Function Start To Log (debug) Writes the calling function to log under the DEBUG category. """ caller_function_name = inspect.currentframe().f_back.f_code.co_name - globals.log(f"Function {caller_function_name} started processing") + globals.log(f"Function {caller_function_name} started processing", "DEBUG") def wfetl(): """ @@ -754,7 +931,67 @@ def wfetl(): Writes the calling function to log under the DEBUG category. """ caller_function_name = inspect.currentframe().f_back.f_code.co_name - globals.log(f"Function {caller_function_name} finished processing") + globals.log(f"Function {caller_function_name} finished processing", "DEBUG") + +async def get_guild_info(bot: discord.Client, guild_id: Union[int, str]) -> dict: + """ + Retrieve information about a Discord guild given its ID. + + This asynchronous function attempts to retrieve a guild's information by its ID. The provided + guild_id is first converted to an integer if it is not already one. The function then tries to + obtain the guild from the bot's cache using `bot.get_guild()`. If the guild is not found in the + cache, it fetches the guild from the Discord API using `bot.fetch_guild()`. If the guild cannot be + found or if an error occurs during the fetch, the function raises an appropriate exception. + + The function returns a dictionary containing basic information about the guild, including: + - id (int): The unique identifier of the guild. + - name (str): The name of the guild. + - owner_id (int): The user ID of the guild's owner. + - member_count (int): The number of members in the guild. + - icon_url (Optional[str]): The URL of the guild's icon if available. + - created_at (Optional[str]): The ISO-formatted creation date of the guild if available. + + Args: + bot (discord.Client): The Discord client instance. + guild_id (Union[int, str]): The ID of the guild. This can be provided as an integer or as a + string that represents an integer. + + Returns: + dict: A dictionary containing the guild's information. + + Raises: + ValueError: If the provided guild_id is not convertible to an integer or if the guild is not found. + RuntimeError: If an HTTP error occurs while fetching the guild. + """ + # Ensure guild_id is an integer. + try: + guild_id = int(guild_id) + except ValueError: + raise ValueError("guild_id must be an int or a string representing an int.") + + # Try to get the guild from the cache first. + guild = bot.get_guild(guild_id) + + # If not in cache, try to fetch it from the API. + if guild is None: + try: + guild = await bot.fetch_guild(guild_id) + except discord.NotFound: + raise ValueError("Guild not found.") + except discord.HTTPException as e: + raise RuntimeError(f"An error occurred while fetching the guild: {e}") + + # Build a dictionary with some basic information. + info = { + "id": guild.id, + "name": guild.name, + "owner_id": guild.owner_id, + "member_count": guild.member_count, + "icon_url": guild.icon.url if guild.icon else None, + "created_at": guild.created_at.isoformat() if guild.created_at else None, + } + return info + ###############################################