diff --git a/.gitignore b/.gitignore index 0e09451..1ad44ee 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ Test ?/ .venv permissions.json local_database.sqlite -logo.* \ No newline at end of file +logo.* +_SQL_PREFILL_QUERIES_ \ No newline at end of file diff --git a/bots.py b/bots.py index fb3b602..edb8231 100644 --- a/bots.py +++ b/bots.py @@ -38,14 +38,7 @@ async def main(): # Log initial start globals.log("--------------- BOT STARTUP ---------------") # Before creating your DiscordBot/TwitchBot, initialize DB - try: - db_conn = 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. - globals.log("Terminating bot due to no DB connection.", "FATAL") - sys.exit(1) - except Exception as e: - globals.log(f"Unable to initialize database!: {e}", "FATAL") + db_conn = globals.init_db_conn() try: # Ensure FKs are enabled db.checkenable_db_fk(db_conn) diff --git a/cmd_common/common_commands.py b/cmd_common/common_commands.py index decdce7..a2dd965 100644 --- a/cmd_common/common_commands.py +++ b/cmd_common/common_commands.py @@ -27,7 +27,7 @@ def handle_howl_command(ctx) -> str: We rely on ctx to figure out the platform, the user, the arguments, etc. Return a string that the caller will send. """ - + utility.wfstl() # 1) Detect which platform # We might do something like: platform, author_id, author_name, author_display_name, args = extract_ctx_info(ctx) @@ -41,10 +41,12 @@ def handle_howl_command(ctx) -> str: target_name = args[1] else: target_name = author_name + utility.wfetl() return handle_howl_stats(ctx, platform, target_name) else: # normal usage => random generation + utility.wfetl() return handle_howl_normal(ctx, platform, author_id, author_display_name) def extract_ctx_info(ctx): @@ -52,6 +54,7 @@ def extract_ctx_info(ctx): Figures out if this is Discord or Twitch, returns (platform_str, author_id, author_name, author_display_name, args). """ + utility.wfstl() # Is it discord.py or twitchio? if hasattr(ctx, "guild"): # typically means discord.py context platform_str = "discord" @@ -72,12 +75,14 @@ def extract_ctx_info(ctx): parts = ctx.message.content.strip().split() args = parts[1:] if len(parts) > 1 else [] + utility.wfetl() return (platform_str, author_id, author_name, author_display_name, args) def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str: """ Normal usage: random generation, store in DB. """ + utility.wfstl() db_conn = ctx.bot.db_conn # random logic @@ -102,15 +107,18 @@ def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str: else: globals.log(f"Could not find user by ID={author_id} on {platform}. Not storing howl.", "WARNING") + utility.wfetl() return reply def handle_howl_stats(ctx, platform, target_name) -> str: + utility.wfstl() db_conn = ctx.bot.db_conn # Check if requesting global stats if target_name in ("_COMMUNITY_", "all", "global", "community"): stats = db.get_global_howl_stats(db_conn) if not stats: + utility.wfetl() return "No howls have been recorded yet!" total_howls = stats["total_howls"] @@ -119,6 +127,7 @@ def handle_howl_stats(ctx, platform, target_name) -> str: count_zero = stats["count_zero"] count_hundred = stats["count_hundred"] + utility.wfetl() return (f"**Community Howl Stats:**\n" f"Total Howls: {total_howls}\n" f"Average Howl: {avg_howl:.1f}%\n" @@ -128,16 +137,19 @@ def handle_howl_stats(ctx, platform, target_name) -> str: # Otherwise, lookup a single user user_data = lookup_user_by_name(db_conn, platform, target_name) if not user_data: + utility.wfetl() return f"I don't know that user: {target_name}" stats = db.get_howl_stats(db_conn, user_data["UUID"]) if not stats: + utility.wfetl() return f"{target_name} hasn't howled yet! (Try `!howl` to get started.)" c = stats["count"] a = stats["average"] z = stats["count_zero"] h = stats["count_hundred"] + utility.wfetl() return (f"{target_name} has howled {c} times, averaging {a:.1f}% " f"(0% x{z}, 100% x{h})") @@ -146,21 +158,27 @@ def lookup_user_by_name(db_conn, platform, name_str): """ Attempt to find a user by name on that platform, e.g. 'discord_username' or 'twitch_username'. """ + utility.wfstl() # same logic as before if platform == "discord": ud = db.lookup_user(db_conn, name_str, "discord_user_display_name") if ud: + utility.wfetl() return ud ud = db.lookup_user(db_conn, name_str, "discord_username") + utility.wfetl() return ud elif platform == "twitch": ud = db.lookup_user(db_conn, name_str, "twitch_user_display_name") if ud: + utility.wfetl() return ud ud = db.lookup_user(db_conn, name_str, "twitch_username") + utility.wfetl() return ud else: globals.log(f"Unknown platform {platform} in lookup_user_by_name", "WARNING") + utility.wfetl() return None @@ -168,6 +186,7 @@ def ping() -> str: """ Returns a dynamic, randomized uptime response. """ + utility.wfstl() debug = False # Use function to retrieve correct startup time and calculate uptime @@ -187,6 +206,7 @@ def ping() -> str: if debug: print(f"Elapsed time: {elapsed}\nuptime_str: {uptime_str}\nuptime_s: {uptime_s}\nselected threshold: {selected_threshold}\nresponse: {response}") + utility.wfetl() return response def greet(target_display_name: str, platform_name: str) -> str: @@ -202,11 +222,13 @@ def greet(target_display_name: str, platform_name: str) -> str: def create_quotes_table(db_conn): """ Creates the 'quotes' table if it does not exist, with the columns: - ID, QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED + ID, QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED, QUOTE_REMOVED_DATETIME Uses a slightly different CREATE statement depending on MariaDB vs SQLite. """ + utility.wfstl() if not db_conn: globals.log("No database connection available to create quotes table!", "FATAL") + utility.wfetl() return # Detect if this is SQLite or MariaDB @@ -221,7 +243,8 @@ def create_quotes_table(db_conn): QUOTE_CHANNEL TEXT, QUOTE_DATETIME TEXT, QUOTE_GAME TEXT, - QUOTE_REMOVED BOOLEAN DEFAULT 0 + QUOTE_REMOVED BOOLEAN DEFAULT 0, + QUOTE_REMOVED_DATETIME TEXT ) """ else: @@ -235,11 +258,20 @@ def create_quotes_table(db_conn): QUOTE_CHANNEL VARCHAR(100), QUOTE_DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, QUOTE_GAME VARCHAR(200), - QUOTE_REMOVED BOOLEAN DEFAULT FALSE + QUOTE_REMOVED BOOLEAN DEFAULT FALSE, + QUOTE_REMOVED_DATETIME DATETIME DEFAULT NULL ) """ db.run_db_operation(db_conn, "write", create_table_sql) + utility.wfetl() + + +def is_sqlite(db_conn): + """ + Helper function to determine if the database connection is SQLite. + """ + return 'sqlite3' in str(type(db_conn)).lower() async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_game_for_channel=None): @@ -248,7 +280,7 @@ async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_ - `db_conn`: your active DB connection - `is_discord`: True if this command is being called from Discord, False if from Twitch - `ctx`: the context object (discord.py ctx or twitchio context) - - `args`: a list of arguments (e.g. ["add", "some quote text..."] or ["remove", "3"] or ["2"] etc.) + - `args`: a list of arguments (e.g. ["add", "some quote text..."], ["remove", "3"], ["info", "3"], ["search", "foo", "bar"], or ["2"] etc.) - `get_twitch_game_for_channel`: function(channel_name) -> str or None Behavior: @@ -256,14 +288,27 @@ async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_ -> Adds a new quote, stores channel=Discord or twitch channel name, game if twitch. 2) `!quote remove N` -> Mark quote #N as removed. - 3) `!quote N` + 3) `!quote info N` + -> Returns stored info about quote #N (with a Discord embed if applicable). + 4) `!quote search [keywords]` + -> Searches for the best matching non-removed quote based on the given keywords. + 5) `!quote N` -> Retrieve quote #N, if not removed. - 4) `!quote` (no args) + 6) `!quote` (no args) -> Retrieve a random (not-removed) quote. + 7) `!quote last/latest/newest` + -> Retrieve the latest (most recent) non-removed quote. """ + utility.wfstl() + if callable(db_conn): + db_conn = db_conn() # If no subcommand, treat as "random" if len(args) == 0: - return await retrieve_random_quote(db_conn, is_discord, ctx) + try: + utility.wfetl() + return await retrieve_random_quote(db_conn) + except Exception as e: + globals.log(f"handle_quote_command() failed to retrieve a random quote: {e}", "ERROR", exec_info=True) sub = args[0].lower() @@ -271,26 +316,83 @@ async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_ # everything after "add" is the quote text quote_text = " ".join(args[1:]).strip() if not quote_text: + utility.wfetl() return "Please provide the quote text after 'add'." - return await add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_for_channel) + try: + utility.wfetl() + return await add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_for_channel) + except Exception as e: + globals.log(f"handle_quote_command() failed to add a new quote: {e}", "ERROR", exec_info=True) elif sub == "remove": if len(args) < 2: + utility.wfetl() return "Please specify which quote ID to remove." - return await remove_quote(db_conn, is_discord, ctx, quote_id_str=args[1]) + try: + utility.wfetl() + return await remove_quote(db_conn, is_discord, ctx, quote_id_str=args[1]) + except Exception as e: + globals.log(f"handle_quote_command() failed to remove a quote: {e}", "ERROR", exec_info=True) + elif sub == "restore": + if len(args) < 2: + utility.wfetl() + return "Please specify which quote ID to restore." + try: + utility.wfetl() + return await restore_quote(db_conn, is_discord, ctx, quote_id_str=args[1]) + except Exception as e: + globals.log(f"handle_quote_command() failed to restore a quote: {e}", "ERROR", exec_info=True) + elif sub == "info": + if len(args) < 2: + utility.wfetl() + return "Please specify which quote ID to get info for." + if not args[1].isdigit(): + utility.wfetl() + return f"'{args[1]}' is not a valid quote ID." + quote_id = int(args[1]) + try: + utility.wfetl() + return await retrieve_quote_info(db_conn, ctx, quote_id, is_discord) + except Exception as e: + globals.log(f"handle_quote_command() failed to retrieve quote info: {e}", "ERROR", exec_info=True) + elif sub == "search": + if len(args) < 2: + utility.wfetl() + return "Please provide keywords to search for." + keywords = args[1:] + try: + utility.wfetl() + return await search_quote(db_conn, keywords, is_discord, ctx) + except Exception as e: + globals.log(f"handle_quote_command() failed to process quote search: {e}", "ERROR", exec_info=True) + elif sub in ["last", "latest", "newest"]: + try: + utility.wfetl() + return await retrieve_latest_quote(db_conn) + except Exception as e: + globals.log(f"handle_quote_command() failed to retrieve latest quote: {e}", "ERROR", exec_info=True) else: # Possibly a quote ID if sub.isdigit(): quote_id = int(sub) - return await retrieve_specific_quote(db_conn, ctx, quote_id, is_discord) + try: + utility.wfetl() + return await retrieve_specific_quote(db_conn, ctx, quote_id, is_discord) + except Exception as e: + globals.log(f"handle_quote_command() failed to retrieve a specific quote: {e}", "ERROR", exec_info=True) else: # unrecognized subcommand => fallback to random - return await retrieve_random_quote(db_conn, is_discord, ctx) + try: + utility.wfetl() + return await retrieve_random_quote(db_conn) + except Exception as e: + globals.log(f"handle_quote_command() failed to retrieve a random quote: {e}", "ERROR", exec_info=True) async def add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_for_channel): """ Inserts a new quote with UUID instead of username. """ + utility.wfstl() user_id = str(ctx.author.id) platform = "discord" if is_discord else "twitch" @@ -298,6 +400,7 @@ async def add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_fo user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id") if not user_data: globals.log(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not saved.", "ERROR") + utility.wfetl() return "Could not save quote. Your user data is missing from the system." user_uuid = user_data["UUID"] @@ -315,16 +418,22 @@ async def add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_fo result = db.run_db_operation(db_conn, "write", insert_sql, params) if result is not None: - return "Quote added successfully!" + quote_id = get_max_quote_id(db_conn) + globals.log(f"New quote added: {quote_text} ({quote_id})") + utility.wfetl() + return f"Successfully added quote #{quote_id}" else: + utility.wfetl() return "Failed to add quote." async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str): """ - Mark quote #ID as removed (QUOTE_REMOVED=1). + Mark quote #ID as removed (QUOTE_REMOVED=1) and record removal datetime. """ + utility.wfstl() if not quote_id_str.isdigit(): + utility.wfetl() return f"'{quote_id_str}' is not a valid quote ID." user_id = str(ctx.author.id) @@ -334,6 +443,7 @@ async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str): user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id") if not user_data: globals.log(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not removed.", "ERROR") + utility.wfetl() return "Could not remove quote. Your user data is missing from the system." user_uuid = user_data["UUID"] @@ -341,11 +451,12 @@ async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str): quote_id = int(quote_id_str) remover_user = str(user_uuid) - # Mark as removed + # Mark as removed and record removal datetime update_sql = """ UPDATE quotes SET QUOTE_REMOVED = 1, - QUOTE_REMOVED_BY = ? + QUOTE_REMOVED_BY = ?, + QUOTE_REMOVED_DATETIME = CURRENT_TIMESTAMP WHERE ID = ? AND QUOTE_REMOVED = 0 """ @@ -353,9 +464,38 @@ async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str): rowcount = db.run_db_operation(db_conn, "update", update_sql, params) if rowcount and rowcount > 0: + utility.wfetl() return f"Removed quote #{quote_id}." else: + utility.wfetl() return "Could not remove that quote (maybe it's already removed or doesn't exist)." + +async def restore_quote(db_conn, is_discord: bool, ctx, quote_id_str): + """ + Marks a previously removed quote as unremoved. + Updates the quote so that QUOTE_REMOVED is set to 0 and clears the + QUOTE_REMOVED_BY and QUOTE_REMOVED_DATETIME fields. + """ + if not quote_id_str.isdigit(): + return f"'{quote_id_str}' is not a valid quote ID." + + quote_id = int(quote_id_str) + # Attempt to restore the quote by clearing its removal flags + update_sql = """ + UPDATE quotes + SET QUOTE_REMOVED = 0, + QUOTE_REMOVED_BY = NULL, + QUOTE_REMOVED_DATETIME = NULL + WHERE ID = ? + AND QUOTE_REMOVED = 1 + """ + params = (quote_id,) + rowcount = db.run_db_operation(db_conn, "update", update_sql, params) + + if rowcount and rowcount > 0: + return f"Quote #{quote_id} has been restored." + else: + return "Could not restore that quote (perhaps it is not marked as removed or does not exist)." async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord): @@ -364,9 +504,11 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord): If not found, or removed, inform user of the valid ID range (1 - {max_id}) If no quotes exist at all, say "No quotes are created yet." """ + utility.wfstl() # First, see if we have any quotes at all max_id = get_max_quote_id(db_conn) if max_id < 1: + utility.wfetl() return "No quotes are created yet." # Query for that specific quote @@ -379,7 +521,8 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord): QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED, - QUOTE_REMOVED_BY + QUOTE_REMOVED_BY, + QUOTE_REMOVED_DATETIME FROM quotes WHERE ID = ? """ @@ -387,6 +530,7 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord): if not rows: # no match + utility.wfetl() return f"I couldn't find that quote (1-{max_id})." row = rows[0] @@ -398,33 +542,43 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord): quote_game = row[5] quote_removed = row[6] quote_removed_by = row[7] if row[7] else "Unknown" + quote_removed_datetime = row[8] if row[8] else "Unknown" platform = "discord" if is_discord else "twitch" - # Lookup UUID from users table + # Lookup UUID from users table for the quoter user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID") if not user_data: - globals.log(f"ERROR: Could not find platform name for remover UUID {quote_removed_by} on UUI. Default to 'Unknown'", "ERROR") - quote_removed_by = "Unknown" + globals.log(f"ERROR: Could not find platform name for quotee UUID {quotee}. Default to 'Unknown'", "ERROR") + quotee_display = "Unknown" else: - quote_removed_by = user_data[f"{platform}_user_display_name"] + quotee_display = user_data[f"{platform}_user_display_name"] if quote_removed == 1: - # It's removed - return f"Quote {quote_number}: [REMOVED by {quote_removed_by}]" + # Lookup UUID for removed_by if removed + removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID") + if not removed_user_data: + globals.log(f"ERROR: Could not find platform name for remover UUID {quote_removed_by}. Default to 'Unknown'", "ERROR") + quote_removed_by_display = "Unknown" + else: + quote_removed_by_display = removed_user_data[f"{platform}_user_display_name"] + utility.wfetl() + return f"Quote #{quote_number}: [REMOVED by {quote_removed_by_display} on {quote_removed_datetime}]" else: - # It's not removed - return f"Quote {quote_number}: {quote_text}" + utility.wfetl() + return f"Quote #{quote_number}: {quote_text}" -async def retrieve_random_quote(db_conn, is_discord, ctx): +async def retrieve_random_quote(db_conn): """ Grab a random quote (QUOTE_REMOVED=0). If no quotes exist or all removed, respond with "No quotes are created yet." """ + utility.wfstl() # First check if we have any quotes max_id = get_max_quote_id(db_conn) if max_id < 1: + utility.wfetl() return "No quotes are created yet." # We have quotes, try selecting a random one from the not-removed set @@ -448,36 +602,256 @@ async def retrieve_random_quote(db_conn, is_discord, ctx): rows = db.run_db_operation(db_conn, "read", random_sql) if not rows: + utility.wfetl() return "No quotes are created yet." quote_number, quote_text = rows[0] - await f"Quote {quote_number}: {quote_text}" + utility.wfetl() + return f"Quote #{quote_number}: {quote_text}" + + +async def retrieve_latest_quote(db_conn): + """ + Retrieve the latest (most recent) non-removed quote based on QUOTE_DATETIME. + """ + utility.wfstl() + max_id = get_max_quote_id(db_conn) + if max_id < 1: + utility.wfetl() + return "No quotes are created yet." + + latest_sql = """ + SELECT ID, QUOTE_TEXT + FROM quotes + WHERE QUOTE_REMOVED = 0 + ORDER BY QUOTE_DATETIME DESC + LIMIT 1 + """ + rows = db.run_db_operation(db_conn, "read", latest_sql) + if not rows: + utility.wfetl() + return "No quotes are created yet." + quote_number, quote_text = rows[0] + utility.wfetl() + return f"Quote #{quote_number}: {quote_text}" + + +async def retrieve_quote_info(db_conn, ctx, quote_id, is_discord): + """ + Retrieve the stored information about a specific quote (excluding the quote text itself). + If called from Discord, returns a discord.Embed object with nicely formatted information. + If not found, returns an appropriate error message. + """ + utility.wfstl() + # First, check if any quotes exist + max_id = get_max_quote_id(db_conn) + if max_id < 1: + utility.wfetl() + return "No quotes are created yet." + + # Query for the specific quote by ID + select_sql = """ + SELECT + ID, + QUOTE_TEXT, + QUOTEE, + QUOTE_CHANNEL, + QUOTE_DATETIME, + QUOTE_GAME, + QUOTE_REMOVED, + QUOTE_REMOVED_BY, + QUOTE_REMOVED_DATETIME + FROM quotes + WHERE ID = ? + """ + rows = db.run_db_operation(db_conn, "read", select_sql, (quote_id,)) + if not rows: + utility.wfetl() + return f"I couldn't find that quote (1-{max_id})." + + row = rows[0] + quote_number = row[0] + quote_text = row[1] + quotee = row[2] + quote_channel = row[3] + quote_datetime = row[4] + quote_game = row[5] + quote_removed = row[6] + quote_removed_by = row[7] if row[7] else None + quote_removed_datetime = row[8] if row[8] else None + + platform = "discord" if is_discord else "twitch" + + # Lookup display name for the quoter + user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID") + if not user_data: + globals.log(f"ERROR: Could not find display name for quotee UUID {quotee}.", "ERROR") + quotee_display = "Unknown" + else: + quotee_display = user_data.get(f"{platform}_user_display_name", "Unknown") + + info_lines = [] + info_lines.append(f"Quote #{quote_number} was quoted by {quotee_display} on {quote_datetime}.") + if quote_channel: + info_lines.append(f"Channel: {quote_channel}") + if quote_game: + info_lines.append(f"Game: {quote_game}") + + if quote_removed == 1: + # Lookup display name for the remover + if quote_removed_by: + removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID") + if not removed_user_data: + globals.log(f"ERROR: Could not find display name for remover UUID {quote_removed_by}.", "ERROR") + quote_removed_by_display = "Unknown" + else: + quote_removed_by_display = removed_user_data.get(f"{platform}_user_display_name", "Unknown") + else: + quote_removed_by_display = "Unknown" + removed_info = f"Removed by {quote_removed_by_display}" + if quote_removed_datetime: + removed_info += f" on {quote_removed_datetime}" + info_lines.append(removed_info) + + info_text = "\n ".join(info_lines) + + if is_discord: + # Create a Discord embed + try: + from discord import Embed, Color + except ImportError: + # If discord.py is not available, fallback to plain text + utility.wfetl() + return info_text + + if quote_removed == 1: + embed_color = Color.red() + embed_title = f"Quote #{quote_number} Info [REMOVED]" + else: + embed_color = Color.blue() + embed_title = f"Quote #{quote_number} Info" + + embed = Embed(title=embed_title, color=embed_color) + embed.add_field(name="Quote", value=quote_text, inline=False) + embed.add_field(name="Quoted by", value=quotee_display, inline=True) + embed.add_field(name="Quoted on", value=quote_datetime, inline=True) + if quote_channel: + embed.add_field(name="Channel", value=quote_channel, inline=True) + if quote_game: + embed.add_field(name="Game", value=quote_game, inline=True) + if quote_removed == 1: + embed.add_field(name="Removed Info", value=removed_info, inline=False) + utility.wfetl() + return embed + else: + utility.wfetl() + return info_text + + +async def search_quote(db_conn, keywords, is_discord, ctx): + """ + Searches for the best matching non-removed quote based on the given keywords. + The search compares keywords (case-insensitive) to words in the quote text, game name, + quotee's display name, and channel. For each keyword that matches any of these fields, + the quote receives +1 point, and for each keyword that does not match, it loses 1 point. + A whole word match counts more than a partial match. + The quote with the highest score is returned. In case of several equally good matches, + one is chosen at random. + """ + import re + utility.wfstl() + func_start = time.time() + sql = "SELECT ID, QUOTE_TEXT, QUOTE_GAME, QUOTEE, QUOTE_CHANNEL FROM quotes WHERE QUOTE_REMOVED = 0" + rows = db.run_db_operation(db_conn, "read", sql) + if not rows: + func_end = time.time() + func_elapsed = utility.time_since(func_start, func_end, "s") + dbg_lvl = "DEBUG" if func_elapsed[2] < 5 else "WARNING" + globals.log(f"search_quote took {func_elapsed[0]}, {func_elapsed[1]} to complete", dbg_lvl) + utility.wfetl() + return "No quotes are created yet." + best_score = None + best_quotes = [] + # Normalize keywords to lowercase for case-insensitive matching. + lower_keywords = [kw.lower() for kw in keywords] + for row in rows: + quote_id = row[0] + quote_text = row[1] or "" + quote_game = row[2] or "" + quotee = row[3] or "" + quote_channel = row[4] or "" + # Lookup display name for quotee using UUID. + user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID") + if user_data: + quotee_display = user_data.get(f"{'discord' if is_discord else 'twitch'}_user_display_name", "") + else: + quotee_display = "" + + # For each keyword, check each field. + # Award 2 points for a whole word match and 1 point for a partial match. + score_total = 0 + for kw in lower_keywords: + keyword_score = 0 + for field in (quote_text, quote_game, quotee_display, quote_channel): + field_str = field or "" + field_lower = field_str.lower() + # Check for whole word match using regex word boundaries. + if re.search(r'\b' + re.escape(kw) + r'\b', field_lower): + keyword_score = 2 + break # Whole word match found, no need to check further fields. + elif kw in field_lower: + keyword_score = max(keyword_score, 1) + score_total += keyword_score + # Apply penalty: subtract the number of keywords. + final_score = score_total - len(lower_keywords) + + if best_score is None or final_score > best_score: + best_score = final_score + best_quotes = [(quote_id, quote_text)] + elif final_score == best_score: + best_quotes.append((quote_id, quote_text)) + if not best_quotes: + func_end = time.time() + func_elapsed = utility.time_since(func_start, func_end) + dbg_lvl = "DEBUG" if func_elapsed[2] < 5 else "WARNING" + globals.log(f"search_quote took {func_elapsed[0]}, {func_elapsed[1]} to complete", dbg_lvl) + utility.wfetl() + return "No matching quotes found." + chosen = random.choice(best_quotes) + func_end = time.time() + func_elapsed = utility.time_since(func_start, func_end, "s") + dbg_lvl = "DEBUG" if func_elapsed[2] < 5 else "WARNING" + globals.log(f"search_quote took {func_elapsed[0]}, {func_elapsed[1]} to complete", dbg_lvl) + utility.wfetl() + return f"Quote {chosen[0]}: {chosen[1]}" def get_max_quote_id(db_conn): """ Return the highest ID in the quotes table, or 0 if empty. """ + utility.wfstl() sql = "SELECT MAX(ID) FROM quotes" rows = db.run_db_operation(db_conn, "read", sql) if rows and rows[0] and rows[0][0] is not None: + utility.wfetl() return rows[0][0] + utility.wfetl() return 0 -def is_sqlite(db_conn): - return 'sqlite3' in str(type(db_conn)).lower() - - def get_author_name(ctx, is_discord): """ Return the name/username of the command author. For Discord, it's ctx.author.display_name (or ctx.author.name). For Twitch (twitchio), it's ctx.author.name. """ + utility.wfstl() if is_discord: + utility.wfetl() return str(ctx.author.display_name) else: + utility.wfetl() return str(ctx.author.name) diff --git a/cmd_discord.py b/cmd_discord.py index 7067049..99105ce 100644 --- a/cmd_discord.py +++ b/cmd_discord.py @@ -9,7 +9,7 @@ from modules.permissions import has_permission from modules.utility import handle_help_command import globals -def setup(bot, db_conn=None): +def setup(bot): """ Attach commands to the Discord bot, store references to db/log. """ @@ -168,84 +168,53 @@ def setup(bot, db_conn=None): # get_twitch_game_for_channel=None # None for Discord # ) - @bot.hybrid_group(name="quote", description="Interact with the quotes system", with_app_command=True) - async def cmd_quote(ctx, *, id: Optional[int] = None): + @bot.command(name="quote") + async def cmd_quote(ctx, *, arg_str: str = ""): """ - Handles base quote commands. - - `!quote` -> Fetch a random quote - - `!quote ` -> Fetch a specific quote by ID - - `/quote` or `/quote ` -> Works for slash commands + Handles 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. """ - if not bot.db_conn: - return await ctx.send("Database is unavailable, sorry.") - - # If no query is provided, fetch a random quote. - if not id: - args = [] - else: - args = id.split() # Split query into arguments + if not globals.init_db_conn: + await ctx.send("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=bot.db_conn, + db_conn=globals.init_db_conn, is_discord=True, ctx=ctx, args=args, get_twitch_game_for_channel=None ) - globals.log(f"'quote' result: {result}") + 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) + else: + await ctx.send(result) - await ctx.send(result) - - @cmd_quote.command(name="add", description="Add a quote") - async def cmd_quote_add(ctx, *, text: str): - """ - Usage: - - `!quote add ` - - `/quote add text:` for slash commands. - """ - if not bot.db_conn: - return await ctx.send("Database is unavailable, sorry.") - - # Ensure text isn't empty - if not text.strip(): - return await ctx.send("You must provide a quote text.") - - args = ["add", text] # Properly format arguments - - result = await cc.handle_quote_command( - db_conn=bot.db_conn, - is_discord=True, - ctx=ctx, - args=args, - get_twitch_game_for_channel=None - ) - - await ctx.send(result) - - @cmd_quote.command(name="remove", description="Remove a quote by number") - async def cmd_quote_remove(ctx, id: int): - """ - Usage: - - `!quote remove ` - - `/quote remove id:` - """ - if not bot.db_conn: - return await ctx.send("Database is unavailable, sorry.") - - args = ["remove", str(id)] # Properly pass the ID as an argument - - result = await cc.handle_quote_command( - db_conn=bot.db_conn, - is_discord=True, - ctx=ctx, - args=args, - get_twitch_game_for_channel=None - ) - - await ctx.send(result) ###################### # The following log entry must be last in the file to verify commands loading as they should diff --git a/cmd_twitch.py b/cmd_twitch.py index 90ce735..bd41220 100644 --- a/cmd_twitch.py +++ b/cmd_twitch.py @@ -14,29 +14,29 @@ def setup(bot, db_conn=None): We also attach the db_conn and log so the commands can use them. """ @bot.command(name="greet") - async def cmd_greet(ctx): + async def cmd_greet(ctx: commands.Context): result = cc.greet(ctx.author.display_name, "Twitch") - await ctx.send(result) + await ctx.reply(result) @bot.command(name="ping") - async def cmd_ping(ctx): + async def cmd_ping(ctx: commands.Context): result = cc.ping() - await ctx.send(result) + await ctx.reply(result) @bot.command(name="howl") - async def cmd_howl(ctx): + async def cmd_howl(ctx: commands.Context): response = cc.handle_howl_command(ctx) - await ctx.send(response) + await ctx.reply(response) @bot.command(name="hi") - async def cmd_hi(ctx): + async def cmd_hi(ctx: commands.Context): user_id = str(ctx.author.id) # Twitch user ID user_roles = [role.lower() for role in ctx.author.badges.keys()] # "roles" from Twitch badges if not has_permission("hi", user_id, user_roles, "twitch"): return await ctx.send("You don't have permission to use this command.") - await ctx.send("Hello there!") + await ctx.reply("Hello there!") # @bot.command(name="acc_link") # @monitor_cmds(bot.log) @@ -73,26 +73,94 @@ def setup(bot, db_conn=None): # await ctx.send(f"✅ Successfully linked Discord user **{discord_user_id}** with Twitch account **{twitch_username}**.") + # @bot.command(name="quote") + # async def cmd_quote(ctx: commands.Context): + # """ + # Handles 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 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. + # """ + # if not bot.db_conn: + # return await ctx.send("Database is unavailable, sorry.") + + # parts = ctx.message.content.strip().split() + # args = parts[1:] if len(parts) > 1 else [] + + # def get_twitch_game_for_channel(chan_name): + # # Placeholder for your actual logic to fetch the current game + # return "SomeGame" + + # result = await cc.handle_quote_command( + # db_conn=bot.db_conn, + # is_discord=False, + # ctx=ctx, + # args=args, + # get_twitch_game_for_channel=get_twitch_game_for_channel + # ) + # await ctx.send(result) + @bot.command(name="quote") async def cmd_quote(ctx: commands.Context): - if not bot.db_conn: - return await ctx.send("Database is unavailable, sorry.") + """ + Handles 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. + """ + if not globals.init_db_conn: + await ctx.reply("Database is unavailable, sorry.") + return - parts = ctx.message.content.strip().split() - args = parts[1:] if len(parts) > 1 else [] + # Parse the arguments from the message text + args = ctx.message.content.strip().split() + args = args[1:] if args else [] + globals.log(f"'quote' command initiated with arguments: {args}", "DEBUG") + globals.log(f"'quote' command message content: {ctx.message.content}", "DEBUG") def get_twitch_game_for_channel(chan_name): # Placeholder for your actual logic to fetch the current game return "SomeGame" result = await cc.handle_quote_command( - db_conn=bot.db_conn, + db_conn=globals.init_db_conn, is_discord=False, ctx=ctx, args=args, get_twitch_game_for_channel=get_twitch_game_for_channel ) - await ctx.send(result) + + globals.log(f"'quote' result: {result}", "DEBUG") + + await ctx.reply(result) @bot.command(name="help") async def cmd_help(ctx): diff --git a/dictionary/help_discord.json b/dictionary/help_discord.json index 02f5f15..e311d8f 100644 --- a/dictionary/help_discord.json +++ b/dictionary/help_discord.json @@ -9,8 +9,22 @@ ] }, "quote": { - "description": "Manage quotes (add, remove, fetch).", + "description": "Manage quotes (add, remove, restore, fetch, info).", "subcommands": { + "no subcommand": { + "desc": "Fetch a random quote." + }, + "[quote_number]": { + "args": "[quote_number]", + "desc": "Fetch a specific quote by ID." + }, + "search": { + "args": "[keywords]", + "desc": "Search for a quote with keywords." + }, + "last/latest/newest": { + "desc": "Returns the newest quote." + }, "add": { "args": "[quote_text]", "desc": "Adds a new quote." @@ -19,18 +33,23 @@ "args": "[quote_number]", "desc": "Removes the specified quote by ID." }, - "[quote_number]": { - "desc": "Fetch a specific quote by ID." + "restore": { + "args": "[quote_number]", + "desc": "Restores the specified quote by ID." }, - "no subcommand": { - "desc": "Fetch a random quote." + "info": { + "args": "[quote_number]", + "desc": "Retrieves info about the specified quote." } }, "examples": [ + "quote : Fetch a random quote", + "quote 3 : Fetch quote #3", + "quote search ookamikuntv : Search for quote containing 'ookamikuntv'", "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" + "quote remove 3 : Remove quote #3", + "quote restore 3 : Restores quote #3", + "quote info 3 : Gets info about quote #3" ] }, "ping": { diff --git a/globals.py b/globals.py index 83e0ff9..1f4348f 100644 --- a/globals.py +++ b/globals.py @@ -23,6 +23,9 @@ def load_config_file(): print(f"Error parsing config.json: {e}") sys.exit(1) +# Load configuration file +config_data = load_config_file() + ############################### # Simple Logging System ############################### @@ -42,9 +45,6 @@ def log(message, level="INFO", exec_info=False): See 'config.json' for disabling/enabling logging levels """ - # Load configuration file - config_data = load_config_file() - # Initiate logfile lfp = config_data["logging"]["logfile_path"] # Log File Path clfp = f"cur_{lfp}" # Current Log File Path @@ -99,9 +99,6 @@ def log(message, level="INFO", exec_info=False): sys.exit(1) def reset_curlogfile(): - # Load configuration file - config_data = load_config_file() - # Initiate logfile lfp = config_data["logging"]["logfile_path"] # Log File Path clfp = f"cur_{lfp}" # Current Log File Path @@ -111,4 +108,17 @@ def reset_curlogfile(): #log(f"Current-run logfile cleared", "DEBUG") except Exception as e: #log(f"Failed to clear current-run logfile: {e}") - pass \ No newline at end of file + pass + +def init_db_conn(): + 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. + 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 diff --git a/modules/db.py b/modules/db.py index fb41118..fe16186 100644 --- a/modules/db.py +++ b/modules/db.py @@ -141,6 +141,16 @@ def run_db_operation(conn, operation, query, params=None): conn.commit() if globals.log: globals.log(f"DB operation '{operation}' committed.", "DEBUG") + # If the query is an INSERT, return the last inserted row ID + if query.strip().lower().startswith("insert"): + try: + return cursor.lastrowid + except Exception as e: + if globals.log: + globals.log(f"Error retrieving lastrowid: {e}", "ERROR") + return cursor.rowcount + else: + return cursor.rowcount # If it's read/lookup, fetch results read_ops = ("read", "lookup", "select") @@ -210,6 +220,7 @@ def ensure_quotes_table(db_conn): QUOTE_GAME TEXT, QUOTE_REMOVED BOOLEAN DEFAULT 0, QUOTE_REMOVED_BY TEXT, + QUOTE_REMOVED_DATETIME TEXT DEFAULT NULL, FOREIGN KEY (QUOTEE) REFERENCES users(UUID), FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ) @@ -225,6 +236,7 @@ def ensure_quotes_table(db_conn): QUOTE_GAME VARCHAR(200), QUOTE_REMOVED BOOLEAN DEFAULT FALSE, QUOTE_REMOVED_BY VARCHAR(100), + QUOTE_REMOVED_DATETIME DATETIME DEFAULT NULL, FOREIGN KEY (QUOTEE) REFERENCES users(UUID) ON DELETE SET NULL FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ON DELETE SET NULL ) @@ -418,11 +430,11 @@ def lookup_user(db_conn, identifier: str, identifier_type: str, target_identifie user_data = { "UUID": row[0], "discord_user_id": row[1], - "discord_username": row[2], - "discord_user_display_name": row[3], + "discord_username": row[2] if row[2] else f"{row[5]} (Discord unlinked)", + "discord_user_display_name": row[3] if row[3] else f"{row[6]} (Discord unlinked)", "twitch_user_id": row[4], - "twitch_username": row[5], - "twitch_user_display_name": row[6], + "twitch_username": row[5] if row[5] else f"{row[2]} (Twitch unlinked)", + "twitch_user_display_name": row[6] if row[6] else f"{row[3]} (Twitch unlinked)", "datetime_linked": row[7], "user_is_banned": row[8], "user_is_bot": row[9], @@ -441,6 +453,17 @@ def lookup_user(db_conn, identifier: str, identifier_type: str, target_identifie # The key for "uuid" is stored as "UUID" in our dict. if target_identifier == "uuid": target_identifier = "UUID" + + # If usernames are Null, default to that of the opposite platform + # if not user_data['discord_username']: + # user_data['discord_username'] = f"{user_data['twitch_username']} (Discord unlinked)" + # elif not user_data['twitch_username']: + # user_data['twitch_username'] = f"{user_data['discord_username']} (Twitch unlinked)" + + # if not user_data['discord_user_display_name']: + # user_data['discord_user_display_name'] = f"{user_data['twitch_user_display_name']} (Discord unlinked)" + # elif not user_data['twitch_user_display_name']: + # user_data['twitch_user_display_name'] = f"{user_data['discord_user_display_name']} (Twitch unlinked)" if target_identifier in user_data: return user_data[target_identifier] diff --git a/modules/utility.py b/modules/utility.py index 8385d4a..169d07c 100644 --- a/modules/utility.py +++ b/modules/utility.py @@ -679,6 +679,83 @@ def generate_link_code(): import random, string return ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) +def time_since(start, end=None, format=None): + """ + Returns the epoch time since the start value. + + :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. + """ + # Ensure a start time is provided. + if start is None: + globals.log("time_since() lacks a start value!", "ERROR") + return None + + # If no end time is provided, use the current time. + if end is None: + end = time.time() + + # Default to seconds if format is not valid. + if format not in ["s", "m", "h", "d"]: + globals.log("time_since() has incorrect format string. Defaulting to 's'", "WARNING") + format = "s" + + # Compute the total elapsed time in seconds. + since = end - start + + # Break down the elapsed time according to the requested format. + if format == "s": + # Format as seconds: whole seconds and remainder in milliseconds. + x = int(since) + y = int((since - x) * 1000) + x = str(x) + "s" + y = str(y) + "ms" + elif format == "m": + # Format as minutes: whole minutes and remainder in seconds. + x = int(since // 60) + y = int(since % 60) + x = str(x) + "m" + y = str(y) + "s" + elif format == "h": + # Format as hours: whole hours and remainder in minutes. + x = int(since // 3600) + y = int((since % 3600) // 60) + x = str(x) + "h" + y = str(y) + "m" + elif format == "d": + # Format as days: whole days and remainder in hours. + x = int(since // 86400) + y = int((since % 86400) // 3600) + x = str(x) + "d" + y = str(y) + "h" + + 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") + +def wfetl(): + """ + Write Function End 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} finished processing") + ############################################### # Development Test Function (called upon start)