diff --git a/bot_discord.py b/bot_discord.py index bfce4e9..7dfa1e4 100644 --- a/bot_discord.py +++ b/bot_discord.py @@ -4,14 +4,17 @@ from discord.ext import commands import importlib import cmd_discord -from modules import db +import modules +import modules.utility class DiscordBot(commands.Bot): def __init__(self, config, log_func): super().__init__(command_prefix="!", intents=discord.Intents.all()) + self.remove_command("help") # Remove built-in help function self.config = config self.log = log_func # 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 self.load_commands() def set_db_connection(self, db_conn): @@ -20,18 +23,29 @@ class DiscordBot(commands.Bot): """ self.db_conn = db_conn try: - db.ensure_quotes_table(self.db_conn, self.log) + modules.db.ensure_quotes_table(self.db_conn, self.log) except Exception as e: self.log(f"Critical: unable to ensure quotes table: {e}", "FATAL") def load_commands(self): """ - Load all commands dynamically from cmd_discord.py. + Load all commands from cmd_discord.py """ try: importlib.reload(cmd_discord) cmd_discord.setup(self) self.log("Discord commands loaded successfully.", "INFO") + + # Now load the help info from dictionary/help_discord.json + help_json_path = "dictionary/help_discord.json" + + modules.utility.initialize_help_data( + bot=self, + help_json_path=help_json_path, + is_discord=True, + log_func=self.log + ) + except Exception as e: self.log(f"Error loading Discord commands: {e}", "ERROR") diff --git a/bot_twitch.py b/bot_twitch.py index 6241021..c6d1843 100644 --- a/bot_twitch.py +++ b/bot_twitch.py @@ -6,7 +6,8 @@ from twitchio.ext import commands import importlib import cmd_twitch -from modules import db +import modules +import modules.utility class TwitchBot(commands.Bot): def __init__(self, config, log_func): @@ -16,7 +17,8 @@ class TwitchBot(commands.Bot): self.refresh_token = os.getenv("TWITCH_REFRESH_TOKEN") self.log = log_func # Use the logging function from bots.py self.config = config - self.db_conn = None # We'll set this later + self.db_conn = None # We'll set this + self.help_data = None # We'll set this later # 1) Initialize the parent Bot FIRST super().__init__( @@ -36,7 +38,7 @@ class TwitchBot(commands.Bot): """ self.db_conn = db_conn try: - db.ensure_quotes_table(self.db_conn, self.log) + modules.db.ensure_quotes_table(self.db_conn, self.log) except Exception as e: self.log(f"Critical: unable to ensure quotes table: {e}", "FATAL") @@ -122,12 +124,22 @@ class TwitchBot(commands.Bot): def load_commands(self): """ - Load all commands dynamically from cmd_twitch.py. + Load all commands from cmd_twitch.py """ try: importlib.reload(cmd_twitch) cmd_twitch.setup(self) self.log("Twitch commands loaded successfully.", "INFO") + + # Now load the help info from dictionary/help_twitch.json + help_json_path = "dictionary/help_twitch.json" + modules.utility.initialize_help_data( + bot=self, + help_json_path=help_json_path, + is_discord=False, # Twitch + log_func=self.log + ) + except Exception as e: self.log(f"Error loading Twitch commands: {e}", "ERROR") diff --git a/cmd_discord.py b/cmd_discord.py index fff9b82..03ec943 100644 --- a/cmd_discord.py +++ b/cmd_discord.py @@ -2,6 +2,7 @@ from discord.ext import commands from cmd_common import common_commands as cc from modules.permissions import has_permission +from modules.utility import handle_help_command def setup(bot, db_conn=None, log=None): @@ -16,24 +17,24 @@ def setup(bot, db_conn=None, log=None): # Auto-create the quotes table if desired if db_conn and log: cc.create_quotes_table(db_conn, log) - @bot.command() - async def greet(ctx): + @bot.command(name="greet") + async def cmd_greet(ctx): result = cc.greet(ctx.author.display_name, "Discord") await ctx.send(result) - @bot.command() - async def ping(ctx): + @bot.command(name="ping") + async def cmd_ping(ctx): result = cc.ping() await ctx.send(result) - @bot.command() - async def howl(ctx): + @bot.command(name="howl") + async def cmd_howl(ctx): """Calls the shared !howl logic.""" result = cc.howl(ctx.author.display_name) await ctx.send(result) - @bot.command() - async def reload(ctx): + @bot.command(name="reload") + async def cmd_reload(ctx): """ Dynamically reloads Discord commands. """ try: import cmd_discord @@ -44,8 +45,8 @@ def setup(bot, db_conn=None, log=None): except Exception as e: await ctx.send(f"Error reloading commands: {e}") - @bot.command() - async def hi(ctx): + @bot.command(name="hi") + 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 @@ -56,7 +57,7 @@ def setup(bot, db_conn=None, log=None): await ctx.send("Hello there!") @bot.command(name="quote") - async def quote_command(ctx, *args): + async def cmd_quote(ctx, *args): """ !quote !quote add @@ -74,4 +75,12 @@ def setup(bot, db_conn=None, log=None): ctx=ctx, args=list(args), get_twitch_game_for_channel=None # None for Discord - ) \ No newline at end of file + ) + + @bot.command(name="help") + async def cmd_help(ctx, cmd_name: str = None): + """ + e.g. !help + !help quote + """ + await handle_help_command(ctx, cmd_name, bot, is_discord=True, log_func=bot.log) \ No newline at end of file diff --git a/cmd_twitch.py b/cmd_twitch.py index 1b884b1..7451de7 100644 --- a/cmd_twitch.py +++ b/cmd_twitch.py @@ -3,6 +3,7 @@ from twitchio.ext import commands from cmd_common import common_commands as cc from modules.permissions import has_permission +from modules.utility import handle_help_command def setup(bot, db_conn=None, log=None): """ @@ -15,22 +16,22 @@ def setup(bot, db_conn=None, log=None): cc.create_quotes_table(bot.db_conn, bot.log) @bot.command(name="greet") - async def greet(ctx): + async def cmd_greet(ctx): result = cc.greet(ctx.author.display_name, "Twitch") await ctx.send(result) @bot.command(name="ping") - async def ping(ctx): + async def cmd_ping(ctx): result = cc.ping() await ctx.send(result) @bot.command(name="howl") - async def howl(ctx): + async def cmd_howl(ctx): result = cc.howl(ctx.author.display_name) await ctx.send(result) @bot.command(name="hi") - async def hi_command(ctx): + async def cmd_hi(ctx): user_id = str(ctx.author.id) # Twitch user ID user_roles = [role.lower() for role in ctx.author.badges.keys()] # "roles" from Twitch badges @@ -40,7 +41,7 @@ def setup(bot, db_conn=None, log=None): await ctx.send("Hello there!") @bot.command(name="quote") - async def quote(ctx: commands.Context): + async def cmd_quote(ctx: commands.Context): if not bot.db_conn: return await ctx.send("Database is unavailable, sorry.") @@ -58,4 +59,10 @@ def setup(bot, db_conn=None, log=None): ctx=ctx, args=args, get_twitch_game_for_channel=get_twitch_game_for_channel - ) \ No newline at end of file + ) + + @bot.command(name="help") + async def help_command(ctx): + parts = ctx.message.content.strip().split() + cmd_name = parts[1] if len(parts) > 1 else None + await handle_help_command(ctx, cmd_name, bot, is_discord=False, log_func=bot.log) \ No newline at end of file diff --git a/dictionary/help_discord.json b/dictionary/help_discord.json new file mode 100644 index 0000000..85513b7 --- /dev/null +++ b/dictionary/help_discord.json @@ -0,0 +1,68 @@ +{ + "commands": { + "help": { + "description": "Show information about available commands.", + "subcommands": {}, + "examples": ["!help", "!help quote"] + }, + "quote": { + "description": "Manage quotes (add, remove, fetch).", + "subcommands": { + "add": { + "args": "[quote_text]", + "desc": "Adds a new quote." + }, + "remove": { + "args": "[quote_number]", + "desc": "Removes the specified quote by ID." + }, + "[quote_number]": { + "desc": "Fetch a specific quote by ID." + }, + "no subcommand": { + "desc": "Fetch a random quote." + } + }, + "examples": [ + "!quote add This is my new quote : Add a new quote", + "!quote remove 3 : Remove quote # 3", + "!quote 5 : Fetch quote # 5", + "!quote : Fetch a random quote" + ] + }, + "ping": { + "description": "Check my uptime.", + "subcommands": {}, + "examples": ["!ping"] + }, + "howl": { + "description": "Attempt a howl, measured 0-100%.\n(*Adventure Command*)", + "subcommands": {}, + "examples": [ + "!howl" + ] + }, + "hi": { + "description": "Hello there.", + "subcommands": {}, + "examples": [ + "!hi" + ] + }, + "greet": { + "description": "Make me greet you to Discord!", + "subcommands": {}, + "examples": [ + "!greet" + ] + }, + "reload": { + "description": "Reload Discord commands dynamically. TODO.", + "subcommands": {}, + "examples": [ + "!reload" + ] + } + } + } + \ No newline at end of file diff --git a/dictionary/help_twitch.json b/dictionary/help_twitch.json new file mode 100644 index 0000000..ceb616d --- /dev/null +++ b/dictionary/help_twitch.json @@ -0,0 +1,40 @@ +{ + "commands": { + "help": { + "description": "Show info about available commands in Twitch chat.", + "subcommands": {}, + "examples": ["!help", "!help quote"] + }, + "quote": { + "description": "Manage quotes (add, remove, fetch).", + "subcommands": { + "add": { + "args": "[quote_text]", + "desc": "Adds a new quote." + }, + "remove": { + "args": "[quote_number]", + "desc": "Removes the specified quote by ID." + }, + "[quote_number]": { + "desc": "Fetch a specific quote by ID." + }, + "no subcommand": { + "desc": "Fetch a random quote." + } + }, + "examples": [ + "!quote add This is my new quote", + "!quote remove 3", + "!quote 5", + "!quote" + ] + }, + "ping": { + "description": "Check bot’s latency or respond with a pun.", + "subcommands": {}, + "examples": ["!ping"] + } + } + } + \ No newline at end of file diff --git a/modules/utility.py b/modules/utility.py index 7fee8ae..dfe8bfb 100644 --- a/modules/utility.py +++ b/modules/utility.py @@ -171,3 +171,179 @@ 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, log_func): + """ + 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 + :param log_func: The logging function + """ + + # 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 send_message(ctx, "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 await send_message(ctx, "Invalid help data structure (no 'commands' key).") + + if not command_name: + # User typed just "!help" => list all known commands from this bot + loaded_cmds = get_loaded_commands(bot) + if not loaded_cmds: + return await send_message(ctx, "I have no commands loaded.") + else: + short_list = ", ".join(loaded_cmds) + # We can also mention "Use !help [command] for more info." + return await send_message( + ctx, + 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)) + # 2) Check if it has help info in the JSON + cmd_help = help_data["commands"].get(command_name, None) + + if loaded and cmd_help: + # The command is loaded, and we have help info => show it + if is_discord: + msg = build_discord_help_message(command_name, cmd_help) + else: + msg = build_twitch_help_message(command_name, cmd_help) + await send_message(ctx, msg) + + elif loaded and not cmd_help: + # The command is loaded but no help info => mention that + await send_message(ctx, f"The '{command_name}' command is loaded but has no help info yet.") + elif (not loaded) and cmd_help: + # The command is not loaded, but we have an entry => mention it's unloaded/deprecated + await send_message(ctx, f"The '{command_name}' command is not currently loaded (deprecated or unavailable).") + else: + # Not loaded, no help info => not found at all + await send_message(ctx, f"I'm sorry, I don't offer a command named '{command_name}'.") + + +def initialize_help_data(bot, help_json_path, is_discord, log_func): + """ + 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" + """ + + if not os.path.exists(help_json_path): + log_func(f"Help file '{help_json_path}' not found. No help data loaded.", "WARNING") + bot.help_data = {} + return + + # Load the JSON + try: + with open(help_json_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + log_func(f"Error parsing help JSON '{help_json_path}': {e}", "ERROR") + data = {} + + bot.help_data = data + + # Now cross-check the loaded commands vs. the data + loaded_cmds = set(get_loaded_commands(bot)) + if "commands" not in data: + log_func(f"No 'commands' key in {help_json_path}, skipping checks.", "ERROR") + return + + file_cmds = set(data["commands"].keys()) + + # 1) Commands in file but not loaded + missing_cmds = file_cmds - loaded_cmds + for cmd in missing_cmds: + log_func(f"Help file has '{cmd}', but it's not loaded on this {'Discord' if is_discord else 'Twitch'} bot (deprecated?).", "WARNING") + + # 2) Commands loaded but not in file + needed_cmds = loaded_cmds - file_cmds + for cmd in needed_cmds: + log_func(f"Command '{cmd}' is loaded on {('Discord' if is_discord else 'Twitch')} but no help info is provided in {help_json_path}.", "WARNING") + + +def get_loaded_commands(bot): + commands_list = [] + + # For Discord.py + if hasattr(bot, "commands"): + for c_obj in bot.commands: + commands_list.append(c_obj.name) + + # For TwitchIO + if hasattr(bot, "all_commands"): + # each item is (command_name_str, command_object) + for cmd_name, cmd_obj in bot.all_commands.items(): + commands_list.append(cmd_name) + + return sorted(commands_list) + + +def build_discord_help_message(cmd_name, cmd_help_dict): + """ + A verbose multi-line string for Discord. + """ + description = cmd_help_dict.get("description", "No description available.\n") + subcommands = cmd_help_dict.get("subcommands", {}) + examples = cmd_help_dict.get("examples", []) + + lines = [f"**Help for `!{cmd_name}`**:", + f"Description: {description}"] + + if subcommands: + lines.append("\n**Subcommands / Arguments:**") + for sub, detail in subcommands.items(): + arg_part = detail.get("args", "") + desc_part = detail.get("desc", "") + lines.append(f"• **{sub}** {arg_part} **->** {desc_part}") + else: + lines.append("\n*No subcommands defined.*") + + if examples: + lines.append("\nExample usage:") + for ex in examples: + ex_cmd = ex.split(" : ")[0] + ex_note = ex.split(" : ")[1] + lines.append(f"- `{ex_cmd}`\n {ex_note}") + + return "\n".join(lines) + + +def build_twitch_help_message(cmd_name, cmd_help_dict): + """ + A concise, possibly single-line help for Twitch. + """ + description = cmd_help_dict.get("description", "No description available.") + subcommands = cmd_help_dict.get("subcommands", {}) + sub_line_parts = [] + for sub, detail in subcommands.items(): + if sub == "no subcommand": + sub_line_parts.append(f"!{cmd_name}") + else: + arg_part = detail.get("args", "") + if arg_part: + sub_line_parts.append(f"!{cmd_name} {sub} {arg_part}".strip()) + else: + sub_line_parts.append(f"!{cmd_name} {sub}".strip()) + + usage_line = " | ".join(sub_line_parts) if sub_line_parts else f"!{cmd_name}" + + 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. + """ + await ctx.send(text) \ No newline at end of file