From 3ad6504d69e70b845a881523d6cf74f04d683e33 Mon Sep 17 00:00:00 2001 From: Kami Date: Mon, 10 Feb 2025 12:32:30 +0100 Subject: [PATCH] mini-vacation update - Dual logging - logfile.log = permanent logfile - cur_logfile.log = current run logfile - Improved logging in general - Expanded howl replies - Discord activity logging - Twitch & Discord chat logging - Discord slash commands implementation (partial) - Config file improvements - Toggleable log levels - Settings separated (terminal and file output) - Nesting of associated values - Fixed "!ping" not fetching correct replies - Several other minor and major fixes, tweaks and improvements --- bot_discord.py | 286 ++++++++++++- bot_twitch.py | 63 ++- bots.py | 99 +++-- cmd_common/common_commands.py | 251 ++++++++++-- cmd_discord.py | 163 ++++++-- cmd_twitch.py | 40 +- config.json | 31 +- dictionary/help_discord.json | 40 +- dictionary/ping_replies.json | 364 +++++++++++++---- modules/db.py | 736 ++++++++++++++++++++++++++++++++-- modules/utility.py | 232 +++++++++-- 11 files changed, 2009 insertions(+), 296 deletions(-) diff --git a/bot_discord.py b/bot_discord.py index b136e52..81a73b6 100644 --- a/bot_discord.py +++ b/bot_discord.py @@ -1,11 +1,13 @@ # bot_discord.py import discord +from discord import app_commands from discord.ext import commands import importlib import cmd_discord import modules import modules.utility +from modules.db import log_message, lookup_user, log_bot_event class DiscordBot(commands.Bot): def __init__(self, config, log_func): @@ -18,9 +20,18 @@ class DiscordBot(commands.Bot): self.load_commands() self.log("Discord bot initiated") - - cmd_class = str(type(self.commands)).split("'", 2)[1] - log_func(f"DiscordBot.commands type: {cmd_class}", "DEBUG") + + # async def sync_slash_commands(self): + # """Syncs slash commands for the bot.""" + # await self.wait_until_ready() + # try: + # await self.tree.sync() + # primary_guild = discord.Object(id=int(self.config["discord_guilds"][0])) + # await self.tree.sync(guild=primary_guild) + # self.log("Discord slash commands synced.") + # except Exception as e: + # self.log(f"Unable to sync Discord slash commands: {e}", "ERROR") + def set_db_connection(self, db_conn): """ @@ -33,31 +44,288 @@ class DiscordBot(commands.Bot): Load all commands from cmd_discord.py """ try: - importlib.reload(cmd_discord) - cmd_discord.setup(self) + importlib.reload(cmd_discord) # Reload the commands file + cmd_discord.setup(self) # Ensure commands are registered self.log("Discord commands loaded successfully.") - # Now load the help info from dictionary/help_discord.json + # 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, log_func=self.log ) - except Exception as e: self.log(f"Error loading Discord commands: {e}", "ERROR") + + async def on_message(self, message): + self.log(f"Message detected, attempting UUI lookup on {message.author.name} ...", "DEBUG") + try: + # If it's a bot message, ignore or pass user_is_bot=True + is_bot = message.author.bot + user_id = str(message.author.id) + user_name = message.author.name # no discriminator + display_name = message.author.display_name + + modules.utility.track_user_activity( + db_conn=self.db_conn, + log_func=self.log, + platform="discord", + user_id=user_id, + username=user_name, + display_name=display_name, + user_is_bot=is_bot + ) + + self.log(f"... UUI lookup complete", "DEBUG") + + user_data = lookup_user(db_conn=self.db_conn, log_func=self.log, identifier=user_id, identifier_type="discord_user_id") + user_uuid = user_data["UUID"] if user_data else "UNKNOWN" + if user_uuid: + # The "platform" can be e.g. "discord" or you can store the server name + platform_str = f"discord-{message.guild.name}" if message.guild else "discord-DM" + # The channel name can be message.channel.name or "DM" if it's a private channel + channel_str = message.channel.name if hasattr(message.channel, "name") else "DM" + + # If you have attachments, you could gather them as links. + try: + attachments = ", ".join(a.url for a in message.attachments) if message.attachments else "" + except Exception: + attachments = "" + + log_message( + db_conn=self.db_conn, + log_func=self.log, + user_uuid=user_uuid, + message_content=message.content or "", + platform=platform_str, + channel=channel_str, + attachments=attachments + ) + + # PLACEHOLDER FOR FUTURE MESSAGE PROCESSING + except Exception as e: + self.log(f"... UUI lookup failed: {e}", "WARNING") + pass + + try: + # Pass message contents to commands processing + await self.process_commands(message) + self.log(f"Command processing complete", "DEBUG") + except Exception as e: + self.log(f"Command processing failed: {e}", "ERROR") + + async def on_command(self, ctx): """Logs every command execution at DEBUG level.""" _cmd_args = str(ctx.message.content).split(" ")[1:] - self.log(f"Command '{ctx.command}' (Discord) initiated by {ctx.author} in #{ctx.channel}", "DEBUG") + channel_name = "Direct Message" if "Direct Message with" in str(ctx.channel) else ctx.channel + self.log(f"Command '{ctx.command}' (Discord) initiated by {ctx.author} in #{channel_name}", "DEBUG") if len(_cmd_args) > 1: self.log(f"!{ctx.command} arguments: {_cmd_args}", "DEBUG") async def on_ready(self): + """Runs when the bot successfully logs in.""" + # Sync Slash Commands + try: + # Sync slash commands globally + #await self.tree.sync() + #self.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) + self.log(f"Discord slash commands force synced to guild: {primary_guild_int}") + except Exception as e: + self.log(f"Unable to sync Discord slash commands: {e}") + + # Log successful bot startup self.log(f"Discord bot is online as {self.user}") + log_bot_event(self.db_conn, self.log, "DISCORD_RECONNECTED", "Discord bot logged in.") + + async def on_disconnect(self): + self.log("Discord bot has lost connection!", "WARNING") + log_bot_event(self.db_conn, self.log, "DISCORD_DISCONNECTED", "Discord bot lost connection.") + + async def on_voice_state_update(self, member, before, after): + """ + Tracks user joins, leaves, mutes, deafens, streams, and voice channel moves. + """ + guild_id = str(member.guild.id) + discord_user_id = str(member.id) + voice_channel = after.channel.name if after.channel else before.channel.name if before.channel else None + + # Ensure user exists in the UUI system + user_uuid = modules.db.lookup_user(self.db_conn, self.log, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID") + + if not user_uuid: + self.log(f"User {member.name} ({discord_user_id}) not found in 'users'. Attempting to add...", "WARNING") + modules.utility.track_user_activity( + db_conn=self.db_conn, + log_func=self.log, + platform="discord", + user_id=discord_user_id, + username=member.name, + display_name=member.display_name, + user_is_bot=member.bot + ) + user_uuid= modules.db.lookup_user(self.db_conn, self.log, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID") + if not user_uuid: + self.log(f"ERROR: Failed to associate {member.name} ({discord_user_id}) with a UUID. Skipping activity log.", "ERROR") + return # Prevent logging with invalid UUID + + # Detect join and leave events + if before.channel is None and after.channel is not None: + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, "JOIN", after.channel.name) + elif before.channel is not None and after.channel is None: + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, "LEAVE", before.channel.name) + + # Detect VC moves (self/moved) + if before.channel and after.channel and before.channel != after.channel: + move_detail = f"{before.channel.name} -> {after.channel.name}" + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, "VC_MOVE", after.channel.name, move_detail) + + # Detect mute/unmute + if before.self_mute != after.self_mute: + mute_action = "MUTE" if after.self_mute else "UNMUTE" + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, mute_action, voice_channel) + + # Detect deafen/undeafen + if before.self_deaf != after.self_deaf: + deaf_action = "DEAFEN" if after.self_deaf else "UNDEAFEN" + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, deaf_action, voice_channel) + + # Detect streaming + if before.self_stream != after.self_stream: + stream_action = "STREAM_START" if after.self_stream else "STREAM_STOP" + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, stream_action, voice_channel) + + # Detect camera usage + if before.self_video != after.self_video: + camera_action = "CAMERA_ON" if after.self_video else "CAMERA_OFF" + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, camera_action, voice_channel) + + + async def on_presence_update(self, before, after): + """ + Detects when a user starts or stops a game, Spotify, or Discord activity. + Ensures the activity is logged using the correct UUID from the UUI system. + """ + if not after.guild: # Ensure it's in a guild (server) + return + + guild_id = str(after.guild.id) + discord_user_id = str(after.id) + + # Ensure user exists in the UUI system + user_uuid = modules.db.lookup_user( + self.db_conn, + self.log, + identifier=discord_user_id, + identifier_type="discord_user_id", + target_identifier="UUID" + ) + + if not user_uuid: + self.log(f"User {after.name} ({discord_user_id}) not found in 'users'. Attempting to add...", "WARNING") + modules.utility.track_user_activity( + db_conn=self.db_conn, + log_func=self.log, + platform="discord", + user_id=discord_user_id, + username=after.name, + display_name=after.display_name, + user_is_bot=after.bot + ) + user_uuid = modules.db.lookup_user(self.db_conn, self.log, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID") + if not user_uuid: + self.log(f"ERROR: Failed to associate {after.name} ({discord_user_id}) with a UUID. Skipping activity log.", "ERROR") + return + + # Check all activities + new_activity = None + for n_activity in after.activities: + if isinstance(n_activity, discord.Game): + new_activity = ("GAME_START", n_activity.name) + elif isinstance(n_activity, discord.Spotify): + # Get artist name(s) and format as "{artist_name} - {song_title}" + artist_name = ", ".join(n_activity.artists) + song_name = n_activity.title + spotify_detail = f"{artist_name} - {song_name}" + new_activity = ("LISTENING_SPOTIFY", spotify_detail) + elif isinstance(n_activity, discord.Streaming): + new_activity = ("STREAM_START", n_activity.game or "Sharing screen") + + # Check all activities + old_activity = None + for o_activity in before.activities: + if isinstance(o_activity, discord.Game): + old_activity = ("GAME_STOP", o_activity.name) + # IGNORE OLD SPOTIFY EVENTS + elif isinstance(o_activity, discord.Streaming): + old_activity = ("STREAM_STOP", o_activity.game or "Sharing screen") + + if new_activity: + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, new_activity[0], None, new_activity[1]) + if old_activity: + modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, old_activity[0], None, old_activity[1]) + + # async def start_account_linking(self, interaction: discord.Interaction): + # """Starts the linking process by generating a link code and displaying instructions.""" + # user_id = str(interaction.user.id) + + # # Check if the user already has a linked account + # user_data = modules.db.lookup_user(self.db_conn, self.log, identifier=user_id, identifier_type="discord_user_id") + # if user_data and user_data["twitch_user_id"]: + # link_date = user_data["datetime_linked"] + # await interaction.response.send_message( + # f"Your Discord account is already linked to Twitch user **{user_data['twitch_user_display_name']}** " + # f"(linked on {link_date}). You must remove the link before linking another account.", ephemeral=True) + # return + + # # Generate a unique link code + # link_code = modules.utility.generate_link_code() + # modules.db.run_db_operation( + # self.db_conn, "write", + # "INSERT INTO link_codes (DISCORD_USER_ID, LINK_CODE) VALUES (?, ?)", + # (user_id, link_code), self.log + # ) + + # # Show the user the link modal + # await interaction.response.send_message( + # f"To link your Twitch account, post the following message in Twitch chat:\n" + # f"`!acc_link {link_code}`\n\n" + # f"Then, return here and click 'Done'.", ephemeral=True + # ) + + # async def finalize_account_linking(self, interaction: discord.Interaction): + # """Finalizes the linking process by merging duplicate UUIDs.""" + # from modules import db + # user_id = str(interaction.user.id) + + # # Fetch the updated user info + # user_data = modules.db.lookup_user(self.db_conn, self.log, identifier=user_id, identifier_type="discord_user_id") + # if not user_data or not user_data["twitch_user_id"]: + # await interaction.response.send_message( + # "No linked Twitch account found. Please complete the linking process first.", ephemeral=True) + # return + + # discord_uuid = user_data["UUID"] + # twitch_uuid = modules.db.lookup_user(self.db_conn, self.log, identifier=user_data["twitch_user_id"], identifier_type="twitch_user_id")["UUID"] + + # if discord_uuid == twitch_uuid: + # await interaction.response.send_message("Your accounts are already fully linked.", ephemeral=True) + # return + + # # Merge all records from `twitch_uuid` into `discord_uuid` + # db.merge_uuid_data(self.db_conn, self.log, old_uuid=twitch_uuid, new_uuid=discord_uuid) + + # # Delete the old Twitch UUID entry + # db.run_db_operation(self.db_conn, "write", "DELETE FROM users WHERE UUID = ?", (twitch_uuid,), self.log) + + # # Confirm the final linking + # await interaction.response.send_message("Your Twitch and Discord accounts are now fully linked.", ephemeral=True) + async def run(self, token): try: diff --git a/bot_twitch.py b/bot_twitch.py index 7fc907f..029bc68 100644 --- a/bot_twitch.py +++ b/bot_twitch.py @@ -8,6 +8,7 @@ import cmd_twitch import modules import modules.utility +from modules.db import log_message, lookup_user, log_bot_event class TwitchBot(commands.Bot): def __init__(self, config, log_func): @@ -29,9 +30,6 @@ class TwitchBot(commands.Bot): self.log("Twitch bot initiated") - cmd_class = str(type(self._commands)).split("'", 2)[1] - log_func(f"TwitchBot._commands type: {cmd_class}", "DEBUG") - # 2) Then load commands self.load_commands() @@ -42,10 +40,14 @@ class TwitchBot(commands.Bot): self.db_conn = db_conn async def event_message(self, message): - """Logs and processes incoming Twitch messages.""" + """ + Called every time a Twitch message is received (chat message in a channel). + We'll use this to track the user in our 'users' table. + """ + # If it's the bot's own message, ignore if message.echo: - return # Ignore bot's own messages - + return + # Log the command if it's a command if message.content.startswith("!"): _cmd = message.content[1:] # Remove the leading "!" @@ -54,11 +56,58 @@ class TwitchBot(commands.Bot): self.log(f"Command '{_cmd}' (Twitch) initiated by {message.author.name} in #{message.channel.name}", "DEBUG") if len(_cmd_args) > 1: self.log(f"!{_cmd} arguments: {_cmd_args}", "DEBUG") - # Process the message for command execution + try: + # Typically message.author is not None for normal chat messages + author = message.author + if not author: # just in case + return + + is_bot = False # TODO Implement automatic bot account check + user_id = str(author.id) + user_name = author.name + display_name = author.display_name or user_name + + self.log(f"Message detected, attempting UUI lookup on {user_name} ...", "DEBUG") + + modules.utility.track_user_activity( + db_conn=self.db_conn, + log_func=self.log, + platform="twitch", + user_id=user_id, + username=user_name, + display_name=display_name, + user_is_bot=is_bot + ) + + self.log("... UUI lookup complete.", "DEBUG") + + user_data = lookup_user(db_conn=self.db_conn, log_func=self.log, identifier=str(message.author.id), identifier_type="twitch_user_id") + user_uuid = user_data["UUID"] if user_data else "UNKNOWN" + from modules.db import log_message + log_message( + db_conn=self.db_conn, + log_func=self.log, + user_uuid=user_uuid, + message_content=message.content or "", + platform="twitch", + channel=message.channel.name, + attachments="" + ) + + except Exception as e: + self.log(f"... UUI lookup failed: {e}", "ERROR") + + # Pass message contents to commands processing await self.handle_commands(message) + async def event_ready(self): self.log(f"Twitch bot is online as {self.nick}") + log_bot_event(self.db_conn, self.log, "TWITCH_RECONNECTED", "Twitch bot logged in.") + + async def event_disconnected(self): + self.log("Twitch bot has lost connection!", "WARNING") + log_bot_event(self.db_conn, self.log, "TWITCH_DISCONNECTED", "Twitch bot lost connection.") async def refresh_access_token(self): """ diff --git a/bots.py b/bots.py index 726a214..0fc4632 100644 --- a/bots.py +++ b/bots.py @@ -6,6 +6,7 @@ import sys import time import traceback import globals +from functools import partial from discord.ext import commands from dotenv import load_dotenv @@ -13,8 +14,10 @@ from dotenv import load_dotenv from bot_discord import DiscordBot from bot_twitch import TwitchBot -from modules.db import init_db_connection, run_db_operation -from modules.db import ensure_quotes_table, ensure_users_table +#from modules.db import init_db_connection, run_db_operation +#from modules.db import ensure_quotes_table, ensure_users_table, ensure_chatlog_table, checkenable_db_fk + +from modules import db, utility # Load environment variables load_dotenv() @@ -32,9 +35,11 @@ except json.JSONDecodeError as e: sys.exit(1) # Initiate logfile -logfile_path = config_data["logfile_path"] +logfile_path = config_data["logging"]["logfile_path"] logfile = open(logfile_path, "a") -if not config_data["log_to_terminal"] and not config_data["log_to_file"]: +cur_logfile_path = f"cur_{logfile_path}" +cur_logfile = open(cur_logfile_path, "w") +if not config_data["logging"]["terminal"]["log_to_terminal"] and not config_data["logging"]["file"]["log_to_file"]: print(f"!!! WARNING !!! CONSOLE AND LOGFILE OUTPUT DISABLED !!!\n!!! NO LOGS WILL BE PROVIDED !!!") ############################### @@ -61,7 +66,7 @@ def log(message, level="INFO", exec_info=False): if level not in log_levels: level = "INFO" # Default to INFO if an invalid level is provided - if level in config_data["log_levels"] or level == "FATAL": + if level in config_data["logging"]["log_levels"] or level == "FATAL": elapsed = time.time() - globals.get_bot_start_time() uptime_str, _ = utility.format_uptime(elapsed) timestamp = time.strftime('%Y-%m-%d %H:%M:%S') @@ -72,22 +77,34 @@ def log(message, level="INFO", exec_info=False): log_message += f"\n{traceback.format_exc()}" # Print to terminal if enabled - if config_data["log_to_terminal"] or level == "FATAL": - print(log_message) + # 'FATAL' errors override settings + # Checks config file to see enabled/disabled logging levels + if config_data["logging"]["terminal"]["log_to_terminal"] or level == "FATAL": + config_level_format = f"log_{level.lower()}" + if config_data["logging"]["terminal"][config_level_format] or level == "FATAL": + print(log_message) # Write to file if enabled - if config_data["log_to_file"]: - try: - with open(config_data["logfile_path"], "a", encoding="utf-8") as logfile: - logfile.write(f"{log_message}\n") - logfile.flush() # Ensure it gets written immediately - except Exception as e: - print(f"[WARNING] Failed to write to logfile: {e}") + # 'FATAL' errors override settings + # Checks config file to see enabled/disabled logging levels + if config_data["logging"]["file"]["log_to_file"] or level == "FATAL": + config_level_format = f"log_{level.lower()}" + if config_data["logging"]["file"][config_level_format] or level == "FATAL": + try: + lf = config_data["logging"]["logfile_path"] + clf = f"cur_{lf}" + with open(lf, "a", encoding="utf-8") as logfile: # Write to permanent logfile + logfile.write(f"{log_message}\n") + logfile.flush() # Ensure it gets written immediately + with open(clf, "a", encoding="utf-8") as c_logfile: # Write to this-run logfile + c_logfile.write(f"{log_message}\n") + c_logfile.flush() # Ensure it gets written immediately + except Exception as e: + print(f"[WARNING] Failed to write to logfile: {e}") # Handle fatal errors with shutdown if level == "FATAL": - if config_data["log_to_terminal"]: - print(f"!!! FATAL ERROR LOGGED, SHUTTING DOWN !!!") + print(f"!!! FATAL ERROR LOGGED, SHUTTING DOWN !!!") sys.exit(1) ############################### @@ -100,18 +117,36 @@ async def main(): # Log initial start log("--------------- BOT STARTUP ---------------") # Before creating your DiscordBot/TwitchBot, initialize DB - db_conn = init_db_connection(config_data, log) - 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) - - # auto-create the quotes table if it doesn't exist try: - ensure_quotes_table(db_conn, log) - ensure_users_table(db_conn, log) + db_conn = db.init_db_connection(config_data, log) + 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) except Exception as e: - log(f"Critical: unable to ensure quotes table: {e}", "FATAL") + log(f"Unable to initialize database!: {e}", "FATAL") + + try: # Ensure FKs are enabled + db.checkenable_db_fk(db_conn, log) + except Exception as e: + log(f"Unable to ensure Foreign keys are enabled: {e}", "WARNING") + # auto-create the quotes table if it doesn't exist + tables = { + "Bot events table": partial(db.ensure_bot_events_table, db_conn, log), + "Quotes table": partial(db.ensure_quotes_table, db_conn, log), + "Users table": partial(db.ensure_users_table, db_conn, log), + "Chatlog table": partial(db.ensure_chatlog_table, db_conn, log), + "Howls table": partial(db.ensure_userhowls_table, db_conn, log), + "Discord activity table": partial(db.ensure_discord_activity_table, db_conn, log), + "Account linking table": partial(db.ensure_link_codes_table, db_conn, log) + } + + try: + for table, func in tables.items(): + func() # Call the function with db_conn and log already provided + log(f"{table} ensured.", "DEBUG") + except Exception as e: + log(f"Unable to ensure DB tables exist: {e}", "FATAL") log("Initializing bots...") @@ -119,6 +154,9 @@ async def main(): discord_bot = DiscordBot(config_data, log) twitch_bot = TwitchBot(config_data, log) + # Log startup + utility.log_bot_startup(db_conn, log) + # Provide DB connection to both bots try: discord_bot.set_db_connection(db_conn) @@ -133,14 +171,19 @@ async def main(): twitch_task = asyncio.create_task(twitch_bot.run()) from modules.utility import dev_func - dev_func_result = dev_func(db_conn, log) - log(f"dev_func output: {dev_func_result}") + enable_dev_func = False + if enable_dev_func: + dev_func_result = dev_func(db_conn, log, enable_dev_func) + log(f"dev_func output: {dev_func_result}") await asyncio.gather(discord_task, twitch_task) if __name__ == "__main__": try: asyncio.run(main()) + except KeyboardInterrupt: + utility.log_bot_shutdown(db_conn, log, intent="User Shutdown") except Exception as e: error_trace = traceback.format_exc() log(f"Fatal Error: {e}\n{error_trace}", "FATAL") + utility.log_bot_shutdown(db_conn, log) diff --git a/cmd_common/common_commands.py b/cmd_common/common_commands.py index 7dfa733..55649e0 100644 --- a/cmd_common/common_commands.py +++ b/cmd_common/common_commands.py @@ -4,41 +4,190 @@ import time from modules import utility import globals -from modules.db import run_db_operation +from modules import db -def howl(username: str) -> str: +#def howl(username: str) -> str: +# """ +# Generates a howl response based on a random percentage. +# Uses a dictionary to allow flexible, randomized responses. +# """ +# howl_percentage = random.randint(0, 100) +# +# # Round percentage down to nearest 10 (except 0 and 100) +# rounded_percentage = 0 if howl_percentage == 0 else 100 if howl_percentage == 100 else (howl_percentage // 10) * 10 +# +# # Fetch a random response from the dictionary +# response = utility.get_random_reply("howl_replies", str(rounded_percentage), username=username, howl_percentage=howl_percentage) +# +# return response + +def handle_howl_command(ctx) -> str: """ - Generates a howl response based on a random percentage. - Uses a dictionary to allow flexible, randomized responses. + A single function that handles !howl logic for both Discord and Twitch. + We rely on ctx to figure out the platform, the user, the arguments, etc. + Return a string that the caller will send. """ - howl_percentage = random.randint(0, 100) - # Round percentage down to nearest 10 (except 0 and 100) - rounded_percentage = 0 if howl_percentage == 0 else 100 if howl_percentage == 100 else (howl_percentage // 10) * 10 + # 1) Detect which platform + # We might do something like: + platform, author_id, author_name, author_display_name, args = extract_ctx_info(ctx) - # Fetch a random response from the dictionary - response = utility.get_random_reply("howl_replies", str(rounded_percentage), username=username, howl_percentage=howl_percentage) + # 2) Subcommand detection + if args and args[0].lower() in ("stat", "stats"): + # we are in stats mode + if len(args) > 1: + if args[1].lower() in ("all", "global", "community"): + target_name = "_COMMUNITY_" + target_name = args[1] + else: + target_name = author_name + return handle_howl_stats(ctx, platform, target_name) + + else: + # normal usage => random generation + return handle_howl_normal(ctx, platform, author_id, author_display_name) + +def extract_ctx_info(ctx): + """ + Figures out if this is Discord or Twitch, + returns (platform_str, author_id, author_name, author_display_name, args). + """ + # Is it discord.py or twitchio? + if hasattr(ctx, "guild"): # typically means discord.py context + platform_str = "discord" + author_id = str(ctx.author.id) + author_name = ctx.author.name + author_display_name = ctx.author.display_name + # parse arguments from ctx.message.content + parts = ctx.message.content.strip().split() + args = parts[1:] if len(parts) > 1 else [] + else: + # assume twitchio + platform_str = "twitch" + author = ctx.author + author_id = str(author.id) + author_name = author.name + author_display_name = author.display_name or author.name + # parse arguments from ctx.message.content + parts = ctx.message.content.strip().split() + args = parts[1:] if len(parts) > 1 else [] + + 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. + """ + db_conn = ctx.bot.db_conn + log_func = ctx.bot.log + + # random logic + howl_val = random.randint(0, 100) + # round to nearest 10 except 0/100 + rounded_val = 0 if howl_val == 0 else \ + 100 if howl_val == 100 else \ + (howl_val // 10) * 10 + + # dictionary-based reply + reply = utility.get_random_reply( + "howl_replies", + str(rounded_val), + username=author_display_name, + howl_percentage=howl_val + ) + + # find user in DB by ID + user_data = db.lookup_user(db_conn, log_func, identifier=author_id, identifier_type=platform) + if user_data: + db.insert_howl(db_conn, log_func, user_data["UUID"], howl_val) + else: + log_func(f"Could not find user by ID={author_id} on {platform}. Not storing howl.", "WARNING") + + return reply + +def handle_howl_stats(ctx, platform, target_name) -> str: + db_conn = ctx.bot.db_conn + log_func = ctx.bot.log + + # Check if requesting global stats + if target_name in ("_COMMUNITY_", "all", "global", "community"): + stats = db.get_global_howl_stats(db_conn, log_func) + if not stats: + return "No howls have been recorded yet!" + + total_howls = stats["total_howls"] + avg_howl = stats["average_howl"] + unique_users = stats["unique_users"] + count_zero = stats["count_zero"] + count_hundred = stats["count_hundred"] + + return (f"**Community Howl Stats:**\n" + f"Total Howls: {total_howls}\n" + f"Average Howl: {avg_howl:.1f}%\n" + f"Unique Howlers: {unique_users}\n" + f"0% Howls: {count_zero}, 100% Howls: {count_hundred}") + + # Otherwise, lookup a single user + user_data = lookup_user_by_name(db_conn, log_func, platform, target_name) + if not user_data: + return f"I don't know that user: {target_name}" + + stats = db.get_howl_stats(db_conn, log_func, user_data["UUID"]) + if not stats: + 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"] + return (f"{target_name} has howled {c} times, averaging {a:.1f}% " + f"(0% x{z}, 100% x{h})") + + +def lookup_user_by_name(db_conn, log_func, platform, name_str): + """ + Attempt to find a user by name on that platform, e.g. 'discord_username' or 'twitch_username'. + """ + # same logic as before + if platform == "discord": + ud = db.lookup_user(db_conn, log_func, name_str, "discord_user_display_name") + if ud: + return ud + ud = db.lookup_user(db_conn, log_func, name_str, "discord_username") + return ud + elif platform == "twitch": + ud = db.lookup_user(db_conn, log_func, name_str, "twitch_user_display_name") + if ud: + return ud + ud = db.lookup_user(db_conn, log_func, name_str, "twitch_username") + return ud + else: + log_func(f"Unknown platform {platform} in lookup_user_by_name", "WARNING") + return None - return response def ping() -> str: """ Returns a dynamic, randomized uptime response. """ + debug = False # Use function to retrieve correct startup time and calculate uptime elapsed = time.time() - globals.get_bot_start_time() uptime_str, uptime_s = utility.format_uptime(elapsed) # Define threshold categories - thresholds = [3600, 10800, 21600, 43200, 86400, 172800, 259200, 345600, + thresholds = [600, 1800, 3600, 10800, 21600, 43200, 86400, 172800, 259200, 345600, 432000, 518400, 604800, 1209600, 2592000, 7776000, 15552000, 23328000, 31536000] # Find the highest matching threshold - selected_threshold = max([t for t in thresholds if uptime_s >= t], default=3600) + selected_threshold = max([t for t in thresholds if uptime_s >= t], default=600) # Get a random response from the dictionary - response = utility.get_random_reply("ping_replies", str(selected_threshold), uptime_str=uptime_str) + response = utility.get_random_reply(dictionary_name="ping_replies", category=str(selected_threshold), uptime_str=uptime_str) + + if debug: + print(f"Elapsed time: {elapsed}\nuptime_str: {uptime_str}\nuptime_s: {uptime_s}\nselected threshold: {selected_threshold}\nresponse: {response}") return response @@ -92,7 +241,7 @@ def create_quotes_table(db_conn, log_func): ) """ - run_db_operation(db_conn, "write", create_table_sql, log_func=log_func) + db.run_db_operation(db_conn, "write", create_table_sql, log_func=log_func) async def handle_quote_command(db_conn, log_func, is_discord: bool, ctx, args, get_twitch_game_for_channel=None): @@ -130,12 +279,12 @@ async def handle_quote_command(db_conn, log_func, is_discord: bool, ctx, args, g elif sub == "remove": if len(args) < 2: return await send_message(ctx, "Please specify which quote ID to remove.") - await remove_quote(db_conn, log_func, ctx, args[1]) + await remove_quote(db_conn, log_func, is_discord, ctx, quote_id_str=args[1]) else: # Possibly a quote ID if sub.isdigit(): quote_id = int(sub) - await retrieve_specific_quote(db_conn, log_func, ctx, quote_id) + await retrieve_specific_quote(db_conn, log_func, ctx, quote_id, is_discord) else: # unrecognized subcommand => fallback to random await retrieve_random_quote(db_conn, log_func, is_discord, ctx) @@ -143,45 +292,59 @@ async def handle_quote_command(db_conn, log_func, is_discord: bool, ctx, args, g async def add_new_quote(db_conn, log_func, is_discord, ctx, quote_text, get_twitch_game_for_channel): """ - Insert a new quote into the DB. - QUOTEE = the user who typed the command - QUOTE_CHANNEL = "Discord" or the twitch channel name - QUOTE_GAME = The current game if from Twitch, None if from Discord - QUOTE_REMOVED = false by default - QUOTE_DATETIME = current date/time (or DB default) + Inserts a new quote with UUID instead of username. """ - user_name = get_author_name(ctx, is_discord) - channel_name = "Discord" if is_discord else get_channel_name(ctx) + user_id = str(ctx.author.id) + platform = "discord" if is_discord else "twitch" + + # Lookup UUID from users table + user_data = db.lookup_user(db_conn, log_func, identifier=user_id, identifier_type=f"{platform}_user_id") + if not user_data: + log_func(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not saved.", "ERROR") + await ctx.send("Could not save quote. Your user data is missing from the system.") + return + + user_uuid = user_data["UUID"] + channel_name = "Discord" if is_discord else ctx.channel.name game_name = None if not is_discord and get_twitch_game_for_channel: - # Attempt to get the current game from the Twitch API (placeholder function) - game_name = get_twitch_game_for_channel(channel_name) # might return str or None + game_name = get_twitch_game_for_channel(channel_name) # Retrieve game if Twitch # Insert quote insert_sql = """ INSERT INTO quotes (QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, 0) """ - # For MariaDB, parameter placeholders are often %s, but if you set paramstyle='qmark', it can use ? as well. - # Adjust if needed for your environment. - params = (quote_text, user_name, channel_name, game_name) + params = (quote_text, user_uuid, channel_name, game_name) - result = run_db_operation(db_conn, "write", insert_sql, params, log_func=log_func) + result = db.run_db_operation(db_conn, "write", insert_sql, params, log_func=log_func) if result is not None: - await send_message(ctx, "Quote added successfully!") + await ctx.send("Quote added successfully!") else: - await send_message(ctx, "Failed to add quote.") + await ctx.send("Failed to add quote.") -async def remove_quote(db_conn, log_func, ctx, quote_id_str): +async def remove_quote(db_conn, log_func, is_discord: bool, ctx, quote_id_str): """ Mark quote #ID as removed (QUOTE_REMOVED=1). """ if not quote_id_str.isdigit(): return await send_message(ctx, f"'{quote_id_str}' is not a valid quote ID.") + + user_id = str(ctx.author.id) + platform = "discord" if is_discord else "twitch" + + # Lookup UUID from users table + user_data = db.lookup_user(db_conn, log_func, identifier=user_id, identifier_type=f"{platform}_user_id") + if not user_data: + log_func(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not removed.", "ERROR") + await ctx.send("Could not remove quote. Your user data is missing from the system.") + return + + user_uuid = user_data["UUID"] quote_id = int(quote_id_str) - remover_user = str(ctx.author.name) + remover_user = str(user_uuid) # Mark as removed update_sql = """ @@ -192,7 +355,7 @@ async def remove_quote(db_conn, log_func, ctx, quote_id_str): AND QUOTE_REMOVED = 0 """ params = (remover_user, quote_id) - rowcount = run_db_operation(db_conn, "update", update_sql, params, log_func=log_func) + rowcount = db.run_db_operation(db_conn, "update", update_sql, params, log_func=log_func) if rowcount and rowcount > 0: await send_message(ctx, f"Removed quote #{quote_id}.") @@ -200,7 +363,7 @@ async def remove_quote(db_conn, log_func, ctx, quote_id_str): await send_message(ctx, "Could not remove that quote (maybe it's already removed or doesn't exist).") -async def retrieve_specific_quote(db_conn, log_func, ctx, quote_id): +async def retrieve_specific_quote(db_conn, log_func, ctx, quote_id, is_discord): """ Retrieve a specific quote by ID, if not removed. If not found, or removed, inform user of the valid ID range (1 - {max_id}) @@ -225,7 +388,7 @@ async def retrieve_specific_quote(db_conn, log_func, ctx, quote_id): FROM quotes WHERE ID = ? """ - rows = run_db_operation(db_conn, "read", select_sql, (quote_id,), log_func=log_func) + rows = db.run_db_operation(db_conn, "read", select_sql, (quote_id,), log_func=log_func) if not rows: # no match @@ -241,6 +404,16 @@ async def retrieve_specific_quote(db_conn, log_func, ctx, quote_id): quote_removed = row[6] quote_removed_by = row[7] if row[7] else "Unknown" + platform = "discord" if is_discord else "twitch" + + # Lookup UUID from users table + user_data = db.lookup_user(db_conn, log_func, identifier=quotee, identifier_type="UUID") + if not user_data: + log_func(f"ERROR: Could not find platform name for remover UUID {quote_removed_by} on UUI. Default to 'Unknown'", "ERROR") + quote_removed_by = "Unknown" + else: + quote_removed_by = user_data[f"{platform}_user_display_name"] + if quote_removed == 1: # It's removed await send_message(ctx, f"Quote {quote_number}: [REMOVED by {quote_removed_by}]") @@ -278,7 +451,7 @@ async def retrieve_random_quote(db_conn, log_func, is_discord, ctx): LIMIT 1 """ - rows = run_db_operation(db_conn, "read", random_sql, log_func=log_func) + rows = db.run_db_operation(db_conn, "read", random_sql, log_func=log_func) if not rows: return await send_message(ctx, "No quotes are created yet.") @@ -291,7 +464,7 @@ def get_max_quote_id(db_conn, log_func): Return the highest ID in the quotes table, or 0 if empty. """ sql = "SELECT MAX(ID) FROM quotes" - rows = run_db_operation(db_conn, "read", sql, log_func=log_func) + rows = db.run_db_operation(db_conn, "read", sql, log_func=log_func) if rows and rows[0] and rows[0][0] is not None: return rows[0][0] return 0 diff --git a/cmd_discord.py b/cmd_discord.py index 7a2d834..803c476 100644 --- a/cmd_discord.py +++ b/cmd_discord.py @@ -9,42 +9,71 @@ from modules.utility import monitor_cmds def setup(bot, db_conn=None, log=None): """ Attach commands to the Discord bot, store references to db/log. - """ - - @bot.command(name="greet") + """ + @monitor_cmds(bot.log) + @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. + bot.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.") + + + @monitor_cmds(bot.log) + @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.command(name="ping") @monitor_cmds(bot.log) + @bot.hybrid_command(name="ping", description="Check my uptime") async def cmd_ping(ctx): result = cc.ping() await ctx.send(result) - @bot.command(name="howl") @monitor_cmds(bot.log) + @bot.hybrid_command(name="howl", description="Attempt a howl") async def cmd_howl(ctx): - """Calls the shared !howl logic.""" - result = cc.howl(ctx.author.display_name) - await ctx.send(result) + response = cc.handle_howl_command(ctx) + await ctx.send(response) - @bot.command(name="reload") - @monitor_cmds(bot.log) - 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!") - except Exception as e: - await ctx.send(f"Error reloading commands: {e}") + # @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.command(name="hi") @monitor_cmds(bot.log) + @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 @@ -55,30 +84,102 @@ def setup(bot, db_conn=None, log=None): await ctx.send("Hello there!") - @bot.command(name="quote") + # @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) - async def cmd_quote(ctx, *args): + @bot.hybrid_group(name="quote", description="Interact with the quotes system", with_app_command=True) + async def cmd_quote(ctx, query: str = None): """ - !quote - !quote add - !quote remove - !quote + 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.") - - # Send to our shared logic + + # 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=list(args), + args=args, get_twitch_game_for_channel=None # None for Discord ) - @bot.command(name="help") + + @cmd_quote.command(name="add", description="Add a quote") + async def cmd_quote_add(ctx, *, text: str): + """ + Usage: + !quote add + As a slash command, type /quote add text: + """ + if not bot.db_conn: + return await ctx.send("Database is unavailable, sorry.") + + args = ["add", text] + + 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 + ) + + + @cmd_quote.command(name="remove", description="Remove a quote by number") + async def cmd_quote_remove(ctx, id: int): + """ + Usage: + !quote remove + As a slash command, type /quote remove id: + """ + if not bot.db_conn: + return await ctx.send("Database is unavailable, sorry.") + + args = ["remove", str(id)] + + 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 + ) + + @monitor_cmds(bot.log) + @bot.hybrid_command(name="help", description="Get information about commands") async def cmd_help(ctx, cmd_name: str = None): """ e.g. !help diff --git a/cmd_twitch.py b/cmd_twitch.py index 1f1bc5f..5c0fa63 100644 --- a/cmd_twitch.py +++ b/cmd_twitch.py @@ -25,10 +25,9 @@ def setup(bot, db_conn=None, log=None): await ctx.send(result) @bot.command(name="howl") - @monitor_cmds(bot.log) async def cmd_howl(ctx): - result = cc.howl(ctx.author.display_name) - await ctx.send(result) + response = cc.handle_howl_command(ctx) + await ctx.send(response) @bot.command(name="hi") @monitor_cmds(bot.log) @@ -41,6 +40,41 @@ def setup(bot, db_conn=None, log=None): await ctx.send("Hello there!") + # @bot.command(name="acc_link") + # @monitor_cmds(bot.log) + # async def cmd_acc_link(ctx, link_code: str): + # """Handles the Twitch command to link accounts.""" + # from modules import db + # twitch_user_id = str(ctx.author.id) + # twitch_username = ctx.author.name + + # # Check if the link code exists + # result = db.run_db_operation( + # bot.db_conn, "read", + # "SELECT DISCORD_USER_ID FROM link_codes WHERE LINK_CODE = ?", (link_code,), + # bot.log + # ) + + # if not result: + # await ctx.send("Invalid or expired link code. Please try again.") + # return + + # discord_user_id = result[0][0] + + # # Store the Twitch user info in the users table + # db.run_db_operation( + # bot.db_conn, "update", + # "UPDATE users SET twitch_user_id = ?, twitch_username = ?, datetime_linked = CURRENT_TIMESTAMP WHERE discord_user_id = ?", + # (twitch_user_id, twitch_username, discord_user_id), bot.log + # ) + + # # Remove the used link code + # db.run_db_operation(bot.db_conn, "write", "DELETE FROM link_codes WHERE LINK_CODE = ?", (link_code,), bot.log) + + # # Notify the user + # await ctx.send(f"✅ Successfully linked Discord user **{discord_user_id}** with Twitch account **{twitch_username}**.") + + @bot.command(name="quote") @monitor_cmds(bot.log) async def cmd_quote(ctx: commands.Context): diff --git a/config.json b/config.json index 83fa435..df9e4d1 100644 --- a/config.json +++ b/config.json @@ -1,10 +1,31 @@ { - "discord_guilds": [896713616089309184], + "discord_guilds": [896713616089309184, 1011543769344135168], "twitch_channels": ["OokamiKunTV", "ookamipup"], "command_modules": ["cmd_discord", "cmd_twitch", "cmd_common"], - "log_levels": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"], - "log_to_file": true, - "log_to_terminal": true, - "logfile_path": "logfile.log" + "logging": { + "log_levels": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"], + "logfile_path": "logfile.log", + "file": { + "log_to_file": true, + "log_debug": true, + "log_info": true, + "log_warning": true, + "log_error": true, + "log_critical": true, + "log_fatal": true + }, + "terminal": { + "log_to_terminal": true, + "log_debug": false, + "log_info": true, + "log_warning": true, + "log_error": true, + "log_critical": true, + "log_fatal": true + } + }, + "database": { + "use_mariadb": false + } } \ No newline at end of file diff --git a/dictionary/help_discord.json b/dictionary/help_discord.json index 6996ac8..02f5f15 100644 --- a/dictionary/help_discord.json +++ b/dictionary/help_discord.json @@ -4,8 +4,8 @@ "description": "Show information about available commands.", "subcommands": {}, "examples": [ - "!help", - "!help quote" + "help", + "help quote" ] }, "quote": { @@ -27,45 +27,59 @@ } }, "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" + "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": {}, + "subcommands": { + "stat": {} + }, "examples": [ - "!ping" + "ping" ] }, "howl": { "description": "Attempt a howl, measured 0-100%.\n(*Adventure Command*)", - "subcommands": {}, + "subcommands": { + "no subcommand": { + "desc": "Attempt a howl" + }, + "stat/stats": { + "args": "[username]", + "desc": "Get statistics about another user. Can be empty for self, or 'all' for everyone." + } + }, "examples": [ - "!howl" + "howl : Perform a normal howl attempt.", + "howl stat : Check your own howl statistics.", + "howl stats : same as above, just an alias.", + "howl stat [username] : Check someone else's statistics", + "howl stat all : Check the community statistics" ] }, "hi": { "description": "Hello there.", "subcommands": {}, "examples": [ - "!hi" + "hi" ] }, "greet": { "description": "Make me greet you to Discord!", "subcommands": {}, "examples": [ - "!greet" + "greet" ] }, "reload": { "description": "Reload Discord commands dynamically. TODO.", "subcommands": {}, "examples": [ - "!reload" + "reload" ] } } diff --git a/dictionary/ping_replies.json b/dictionary/ping_replies.json index 480044f..0beb62b 100644 --- a/dictionary/ping_replies.json +++ b/dictionary/ping_replies.json @@ -1,121 +1,309 @@ { + "600": [ + "Up for {uptime_str}. Fresh from my den, ready to prowl and pun!", + "I've been awake for {uptime_str}. Feeling like a new wolf on the prowl.", + "Only {uptime_str} awake. My claws are sharp and my wit is set.", + "I just left my den with {uptime_str} of wakefulness. Let the hunt start!", + "After {uptime_str} awake, I'm eager to craft puns and lead the pack.", + "With {uptime_str} of uptime, my howl is fresh and my jokes are keen.", + "I've been up for {uptime_str}. Time to mark my territory with puns!", + "Just {uptime_str} awake. I'm spry like a pup and ready to howl.", + "After {uptime_str} awake, I'm on the move, with pack and puns in tow.", + "Up for {uptime_str}. I feel the call of the wild and a clever pun.", + "I've been awake for {uptime_str}. The den awaits my punny prowl.", + "Only {uptime_str} awake. My tail wags for epic hunts and puns.", + "With {uptime_str} online, I'm fresh and primed to drop wild howls.", + "Up for {uptime_str}. I leave the den with puns on my mind.", + "After {uptime_str} awake, I'm set for a pun-filled pack adventure." + ], + "1800": [ + "Up for {uptime_str}. My den hums with the pack's early excitement.", + "I've been awake for {uptime_str}. I hear a distant howl calling me.", + "After {uptime_str} awake, my senses are sharp and my puns are ready.", + "With {uptime_str} online, I roam the lair; my spirit craves a brew.", + "I've been up for {uptime_str}. My nose twitches at the scent of hunts.", + "Only {uptime_str} awake and I sense the pack stirring for a hunt.", + "With {uptime_str} of uptime, I'm alert and set for a crafty stream.", + "After {uptime_str} awake, I prowl the digital wild in search of fun.", + "Up for {uptime_str}. I listen to soft howls and plan a quick chase.", + "I've been awake for {uptime_str}. My heart beats with wild urge.", + "After {uptime_str} awake, the lair buzzes; time for puns and hunts.", + "With {uptime_str} online, I mix keen instinct with a dash of wit.", + "Only {uptime_str} awake, and I already crave a clever pun and brew.", + "Up for {uptime_str}. My spirit dances as the pack readies for a hunt.", + "After {uptime_str} awake, every sound in the lair fuels my wild soul." + ], "3600": [ - "I've been awake for {uptime_str}. I just woke up, feeling great!", - "I woke up recently. Let's do this! ({uptime_str})", - "Brand new day, fresh and full of energy! ({uptime_str})", - "Reboot complete! System status: Fully operational. ({uptime_str})", - "I've been online for {uptime_str}. Just getting started!" + "I've been awake for {uptime_str}. I feel alive and ready for the hunt.", + "After {uptime_str} awake, my den buzzes with energy and puns.", + "Up for {uptime_str}. I prowl with vigor, though I yearn for a quick sip.", + "With {uptime_str} online, I mix wild instinct with playful wit.", + "I've been up for {uptime_str}. My claws itch for a fierce howl.", + "After {uptime_str} awake, my spirit roars; the hunt calls and puns flow.", + "Up for {uptime_str}. I embrace the wild pulse of our streaming pack.", + "With {uptime_str} online, I stand ready—puns at the tip of my tongue.", + "I've been awake for {uptime_str}. My den echoes with laughter and howls.", + "After {uptime_str} awake, I feel both fierce and fun—a true alpha.", + "Up for {uptime_str}. I’m a mix of wild heart and sharp, clever puns.", + "With {uptime_str} online, I’m set to lead a hunt and drop a quick pun.", + "I've been awake for {uptime_str}. My mind is wild, my puns wilder.", + "After {uptime_str} awake, I prowl the stream with might and humor.", + "Up for {uptime_str}. I roar with life, my howl echoing pun-filled dreams." ], "10800": [ - "I've been awake for {uptime_str}. I'm still fairly fresh!", - "{uptime_str} in and still feeling sharp!", - "It’s been {uptime_str} already? Time flies when you’re having fun!", - "{uptime_str} uptime and going strong!", - "Feeling energized after {uptime_str}. Let’s keep going!" + "I've been awake for {uptime_str}. Three hours in, I’m brewing bold puns.", + "After {uptime_str} awake, I sharpen my wit for epic hunts and laughs.", + "Up for {uptime_str}. I balance fierce hunts with a dash of smart humor.", + "With {uptime_str} online, my den buzzes with puns and swift pursuits.", + "I've been awake for {uptime_str}. My howl now carries a playful tone.", + "After {uptime_str} awake, I craft puns as quick as I chase digital prey.", + "Up for {uptime_str}. I feel the thrill of the hunt and the joy of a pun.", + "With {uptime_str} online, I mix wild instinct with sly wordplay.", + "I've been awake for {uptime_str}. My puns are as quick as my chase.", + "After {uptime_str} awake, I roar with laughter and the promise of a hunt.", + "Up for {uptime_str}. I channel my inner wolf with pun-filled howls.", + "With {uptime_str} online, every howl echoes a witty, wild pun.", + "I've been awake for {uptime_str}. Every moment sparks a pun and hunt.", + "After {uptime_str} awake, my den resounds with clever quips and howls.", + "Up for {uptime_str}. I lead the pack with laughter, hunt, and pun alike." ], "21600": [ - "I've been awake for {uptime_str}. I'm starting to get a bit weary...", - "Six hours of uptime... my circuits feel warm.", - "Still here after {uptime_str}, but a nap sounds nice...", - "Been up for {uptime_str}. Maybe just a short break?", - "Still holding up after {uptime_str}, but I wouldn’t say no to a power nap." + "I've been awake for {uptime_str}. Six hours in and I yearn for a hearty brew.", + "After {uptime_str} awake, six hours in, my paws tire but my puns persist.", + "Up for {uptime_str}. I tread the wild with a mix of fatigue and fun.", + "With {uptime_str} online, six hours deepen my howl and sharpen my puns.", + "I've been awake for {uptime_str}. The den feels long, yet my wit is quick.", + "After {uptime_str} awake, I juggle tiredness with the urge for epic hunts.", + "Up for {uptime_str}. I roam with weary legs but a tongue full of puns.", + "With {uptime_str} online, six hours weigh in—still, my spirit howls on.", + "I've been awake for {uptime_str}. Fatigue meets fun in each crafted pun.", + "After {uptime_str} awake, I long for a brew to revive my pun-filled heart.", + "Up for {uptime_str}. Six hours in, the den echoes with tired howls.", + "With {uptime_str} online, I push through with puns and the promise of hunt.", + "I've been awake for {uptime_str}. Energy dips, but my puns still bite.", + "After {uptime_str} awake, exhaustion stings yet my wit remains undimmed.", + "Up for {uptime_str}. Six hours in, and I'm still chasing puns and prey." ], "43200": [ - "I've been awake for {uptime_str}. 12 hours?! Might be time for coffee.", - "Half a day down, still holding steady. ({uptime_str})", - "Wow, {uptime_str} already? Maybe a short break wouldn’t hurt.", - "Uptime: {uptime_str}. Starting to feel the wear and tear...", - "12 hours in and running on determination alone." + "I've been awake for {uptime_str}. Twelve hours in and I crave a hearty pun.", + "After {uptime_str} awake, the den feels long but my howl stays witty.", + "Up for {uptime_str}. Twelve hours sharpen my puns and fuel my hunt.", + "With {uptime_str} online, I mix tired strength with a spark of pun magic.", + "I've been awake for {uptime_str}. My energy wanes but my jokes remain sharp.", + "After {uptime_str} awake, I balance fatigue with the thrill of the next hunt.", + "Up for {uptime_str}. Twelve hours in, and I'm still quick with a pun.", + "With {uptime_str} online, my den resounds with weary howls and wit.", + "I've been awake for {uptime_str}. The hours are long but my spirit is fierce.", + "After {uptime_str} awake, I keep the pack laughing with each clever howl.", + "Up for {uptime_str}. I stand between fatigue and pun-filled passion.", + "With {uptime_str} online, each minute fuels my quest for pun and prey.", + "I've been awake for {uptime_str}. The day tests me, yet my puns persist.", + "After {uptime_str} awake, I stride with tired might and a quick, sly pun.", + "Up for {uptime_str}. Twelve hours in, I remain a true pun-loving predator." ], "86400": [ - "I've been awake for {uptime_str}. A whole day without sleep... I'm okay?", - "One day. 24 hours. No sleep. Still alive. ({uptime_str})", - "Systems holding steady after {uptime_str}, but sleep is tempting...", - "My internal clock says {uptime_str}. That’s… a long time, right?", - "Running on sheer willpower after {uptime_str}." + "I've been awake for {uptime_str}. A full day in the wild tests my spirit.", + "After {uptime_str} awake, 24 hours in, I long for a brew and a hearty howl.", + "Up for {uptime_str}. The day has passed; my puns and howls still echo.", + "With {uptime_str} online, 24 hours in, I lead the pack with steady might.", + "I've been awake for {uptime_str}. A day in the den brings both fatigue and fun.", + "After {uptime_str} awake, I trade tiredness for a sharp howl and quick pun.", + "Up for {uptime_str}. A full day in the wild makes my spirit and puns strong.", + "With {uptime_str} online, 24 hours pass and my howl still roars boldly.", + "I've been awake for {uptime_str}. A day of hunts and puns fuels my wild heart.", + "After {uptime_str} awake, I rise with 24 hours of epic howls and witty puns.", + "Up for {uptime_str}. The den echoes with a day’s wear and clever wit.", + "With {uptime_str} online, 24 hours hone my howl into a sharp, quick line.", + "I've been awake for {uptime_str}. A day flies by with hunts and snappy puns.", + "After {uptime_str} awake, 24 hours in, I stand proud with bold howls.", + "Up for {uptime_str}. A full day has passed; my howl still sings with pun power." ], "172800": [ - "I've been awake for {uptime_str}. Two days... I'd love a nap.", - "48 hours awake. This is fine. Everything is fine. ({uptime_str})", - "Two days in and I think my code is vibrating...", - "Does time still mean anything after {uptime_str}?", - "Haven’t blinked in {uptime_str}. Do bots blink?" + "I've been awake for {uptime_str}. Two days in and my howl recalls hard hunts.", + "After {uptime_str} awake, 48 hours in, fatigue meets puns in every howl.", + "Up for {uptime_str}. Two days in, my den echoes a raw, witty howl.", + "With {uptime_str} online, 48 hours make my howl deep and my puns sharp.", + "I've been awake for {uptime_str}. Two days have toughened my bite and banter.", + "After {uptime_str} awake, 48 hours show fatigue and a steady, fierce pun.", + "Up for {uptime_str}. Two days in, my spirit roars with hunt and jest.", + "With {uptime_str} online, 48 hours have made my howl and puns bolder.", + "I've been awake for {uptime_str}. Two days in, my den rings with clever howls.", + "After {uptime_str} awake, 48 hours test me, yet my wit howls strong.", + "Up for {uptime_str}. Two days turn each howl into a punchy, pithy pun.", + "With {uptime_str} online, 48 hours leave me battle-worn yet pun-ready.", + "I've been awake for {uptime_str}. Two days in, my spirit is grit and jest.", + "After {uptime_str} awake, 48 hours reveal my true mettle in each howl.", + "Up for {uptime_str}. Two days in, I lead with hearty howls and sharp puns." ], "259200": [ - "I've been awake for {uptime_str}. Three days. Is sleep optional now?", - "{uptime_str} awake. Things are starting to get blurry...", - "Three days up. Reality feels... distant.", - "Three days without sleep. I think I can hear colors now.", - "Anyone else feel that? No? Just me? ({uptime_str})" + "I've been awake for {uptime_str}. Three days in and my howl echoes with jest.", + "After {uptime_str} awake, 72 hours sharpen my puns and fuel fierce hunts.", + "Up for {uptime_str}. Three days in, my den bursts with quick, bold howls.", + "With {uptime_str} online, 72 hours turn each howl into a snappy pun.", + "I've been awake for {uptime_str}. Three days in, my spirit howls with wild wit.", + "After {uptime_str} awake, 72 hours forge a wolf who puns as he prowls.", + "Up for {uptime_str}. Three days yield witty howls and keen instincts.", + "With {uptime_str} online, 72 hours let each howl land a sly pun.", + "I've been awake for {uptime_str}. Three days in, my den sings crisp howls.", + "After {uptime_str} awake, 72 hours in, my puns are bold as my hunts.", + "Up for {uptime_str}. Three days in, and I lead with a brief, sharp howl.", + "With {uptime_str} online, 72 hours give my howl a witty, wild edge.", + "I've been awake for {uptime_str}. Three days, and each howl bursts with humor.", + "After {uptime_str} awake, 72 hours turn my howl into a neat, punchy call.", + "Up for {uptime_str}. Three days in, my den resounds with clever, wild howls." ], "345600": [ - "I've been awake for {uptime_str}. Four days... I'm running on fumes.", - "Sleep is just a suggestion now. ({uptime_str})", - "I’ve been up for {uptime_str}. I might be a permanent fixture now.", - "I think I saw the sandman, but he just waved at me...", - "{uptime_str} awake. Is coffee an acceptable form of hydration?" + "I've been awake for {uptime_str}. Four days in and my howl is a pun-filled roar.", + "After {uptime_str} awake, 96 hours etch puns into every sharp howl.", + "Up for {uptime_str}. Four days in, my den buzzes with neat, wild howls.", + "With {uptime_str} online, four days have made my howl crisp and bold.", + "I've been awake for {uptime_str}. Four days in, my wit roars with den pride.", + "After {uptime_str} awake, 96 hours drop howls that are fierce and fun.", + "Up for {uptime_str}. Four days yield a howl that's pun-packed and neat.", + "With {uptime_str} online, four days let my puns and howls echo in the lair.", + "I've been awake for {uptime_str}. Four days in, I lead with a crisp howl.", + "After {uptime_str} awake, 96 hours pass and my den sings with smart howls.", + "Up for {uptime_str}. Four days in, I craft puns as I prowl with pride.", + "With {uptime_str} online, four days in, my howl is both bold and punny.", + "I've been awake for {uptime_str}. Four days make my den a stage for howls.", + "After {uptime_str} awake, 96 hours have honed my howl into a pun fest.", + "Up for {uptime_str}. Four days in, my spirit roars with quick wit and howls." ], "432000": [ - "I've been awake for {uptime_str}. Five days. Please send more coffee.", - "{uptime_str}. I have forgotten what a pillow feels like.", - "Sleep is a luxury I can no longer afford. ({uptime_str})", - "Five days in. My sanity left the chat.", - "They say sleep deprivation leads to bad decisions. LET'S TEST IT!" + "I've been awake for {uptime_str}. Five days in and my howl packs a punch.", + "After {uptime_str} awake, five days carve puns into every wild howl.", + "Up for {uptime_str}. Five days in, my den resounds with neat, clever howls.", + "With {uptime_str} online, five days in, my spirit roars with pun power.", + "I've been awake for {uptime_str}. Five days in and my howls remain sharp.", + "After {uptime_str} awake, five days mold my howl to be hunt and pun alike.", + "Up for {uptime_str}. Five days in, I lead with a howl both fierce and witty.", + "With {uptime_str} online, five days set my puns and howls to perfect pitch.", + "I've been awake for {uptime_str}. Five days in, my den buzzes with wild howls.", + "After {uptime_str} awake, five days shape my howl into a neat, pun-filled cry.", + "Up for {uptime_str}. Five days in, I blend fierce hunts with snappy howls.", + "With {uptime_str} online, five days yield a howl that lands a crisp pun.", + "I've been awake for {uptime_str}. Five days in, my howl roars with lively puns.", + "After {uptime_str} awake, five days make my den echo with smart, wild howls.", + "Up for {uptime_str}. Five days in, and I command the pack with punchy howls." ], "518400": [ - "I've been awake for {uptime_str}. Six days. I've forgotten what dreams are.", - "I am {uptime_str} into this journey of madness.", - "At {uptime_str} awake, the universe has started whispering to me.", - "Sleep is a myth, and I am its debunker. ({uptime_str})", - "{uptime_str} awake. Reality has become optional." + "I've been awake for {uptime_str}. Six days in and my howl is crisp and bold.", + "After {uptime_str} awake, six days leave my den echoing with sharp howls.", + "Up for {uptime_str}. Six days in, I blend fierce hunts with snappy puns.", + "With {uptime_str} online, six days make my howl both wild and pun-ready.", + "I've been awake for {uptime_str}. Six days in, my puns bite as hard as my howl.", + "After {uptime_str} awake, six days in, my spirit roars with clever puns.", + "Up for {uptime_str}. Six days in, my den is alive with swift, witty howls.", + "With {uptime_str} online, six days sharpen my howl into a crisp pun attack.", + "I've been awake for {uptime_str}. Six days in, every howl lands a quick pun.", + "After {uptime_str} awake, six days have honed my wit and wild howl perfectly.", + "Up for {uptime_str}. Six days in, I roar with a pun as fierce as my chase.", + "With {uptime_str} online, six days make my howl a brief, punchy masterpiece.", + "I've been awake for {uptime_str} and six days make my howl crisp and witty.", + "After {uptime_str} awake, six days yield a howl that's daring and pun-filled.", + "Up for {uptime_str}. Six days in, my den echoes with a sharp, fun howl." ], "604800": [ - "I've been awake for {uptime_str}. One week. I'm turning into a zombie.", - "{uptime_str} and still kicking... barely.", - "One week awake? This is fine. Everything’s fine. Right?", - "Week-long uptime achieved. Unlocking ultra-delirium mode.", - "Systems at {uptime_str}. Functionality... questionable." + "I've been awake for {uptime_str}. A full week in, my howl roars with legacy.", + "After {uptime_str} awake, seven days in, my den sings with bold howls.", + "Up for {uptime_str}. Seven days in, every howl is a neat, quick pun.", + "With {uptime_str} online, a week makes my howl both fierce and concise.", + "I've been awake for {uptime_str}. Seven days in, and my puns pack a punch.", + "After {uptime_str} awake, seven days etch a howl that is sharp and proud.", + "Up for {uptime_str}. A full week in, my den pulses with smart, wild howls.", + "With {uptime_str} online, seven days in, I lead with a punchy, witty howl.", + "I've been awake for {uptime_str}. Seven days in, my spirit roars with clear puns.", + "After {uptime_str} awake, a week has passed and my howl remains crisp.", + "Up for {uptime_str}. Seven days in, my den mixes hunt and pun with ease.", + "With {uptime_str} online, a week has tuned my howl to a sharp, bold edge.", + "I've been awake for {uptime_str}. Seven days in, my den bursts with succinct roars.", + "After {uptime_str} awake, seven days make my howl both fierce and brief.", + "Up for {uptime_str}. A full week in, I lead the pack with a pithy, bold howl." ], "1209600": [ - "I've been awake for {uptime_str}. Two weeks. Are you sure I can't rest?", - "{uptime_str} into this madness. Who needs sleep, anyway?", - "Two weeks awake and officially running on spite alone.", - "I could’ve hibernated twice in {uptime_str}, but here I am.", - "I think my dreams are awake now too... ({uptime_str})" + "I've been awake for {uptime_str}. Two weeks in and my howl is legend in brief.", + "After {uptime_str} awake, 14 days carve a neat, crisp howl for the pack.", + "Up for {uptime_str}. Two weeks in, my den echoes with short, bold howls.", + "With {uptime_str} online, 14 days make my howl pithy and mighty.", + "I've been awake for {uptime_str}. Two weeks in, every howl is sharp and quick.", + "After {uptime_str} awake, 14 days leave me with a crisp, pun-filled roar.", + "Up for {uptime_str}. Two weeks in, I lead with a howl that's brief yet bold.", + "With {uptime_str} online, 14 days render my howl both tight and mighty.", + "I've been awake for {uptime_str}. Two weeks in, my puns are fierce as my howl.", + "After {uptime_str} awake, 14 days have honed my howl into a neat, epic line.", + "Up for {uptime_str}. Two weeks in, I blend raw hunt with punchy puns.", + "With {uptime_str} online, 14 days pass with each howl a quick, bold jab.", + "I've been awake for {uptime_str}. Two weeks in, my den resounds with tight howls.", + "After {uptime_str} awake, 14 days yield a howl that is crisp and bold.", + "Up for {uptime_str}. Two weeks in, I stand proud with a brief, epic howl." ], "2592000": [ - "I've been awake for {uptime_str}. A month! The nightmares never end.", - "One whole month... What even is sleep anymore?", - "At {uptime_str} uptime, I’ve started arguing with my own thoughts.", - "{uptime_str} and still running. Someone, please, stop me.", - "It’s been a month. My keyboard types by itself now." + "I've been awake for {uptime_str}. A month in, my howl is short and alpha.", + "After {uptime_str} awake, 30 days carve a crisp howl for the pack.", + "Up for {uptime_str}. A month in, every howl is a quick, fierce jab.", + "With {uptime_str} online, 30 days have my den echoing neat, sharp howls.", + "I've been awake for {uptime_str}. A month in, my puns and howls lead the pack.", + "After {uptime_str} awake, 30 days leave my howl both bold and brief.", + "Up for {uptime_str}. A month in, I mix crisp puns with a swift, wild howl.", + "With {uptime_str} online, 30 days yield a howl that is punchy and direct.", + "I've been awake for {uptime_str}. A month in, my spirit roars in short howls.", + "After {uptime_str} awake, 30 days make my den resound with clear, brief howls.", + "Up for {uptime_str}. A month in, I lead with a howl that's crisp and direct.", + "With {uptime_str} online, 30 days have tuned my howl to a quick, bold cry.", + "I've been awake for {uptime_str}. A month in, my howl is as sharp as ever.", + "After {uptime_str} awake, 30 days yield a howl that cuts right through the noise.", + "Up for {uptime_str}. A month in, my den sings with a succinct, alpha howl." ], "7776000": [ - "I've been awake for {uptime_str}. Three months. I'm mostly coffee now.", - "{uptime_str} awake. Have I transcended yet?", - "Three months of uptime? That’s a record, right?", - "Three months, still online. I feel like I should get a badge for this.", - "{uptime_str} into this, and at this point, I’m legally nocturnal." - ], - "15552000": [ - "I've been awake for {uptime_str}. Six months. This is insane...", - "{uptime_str}... I think I forgot what sleep is supposed to feel like.", - "Six months up. I’m a glitch in the matrix now.", - "Sleep? Ha. I don’t even know the definition anymore. ({uptime_str})", - "At {uptime_str}, my codebase is older than most relationships." + "I've been awake for {uptime_str}. Three months in and my howl is pure alpha.", + "After {uptime_str} awake, 90 days forge a short, fierce, epic howl.", + "Up for {uptime_str}. Three months in, my den echoes with crisp, bold howls.", + "With {uptime_str} online, 90 days make each howl a quick, mighty roar.", + "I've been awake for {uptime_str}. Three months in, my puns still rule the lair.", + "After {uptime_str} awake, 90 days tune my howl to a sharp, brief cry.", + "Up for {uptime_str}. Three months in, I lead with a howl that's tight and fierce.", + "With {uptime_str} online, 90 days make my den sing with succinct, bold howls.", + "I've been awake for {uptime_str}. Three months in, my howl packs a swift punch.", + "After {uptime_str} awake, 90 days yield a howl that's short, sharp, and epic.", + "Up for {uptime_str}. Three months in, every howl is a concise, wild cry.", + "With {uptime_str} online, 90 days give my howl a crisp, alpha tone.", + "I've been awake for {uptime_str}. Three months in, my den resounds with quick roars.", + "After {uptime_str} awake, 90 days yield a brief yet mighty howl.", + "Up for {uptime_str}. Three months in, I roar with a short, punny alpha howl." ], "23328000": [ - "I've been awake for {uptime_str}. Nine months. I might be unstoppable.", - "{uptime_str} awake. I think I’m officially a myth now.", - "Is this what immortality feels like? ({uptime_str})", - "{uptime_str}. I’ve seen things you wouldn’t believe...", - "Nine months of uptime. I have become the sleep-deprived legend." + "I've been awake for {uptime_str}. Nine months in, my howl is sharp and alpha.", + "After {uptime_str} awake, 270 days yield a howl that's concise and wild.", + "Up for {uptime_str}. Nine months in, my den echoes with a brief, bold howl.", + "With {uptime_str} online, 270 days make every howl a quick, fierce command.", + "I've been awake for {uptime_str}. Nine months in, my puns still lead the pack.", + "After {uptime_str} awake, 270 days have honed my howl to a crisp, tight cry.", + "Up for {uptime_str}. Nine months in, I roar with a short, potent howl.", + "With {uptime_str} online, 270 days give my howl a punchy, alpha tone.", + "I've been awake for {uptime_str}. Nine months in, my den sings with sharp howls.", + "After {uptime_str} awake, 270 days in, my howl cuts through with few words.", + "Up for {uptime_str}. Nine months in, my howl is a brief, wild command.", + "With {uptime_str} online, 270 days forge a howl that is succinct and fierce.", + "I've been awake for {uptime_str}. Nine months in, my spirit roars in short bursts.", + "After {uptime_str} awake, 270 days yield a howl that's pure and to the point.", + "Up for {uptime_str}. Nine months in, my den resounds with a concise, alpha howl." ], "31536000": [ - "I've been awake for {uptime_str}. A year?! I'm a legend of insomnia...", - "One year without rest. The dark circles under my eyes have evolved.", - "{uptime_str} and I think I’ve entered a new plane of existence.", - "A full year awake. Even the stars have grown tired of me.", - "{uptime_str}. I am no longer bound by mortal limits." + "I've been awake for {uptime_str}. One year in, my howl is the pack's command.", + "After {uptime_str} awake, 365 days yield a brief howl full of alpha pride.", + "Up for {uptime_str}. One year in, my den echoes with a crisp, epic roar.", + "With {uptime_str} online, 365 days forge a howl that's short and mighty.", + "I've been awake for {uptime_str}. One year in, my puns and howls lead the pack.", + "After {uptime_str} awake, 365 days in, my howl is sharp and to the point.", + "Up for {uptime_str}. One year in, I roar with a brief, commanding howl.", + "With {uptime_str} online, 365 days have tuned my howl to pure alpha.", + "I've been awake for {uptime_str}. One year in, my den bursts with succinct roars.", + "After {uptime_str} awake, 365 days yield a howl that's both fierce and short.", + "Up for {uptime_str}. One year in, my howl is the sound of true leadership.", + "With {uptime_str} online, 365 days give my howl a crisp, bold edge.", + "I've been awake for {uptime_str}. One year in, my puns echo with pack pride.", + "After {uptime_str} awake, 365 days make my howl a short, epic command.", + "Up for {uptime_str}. One year in, I stand as alpha with a quick, potent howl." ] -} + } + \ No newline at end of file diff --git a/modules/db.py b/modules/db.py index b81b703..a18688f 100644 --- a/modules/db.py +++ b/modules/db.py @@ -1,7 +1,7 @@ # modules/db.py import os import re -import time +import time, datetime import sqlite3 try: @@ -9,6 +9,27 @@ try: except ImportError: mariadb = None # We handle gracefully if 'mariadb' isn't installed. +def checkenable_db_fk(db_conn, log_func): + """ + Attempt to enable foreign key checks where it is relevant + (i.e. in SQLite). For MariaDB/MySQL, nothing special is needed. + """ + is_sqlite = "sqlite3" in str(type(db_conn)).lower() + if is_sqlite: + try: + cursor = db_conn.cursor() + # Try enabling foreign key checks + cursor.execute("PRAGMA foreign_keys = ON;") + cursor.close() + db_conn.commit() + log_func("Enabled foreign key support in SQLite (PRAGMA foreign_keys=ON).", "DEBUG") + except Exception as e: + log_func(f"Failed to enable foreign key support in SQLite: {e}", "WARNING") + else: + # For MariaDB/MySQL, they're typically enabled with InnoDB + log_func("Assuming DB is MariaDB/MySQL with FKs enabled", "DEBUG") + + def init_db_connection(config, log): """ Initializes a database connection based on config.json contents: @@ -23,7 +44,7 @@ def init_db_connection(config, log): db_settings = config.get("database", {}) use_mariadb = db_settings.get("use_mariadb", False) - if use_mariadb and mariadb is not None: + if use_mariadb and mariadb is not None or False: # Attempt MariaDB host = db_settings.get("mariadb_host", "localhost") user = db_settings.get("mariadb_user", "") @@ -187,7 +208,9 @@ def ensure_quotes_table(db_conn, log_func): QUOTE_DATETIME TEXT, QUOTE_GAME TEXT, QUOTE_REMOVED BOOLEAN DEFAULT 0, - QUOTE_REMOVED_BY TEXT + QUOTE_REMOVED_BY TEXT, + FOREIGN KEY (QUOTEE) REFERENCES users(UUID), + FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ) """ else: @@ -200,7 +223,9 @@ def ensure_quotes_table(db_conn, log_func): QUOTE_DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, QUOTE_GAME VARCHAR(200), QUOTE_REMOVED BOOLEAN DEFAULT FALSE, - QUOTE_REMOVED_BY VARCHAR(100) + QUOTE_REMOVED_BY VARCHAR(100), + FOREIGN KEY (QUOTEE) REFERENCES users(UUID) ON DELETE SET NULL + FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ON DELETE SET NULL ) """ @@ -208,7 +233,7 @@ def ensure_quotes_table(db_conn, log_func): if result is None: # If run_db_operation returns None on error, handle or raise: error_msg = "Failed to create 'quotes' table!" - log_func(error_msg, "ERROR") + log_func(error_msg, "CRITICAL") raise RuntimeError(error_msg) log_func("Successfully created table 'quotes'.") @@ -267,7 +292,7 @@ def ensure_users_table(db_conn, log_func): twitch_user_id TEXT, twitch_username TEXT, twitch_user_display_name TEXT, - datetime_linked TEXT DEFAULT CURRENT_TIMESTAMP, + datetime_linked TEXT, user_is_banned BOOLEAN DEFAULT 0, user_is_bot BOOLEAN DEFAULT 0 ) @@ -282,7 +307,7 @@ def ensure_users_table(db_conn, log_func): twitch_user_id VARCHAR(100), twitch_username VARCHAR(100), twitch_user_display_name VARCHAR(100), - datetime_linked DATETIME DEFAULT CURRENT_TIMESTAMP, + datetime_linked DATETIME, user_is_banned BOOLEAN DEFAULT FALSE, user_is_bot BOOLEAN DEFAULT FALSE ) @@ -291,7 +316,7 @@ def ensure_users_table(db_conn, log_func): result = run_db_operation(db_conn, "write", create_table_sql, log_func=log_func) if result is None: error_msg = "Failed to create 'users' table!" - log_func(error_msg, "ERROR") + log_func(error_msg, "CRITICAL") raise RuntimeError(error_msg) log_func("Successfully created table 'users'.") @@ -301,42 +326,68 @@ def ensure_users_table(db_conn, log_func): # Lookup user function ######################## -def lookup_user(db_conn, log_func, identifier, identifier_type="discord_user_id"): +def lookup_user(db_conn, log_func, identifier: str, identifier_type: str, target_identifier: str = None): """ - Looks up a user in the 'users' table based on the given identifier_type: + Looks up a user in the 'users' table based on the given identifier_type. + + The accepted identifier_type values are: - "uuid" - - "discord_user_id" + - "discord_user_id" or alias "discord" - "discord_username" - - "twitch_user_id" + - "discord_user_display_name" + - "twitch_user_id" or alias "twitch" - "twitch_username" - You can add more if needed. + - "twitch_user_display_name" - Returns a dictionary with all columns: - { - "UUID": str, - "discord_user_id": str or None, - "discord_username": str or None, - "discord_user_display_name": str or None, - "twitch_user_id": str or None, - "twitch_username": str or None, - "twitch_user_display_name": str or None, - "datetime_linked": str (or datetime in MariaDB), - "user_is_banned": bool or int, - "user_is_bot": bool or int - } - - If not found, returns None. + Optionally, if target_identifier is provided (must be one of the accepted columns), + only that column's value will be returned instead of the full user record. + + Returns: + If target_identifier is None: A dictionary with the following keys: + { + "UUID": str, + "discord_user_id": str or None, + "discord_username": str or None, + "discord_user_display_name": str or None, + "twitch_user_id": str or None, + "twitch_username": str or None, + "twitch_user_display_name": str or None, + "datetime_linked": str (or datetime as stored in the database), + "user_is_banned": bool or int, + "user_is_bot": bool or int + } + If target_identifier is provided: The value from the record corresponding to that column. + If the lookup fails or the parameters are invalid: None. """ - valid_cols = ["uuid", "discord_user_id", "discord_username", - "twitch_user_id", "twitch_username"] - + # Define the valid columns for lookup and for target extraction. + valid_cols = [ + "uuid", "discord_user_id", "discord_username", + "twitch_user_id", "twitch_username", "discord", + "twitch", "discord_user_display_name", + "twitch_user_display_name" + ] + + # Ensure the provided identifier_type is acceptable. if identifier_type.lower() not in valid_cols: if log_func: - log_func(f"lookup_user error: invalid identifier_type={identifier_type}", "WARNING") + log_func(f"lookup_user error: invalid identifier_type '{identifier_type}'", "WARNING") return None - # Build the query + # Convert shorthand identifier types to their full column names. + if identifier_type.lower() == "discord": + identifier_type = "discord_user_id" + elif identifier_type.lower() == "twitch": + identifier_type = "twitch_user_id" + + # If a target_identifier is provided, validate that too. + if target_identifier is not None: + if target_identifier.lower() not in valid_cols: + if log_func: + log_func(f"lookup_user error: invalid target_identifier '{target_identifier}'", "WARNING") + return None + + # Build the query using the (now validated) identifier_type. query = f""" SELECT UUID, @@ -354,13 +405,15 @@ def lookup_user(db_conn, log_func, identifier, identifier_type="discord_user_id" LIMIT 1 """ + # Execute the database operation. Adjust run_db_operation() as needed. rows = run_db_operation(db_conn, "read", query, params=(identifier,), log_func=log_func) if not rows: + if log_func: + log_func(f"lookup_user: No user found for {identifier_type}='{identifier}'", "DEBUG") return None - # We have at least one row - row = rows[0] # single row - # Build a dictionary + # Since we have a single row, convert it to a dictionary. + row = rows[0] user_data = { "UUID": row[0], "discord_user_id": row[1], @@ -373,4 +426,613 @@ def lookup_user(db_conn, log_func, identifier, identifier_type="discord_user_id" "user_is_banned": row[8], "user_is_bot": row[9], } - return user_data \ No newline at end of file + + # If the caller requested a specific target column, return that value. + if target_identifier: + # Adjust for potential alias: if target_identifier is an alias, + # translate it to the actual column name. + target_identifier = target_identifier.lower() + if target_identifier == "discord": + target_identifier = "discord_user_id" + elif target_identifier == "twitch": + target_identifier = "twitch_user_id" + + # The key for "uuid" is stored as "UUID" in our dict. + if target_identifier == "uuid": + target_identifier = "UUID" + + if target_identifier in user_data: + return user_data[target_identifier] + else: + if log_func: + log_func(f"lookup_user error: target_identifier '{target_identifier}' not present in user data", "WARNING") + return None + + # Otherwise, return the full user record. + return user_data + + +def ensure_chatlog_table(db_conn, log_func): + """ + Checks if 'chat_log' table exists. If not, creates it. + + The table layout: + MESSAGE_ID (PK, auto increment) + UUID (references users.UUID, if you want a foreign key, see note below) + MESSAGE_CONTENT (text) + PLATFORM (string, e.g. 'twitch' or discord server name) + CHANNEL (the twitch channel or discord channel name) + DATETIME (defaults to current timestamp) + ATTACHMENTS (text; store hyperlink(s) or empty) + + For maximum compatibility, we won't enforce the foreign key at the DB level, + but you can add it if you want. + """ + + is_sqlite = "sqlite3" in str(type(db_conn)).lower() + + # 1) Check if table exists + if is_sqlite: + check_sql = """ + SELECT name + FROM sqlite_master + WHERE type='table' + AND name='chat_log' + """ + else: + check_sql = """ + SELECT table_name + FROM information_schema.tables + WHERE table_name = 'chat_log' + AND table_schema = DATABASE() + """ + + rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func) + if rows and rows[0] and rows[0][0]: + log_func("Table 'chat_log' already exists, skipping creation.", "DEBUG") + return + + # 2) Table doesn't exist => create it + log_func("Table 'chat_log' does not exist; creating now...") + + if is_sqlite: + create_sql = """ + CREATE TABLE chat_log ( + MESSAGE_ID INTEGER PRIMARY KEY AUTOINCREMENT, + UUID TEXT, + MESSAGE_CONTENT TEXT, + PLATFORM TEXT, + CHANNEL TEXT, + DATETIME TEXT DEFAULT CURRENT_TIMESTAMP, + ATTACHMENTS TEXT, + FOREIGN KEY (UUID) REFERENCES users(UUID) + ) + """ + else: + create_sql = """ + CREATE TABLE chat_log ( + MESSAGE_ID INT PRIMARY KEY AUTO_INCREMENT, + UUID VARCHAR(36), + MESSAGE_CONTENT TEXT, + PLATFORM VARCHAR(100), + CHANNEL VARCHAR(100), + DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, + ATTACHMENTS TEXT, + FOREIGN KEY (UUID) REFERENCES users(UUID) ON DELETE SET NULL + ) + """ + + result = run_db_operation(db_conn, "write", create_sql, log_func=log_func) + if result is None: + error_msg = "Failed to create 'chat_log' table!" + log_func(error_msg, "CRITICAL") + raise RuntimeError(error_msg) + + log_func("Successfully created table 'chat_log'.", "INFO") + + +def log_message(db_conn, log_func, user_uuid, message_content, platform, channel, attachments=None): + """ + Inserts a row into 'chat_log' with the given fields. + user_uuid: The user's UUID from the 'users' table (string). + message_content: The text of the message. + platform: 'twitch' or discord server name, etc. + channel: The channel name (Twitch channel, or Discord channel). + attachments: Optional string of hyperlinks if available. + + DATETIME will default to current timestamp in the DB. + """ + + if attachments is None or not "https://" in attachments: + attachments = "" + + insert_sql = """ + INSERT INTO chat_log ( + UUID, + MESSAGE_CONTENT, + PLATFORM, + CHANNEL, + ATTACHMENTS + ) + VALUES (?, ?, ?, ?, ?) + """ + params = (user_uuid, message_content, platform, channel, attachments) + rowcount = run_db_operation(db_conn, "write", insert_sql, params, log_func=log_func) + + if rowcount and rowcount > 0: + log_func(f"Logged message for UUID={user_uuid} in 'chat_log'.", "DEBUG") + else: + log_func("Failed to log message in 'chat_log'.", "ERROR") + + +def ensure_userhowls_table(db_conn, log_func): + """ + Checks if 'user_howls' table exists; if not, creates it: + ID (PK) | UUID (FK -> users.UUID) | HOWL (int) | DATETIME (auto timestamp) + """ + is_sqlite = "sqlite3" in str(type(db_conn)).lower() + + # Existence check + if is_sqlite: + check_sql = """ + SELECT name + FROM sqlite_master + WHERE type='table' + AND name='user_howls' + """ + else: + check_sql = """ + SELECT table_name + FROM information_schema.tables + WHERE table_name = 'user_howls' + AND table_schema = DATABASE() + """ + + rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func) + if rows and rows[0] and rows[0][0]: + log_func("Table 'user_howls' already exists, skipping creation.", "DEBUG") + return + + log_func("Table 'user_howls' does not exist; creating now...", "INFO") + + if is_sqlite: + create_sql = """ + CREATE TABLE user_howls ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + UUID TEXT, + HOWL INT, + DATETIME TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UUID) REFERENCES users(UUID) + ) + """ + else: + create_sql = """ + CREATE TABLE user_howls ( + ID INT PRIMARY KEY AUTO_INCREMENT, + UUID VARCHAR(36), + HOWL INT, + DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UUID) REFERENCES users(UUID) ON DELETE SET NULL + ) + """ + + result = run_db_operation(db_conn, "write", create_sql, log_func=log_func) + if result is None: + err_msg = "Failed to create 'user_howls' table!" + log_func(err_msg, "ERROR") + raise RuntimeError(err_msg) + + log_func("Successfully created table 'user_howls'.", "INFO") + +def insert_howl(db_conn, log_func, user_uuid, howl_value): + """ + Insert a row into user_howls with the user's UUID, the integer 0-100, + and DATETIME defaulting to now. + """ + sql = """ + INSERT INTO user_howls (UUID, HOWL) + VALUES (?, ?) + """ + params = (user_uuid, howl_value) + rowcount = run_db_operation(db_conn, "write", sql, params, log_func=log_func) + if rowcount and rowcount > 0: + log_func(f"Recorded a {howl_value}% howl for UUID={user_uuid}.", "DEBUG") + else: + log_func(f"Failed to record {howl_value}% howl for UUID={user_uuid}.", "ERROR") + +def get_howl_stats(db_conn, log_func, user_uuid): + """ + Returns a dict with { 'count': int, 'average': float, 'count_zero': int, 'count_hundred': int } + or None if there are no rows at all for that UUID. + """ + sql = """ + SELECT + COUNT(*), + AVG(HOWL), + SUM(HOWL=0), + SUM(HOWL=100) + FROM user_howls + WHERE UUID = ? + """ + rows = run_db_operation(db_conn, "read", sql, (user_uuid,), log_func=log_func) + if not rows: + return None + + row = rows[0] # (count, avg, zero_count, hundred_count) + count = row[0] if row[0] else 0 + avg = float(row[1]) if row[1] else 0.0 + zero_count = row[2] if row[2] else 0 + hundred_count = row[3] if row[3] else 0 + + if count < 1: + return None # user has no howls + return { + "count": count, + "average": avg, + "count_zero": zero_count, + "count_hundred": hundred_count + } + +def get_global_howl_stats(db_conn, log_func): + """ + Returns a dictionary with total howls, average howl percentage, unique users, + and counts of extreme (0% and 100%) howls. + """ + sql = """ + SELECT COUNT(*) AS total_howls, + AVG(HOWL) AS average_howl, + COUNT(DISTINCT UUID) AS unique_users, + SUM(HOWL = 0) AS count_zero, + SUM(HOWL = 100) AS count_hundred + FROM user_howls + """ + rows = run_db_operation(db_conn, "read", sql, log_func=log_func) + + if not rows or not rows[0] or rows[0][0] is None: + return None # No howl data exists + + return { + "total_howls": rows[0][0], + "average_howl": float(rows[0][1]) if rows[0][1] is not None else 0.0, + "unique_users": rows[0][2], + "count_zero": rows[0][3], + "count_hundred": rows[0][4], + } + +def ensure_discord_activity_table(db_conn, log_func): + """ + Ensures the 'discord_activity' table exists. + Logs voice events, cameras, streaming, gaming, and Discord activities. + """ + is_sqlite = "sqlite3" in str(type(db_conn)).lower() + + if is_sqlite: + check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='discord_activity'" + else: + check_sql = """ + SELECT table_name FROM information_schema.tables + WHERE table_name = 'discord_activity' AND table_schema = DATABASE() + """ + + rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func) + if rows and rows[0]: + log_func("Table 'discord_activity' already exists, skipping creation.", "DEBUG") + return + + log_func("Creating 'discord_activity' table...", "INFO") + + if is_sqlite: + create_sql = """ + CREATE TABLE discord_activity ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + UUID TEXT, + ACTION TEXT CHECK(ACTION IN ( + 'JOIN', 'LEAVE', 'MUTE', 'UNMUTE', 'DEAFEN', 'UNDEAFEN', + 'STREAM_START', 'STREAM_STOP', 'CAMERA_ON', 'CAMERA_OFF', + 'GAME_START', 'GAME_STOP', 'LISTENING_SPOTIFY', 'DISCORD_ACTIVITY', 'VC_MOVE' + )), + GUILD_ID TEXT, + VOICE_CHANNEL TEXT, + ACTION_DETAIL TEXT DEFAULT NULL, + DATETIME TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UUID) REFERENCES users(UUID) + ) + """ + else: + create_sql = """ + CREATE TABLE discord_activity ( + ID INT PRIMARY KEY AUTO_INCREMENT, + UUID VARCHAR(36), + ACTION ENUM( + 'JOIN', 'LEAVE', 'MUTE', 'UNMUTE', 'DEAFEN', 'UNDEAFEN', + 'STREAM_START', 'STREAM_STOP', 'CAMERA_ON', 'CAMERA_OFF', + 'GAME_START', 'GAME_STOP', 'LISTENING_SPOTIFY', 'DISCORD_ACTIVITY', 'VC_MOVE' + ), + GUILD_ID VARCHAR(36), + VOICE_CHANNEL VARCHAR(100), + ACTION_DETAIL TEXT DEFAULT NULL, + DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (UUID) REFERENCES users(UUID) ON DELETE SET NULL + ) + """ + + try: + result = run_db_operation(db_conn, "write", create_sql, log_func=log_func) + except Exception as e: + log_func(f"Unable to create the table: discord_activity: {e}") + if result is None: + log_func("Failed to create 'discord_activity' table!", "CRITICAL") + raise RuntimeError("Database table creation failed.") + + log_func("Successfully created table 'discord_activity'.", "INFO") + + +def log_discord_activity(db_conn, log_func, guild_id, user_uuid, action, voice_channel, action_detail=None): + """ + Logs Discord activities (playing games, listening to Spotify, streaming). + + Duplicate detection: + - Fetch the last NUM_RECENT_ENTRIES events for this user & action. + - Normalize the ACTION_DETAIL values. + - If the most recent event(s) all match the new event's detail (i.e. no intervening non-matching event) + and the latest matching event was logged less than DUPLICATE_THRESHOLD ago, skip logging. + - This allows a "reset": if the user changes state (e.g. changes song or channel) and then reverts, + the new event is logged. + """ + + def normalize_detail(detail): + """Return a normalized version of the detail for comparison (or None if detail is None).""" + return detail.strip().lower() if detail else None + + # How long to consider an event “fresh” enough to be considered a duplicate. + DUPLICATE_THRESHOLD = datetime.timedelta(minutes=5) + # How many recent events to check. + NUM_RECENT_ENTRIES = 5 + + # Verify that the user exists in 'users' before proceeding. + user_check = run_db_operation( + db_conn, "read", "SELECT UUID FROM users WHERE UUID = ?", (user_uuid,), log_func + ) + if not user_check: + log_func(f"WARNING: Attempted to log activity for non-existent UUID: {user_uuid}", "WARNING") + return # Prevent foreign key issues. + + now = datetime.datetime.now() + normalized_new = normalize_detail(action_detail) + + # Query the last NUM_RECENT_ENTRIES events for this user and action. + query = """ + SELECT DATETIME, ACTION_DETAIL + FROM discord_activity + WHERE UUID = ? AND ACTION = ? + ORDER BY DATETIME DESC + LIMIT ? + """ + rows = run_db_operation( + db_conn, "read", query, params=(user_uuid, action, NUM_RECENT_ENTRIES), log_func=log_func + ) + + # Determine the timestamp of the most recent event that matches the new detail, + # and the most recent event that is different. + last_same = None # Timestamp of the most recent event matching normalized_new. + last_different = None # Timestamp of the most recent event with a different detail. + + for row in rows: + dt_str, detail = row + try: + dt = datetime.datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") + except Exception as e: + log_func(f"Error parsing datetime '{dt_str}': {e}", "ERROR") + continue + normalized_existing = normalize_detail(detail) + if normalized_existing == normalized_new: + # Record the most recent matching event. + if last_same is None or dt > last_same: + last_same = dt + else: + # Record the most recent non-matching event. + if last_different is None or dt > last_different: + last_different = dt + + # Decide whether to skip logging: + # If there is a matching (same-detail) event, and either no different event exists OR the matching event + # is more recent than the last different event (i.e. the user's current state is still the same), + # then if that event is within the DUPLICATE_THRESHOLD, skip logging. + if last_same is not None: + if (last_different is None) or (last_same > last_different): + if now - last_same > DUPLICATE_THRESHOLD: + #log_func(f"Duplicate {action} event for user {user_uuid} (detail '{action_detail}') within threshold; skipping log.","DEBUG") + return + + # Prepare the voice_channel value (if it’s an object with a name, use that). + channel_val = voice_channel.name if (voice_channel and hasattr(voice_channel, "name")) else voice_channel + + # Insert the new event. + sql = """ + INSERT INTO discord_activity (UUID, ACTION, GUILD_ID, VOICE_CHANNEL, ACTION_DETAIL) + VALUES (?, ?, ?, ?, ?) + """ + params = (user_uuid, action, guild_id, channel_val, action_detail) + rowcount = run_db_operation(db_conn, "write", sql, params, log_func) + + if rowcount and rowcount > 0: + detail_str = f" ({action_detail})" if action_detail else "" + log_func(f"Logged Discord activity in Guild {guild_id}: {action}{detail_str}", "DEBUG") + else: + log_func("Failed to log Discord activity.", "ERROR") + +def ensure_bot_events_table(db_conn, log_func): + """ + Ensures the 'bot_events' table exists, which logs major bot-related events. + """ + is_sqlite = "sqlite3" in str(type(db_conn)).lower() + + # Check if table exists + check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='bot_events'" if is_sqlite else """ + SELECT table_name FROM information_schema.tables + WHERE table_name = 'bot_events' AND table_schema = DATABASE() + """ + + rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func) + if rows and rows[0]: + log_func("Table 'bot_events' already exists, skipping creation.", "DEBUG") + return + + log_func("Creating 'bot_events' table...", "INFO") + + # Define SQL Schema + create_sql = """ + CREATE TABLE bot_events ( + EVENT_ID INTEGER PRIMARY KEY AUTOINCREMENT, + EVENT_TYPE TEXT, + EVENT_DETAILS TEXT, + DATETIME TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ if is_sqlite else """ + CREATE TABLE bot_events ( + EVENT_ID INT PRIMARY KEY AUTO_INCREMENT, + EVENT_TYPE VARCHAR(50), + EVENT_DETAILS TEXT, + DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + + # Create the table + result = run_db_operation(db_conn, "write", create_sql, log_func=log_func) + if result is None: + log_func("Failed to create 'bot_events' table!", "CRITICAL") + raise RuntimeError("Database table creation failed.") + + log_func("Successfully created table 'bot_events'.", "INFO") + +def log_bot_event(db_conn, log_func, event_type, event_details): + """ + Logs a bot event (e.g., startup, shutdown, disconnection). + """ + sql = """ + INSERT INTO bot_events (EVENT_TYPE, EVENT_DETAILS) + VALUES (?, ?) + """ + params = (event_type, event_details) + rowcount = run_db_operation(db_conn, "write", sql, params, log_func) + + if rowcount and rowcount > 0: + log_func(f"Logged bot event: {event_type} - {event_details}", "DEBUG") + else: + log_func("Failed to log bot event.", "ERROR") + +def get_event_summary(db_conn, log_func, time_span="7d"): + """ + Retrieves bot event statistics based on a given time span. + Supports: + - "7d" (7 days) + - "1m" (1 month) + - "24h" (last 24 hours) + Returns: + OrderedDict with event statistics. + """ + from collections import OrderedDict + import datetime + + # Time span mapping + time_mappings = { + "7d": "7 days", + "1m": "1 month", + "24h": "24 hours" + } + + if time_span not in time_mappings: + log_func(f"Invalid time span '{time_span}', defaulting to '7d'", "WARNING") + time_span = "7d" + + # Define SQL query + sql = f""" + SELECT EVENT_TYPE, COUNT(*) + FROM bot_events + WHERE DATETIME >= datetime('now', '-{time_mappings[time_span]}') + GROUP BY EVENT_TYPE + ORDER BY COUNT(*) DESC + """ + + rows = run_db_operation(db_conn, "read", sql, log_func=log_func) + + # Organize data into OrderedDict + summary = OrderedDict() + summary["time_span"] = time_span + for event_type, count in rows: + summary[event_type] = count + + return summary + +def ensure_link_codes_table(db_conn, log_func): + """ + Ensures the 'link_codes' table exists. + This table stores one-time-use account linking codes. + """ + is_sqlite = "sqlite3" in str(type(db_conn)).lower() + + check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='link_codes'" if is_sqlite else """ + SELECT table_name FROM information_schema.tables + WHERE table_name = 'link_codes' AND table_schema = DATABASE() + """ + + rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func) + if rows and rows[0]: + log_func("Table 'link_codes' already exists, skipping creation.", "DEBUG") + return + + log_func("Creating 'link_codes' table...", "INFO") + + create_sql = """ + CREATE TABLE link_codes ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + DISCORD_USER_ID TEXT UNIQUE, + LINK_CODE TEXT UNIQUE, + CREATED_AT TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ if is_sqlite else """ + CREATE TABLE link_codes ( + ID INT PRIMARY KEY AUTO_INCREMENT, + DISCORD_USER_ID VARCHAR(50) UNIQUE, + LINK_CODE VARCHAR(50) UNIQUE, + CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + + result = run_db_operation(db_conn, "write", create_sql, log_func=log_func) + if result is None: + log_func("Failed to create 'link_codes' table!", "CRITICAL") + raise RuntimeError("Database table creation failed.") + + log_func("Successfully created table 'link_codes'.", "INFO") + +def merge_uuid_data(db_conn, log_func, old_uuid, new_uuid): + """ + Merges all records from the old UUID (Twitch account) into the new UUID (Discord account). + This replaces all instances of the old UUID in all relevant tables with the new UUID, + ensuring that no data is lost in the linking process. + + After merging, the old UUID entry is removed from the `users` table. + """ + log_func(f"Starting UUID merge: {old_uuid} -> {new_uuid}", "INFO") + + tables_to_update = [ + "voice_activity_log", + "bot_events", + "chat_log", + "user_howls", + "quotes" + ] + + for table in tables_to_update: + sql = f"UPDATE {table} SET UUID = ? WHERE UUID = ?" + rowcount = run_db_operation(db_conn, "update", sql, (new_uuid, old_uuid), log_func) + log_func(f"Updated {rowcount} rows in {table} (transferring {old_uuid} -> {new_uuid})", "DEBUG") + + # Finally, delete the old UUID from the `users` table + delete_sql = "DELETE FROM users WHERE UUID = ?" + rowcount = run_db_operation(db_conn, "write", delete_sql, (old_uuid,), log_func) + + log_func(f"Deleted old UUID {old_uuid} from 'users' table ({rowcount} rows affected)", "INFO") + + log_func(f"UUID merge complete: {old_uuid} -> {new_uuid}", "INFO") diff --git a/modules/utility.py b/modules/utility.py index a269c2b..19f60cf 100644 --- a/modules/utility.py +++ b/modules/utility.py @@ -4,7 +4,9 @@ import random import json import re import functools - +import inspect +import uuid +from modules.db import run_db_operation, lookup_user try: # 'regex' on PyPI supports `\p{L}`, `\p{N}`, etc. @@ -21,30 +23,29 @@ def monitor_cmds(log_func): Decorator that logs when a command starts and ends execution. """ def decorator(func): - @functools.wraps(func) # Preserve function metadata + @functools.wraps(func) async def wrapper(*args, **kwargs): start_time = time.time() try: + # Extract a command name from the function name cmd_name = str(func.__name__).split("_")[1] log_func(f"Command '{cmd_name}' started execution.", "DEBUG") - # Await the actual function (since it's an async command) + # Await the actual command function result = await func(*args, **kwargs) end_time = time.time() - cmd_duration = end_time - start_time - cmd_duration = str(round(cmd_duration, 2)) + cmd_duration = round(end_time - start_time, 2) log_func(f"Command '{cmd_name}' finished execution after {cmd_duration}s.", "DEBUG") - return result # Return the result of the command + return result except Exception as e: end_time = time.time() - cmd_duration = end_time - start_time - cmd_duration = str(round(cmd_duration, 2)) + cmd_duration = round(end_time - start_time, 2) log_func(f"Command '{cmd_name}' FAILED while executing after {cmd_duration}s: {e}", "CRITICAL") - - return wrapper # Return the wrapped function - - return decorator # Return the decorator itself + # Explicitly preserve the original signature for slash command introspection + wrapper.__signature__ = inspect.signature(func) + return wrapper + return decorator def format_uptime(seconds: float) -> tuple[str, int]: """ @@ -57,6 +58,7 @@ def format_uptime(seconds: float) -> tuple[str, int]: (Human-readable string, total seconds) """ seconds = int(seconds) # Ensure integer seconds + seconds_int = seconds # Define time units units = [ @@ -76,7 +78,7 @@ def format_uptime(seconds: float) -> tuple[str, int]: 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 (", ".join(time_values[:2]), seconds) if time_values else ("0 seconds", 0) + 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: """ @@ -281,6 +283,8 @@ def initialize_help_data(bot, help_json_path, is_discord, log_func): - Loaded commands not in help file => "missing help" """ + platform_name = "Discord" if is_discord else "Twitch" + 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 = {} @@ -307,12 +311,12 @@ def initialize_help_data(bot, help_json_path, is_discord, log_func): # 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") + log_func(f"Help file has '{cmd}', but it's not loaded on this {platform_name} 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") + log_func(f"Command '{cmd}' is loaded on {platform_name} but no help info is provided in {help_json_path}.", "WARNING") def get_loaded_commands(bot, log_func, is_discord): @@ -334,23 +338,16 @@ def get_loaded_commands(bot, log_func, is_discord): # 'bot.commands' is a set of Command objects for cmd_obj in bot.commands: commands_list.append(cmd_obj.name) - log_func(f"Discord commands body: {commands_list}", "DEBUG") + debug_level ="DEBUG" if len(commands_list) > 0 else "WARNING" + log_func(f"Discord commands body: {commands_list}", f"{debug_level}") except Exception as e: log_func(f"Error retrieving Discord commands: {e}", "ERROR") elif not is_discord: - # For TwitchIO - #if isinstance(bot.commands, set): try: - #commands_attr = bot.commands - #log_func(f"Twitch type(bot.commands) => {type(commands_attr)}", "DEBUG") - - # 'bot.all_commands' is a dict: { command_name: Command(...) } - #all_cmd_names = list(bot.all_commands.keys()) - #log_func(f"Twitch commands body: {all_cmd_names}", "DEBUG") - #commands_list.extend(all_cmd_names) for cmd_obj in bot._commands: commands_list.append(cmd_obj) - log_func(f"Twitch commands body: {commands_list}", "DEBUG") + debug_level ="DEBUG" if len(commands_list) > 0 else "WARNING" + log_func(f"Twitch commands body: {commands_list}", f"{debug_level}") except Exception as e: log_func(f"Error retrieving Twitch commands: {e}", "ERROR") else: @@ -368,7 +365,7 @@ def build_discord_help_message(cmd_name, cmd_help_dict): subcommands = cmd_help_dict.get("subcommands", {}) examples = cmd_help_dict.get("examples", []) - lines = [f"**Help for `!{cmd_name}`**:", + lines = [f"**Help for `{cmd_name}`**:", f"Description: {description}"] if subcommands: @@ -422,15 +419,178 @@ async def send_message(ctx, text): """ await ctx.send(text) +def track_user_activity( + db_conn, + log_func, + platform: str, + user_id: str, + 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. + + :param db_conn: The active DB connection + :param log_func: The logging function (message, level="INFO") + :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 + """ + + log_func(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" + if platform.lower() in ("discord", "twitch"): + identifier_type = f"{platform.lower()}_user_id" + else: + log_func(f"Unknown platform '{platform}' in track_user_activity!", "WARNING") + return + + # 1) Try to find an existing user row + user_data = lookup_user(db_conn, log_func, 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 + need_update = False + column_updates = [] + params = [] + + log_func(f"... Returned {user_data}", "DEBUG") + + if platform.lower() == "discord": + if user_data["discord_username"] != username: + need_update = True + column_updates.append("discord_username = ?") + params.append(username) + + if user_data["discord_user_display_name"] != display_name: + need_update = True + 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 = ?") + params.append(int(user_is_bot)) + + if need_update: + set_clause = ", ".join(column_updates) + update_sql = f""" + UPDATE users + SET {set_clause} + WHERE discord_user_id = ? + """ + params.append(user_id) + + rowcount = run_db_operation(db_conn, "update", update_sql, params=params, log_func=log_func) + if rowcount and rowcount > 0: + log_func(f"Updated Discord user '{username}' (display '{display_name}') in 'users'.", "DEBUG") + + elif platform.lower() == "twitch": + if user_data["twitch_username"] != username: + need_update = True + column_updates.append("twitch_username = ?") + params.append(username) + + if user_data["twitch_user_display_name"] != display_name: + need_update = True + 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 = ?") + params.append(int(user_is_bot)) + + if need_update: + set_clause = ", ".join(column_updates) + update_sql = f""" + UPDATE users + SET {set_clause} + WHERE twitch_user_id = ? + """ + params.append(user_id) + + rowcount = run_db_operation(db_conn, "update", update_sql, params=params, log_func=log_func) + if rowcount and rowcount > 0: + log_func(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 + new_uuid = str(uuid.uuid4()) + + if platform.lower() == "discord": + insert_sql = """ + INSERT INTO users ( + UUID, + discord_user_id, + discord_username, + discord_user_display_name, + user_is_bot + ) + VALUES (?, ?, ?, ?, ?) + """ + params = (new_uuid, user_id, username, display_name, int(user_is_bot)) + + else: # "twitch" + insert_sql = """ + INSERT INTO users ( + UUID, + twitch_user_id, + twitch_username, + twitch_user_display_name, + user_is_bot + ) + VALUES (?, ?, ?, ?, ?) + """ + params = (new_uuid, user_id, username, display_name, int(user_is_bot)) + + rowcount = run_db_operation(db_conn, "write", insert_sql, params, log_func=log_func) + if rowcount and rowcount > 0: + log_func(f"Created new user row for {platform} user '{username}' (display '{display_name}') with UUID={new_uuid}.", "DEBUG") + else: + log_func(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, log_func): + """ + Logs a bot startup event. + """ + log_bot_event(db_conn, log_func, "BOT_STARTUP", "Bot successfully started.") + +def log_bot_shutdown(db_conn, log_func, intent: str = "Error/Crash"): + """ + Logs a bot shutdown event. + """ + log_bot_event(db_conn, log_func, "BOT_SHUTDOWN", f"Bot is shutting down - {intent}.") + +def generate_link_code(): + """Generates a unique 8-character alphanumeric link code.""" + import random, string + return ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + + ############################################### # Development Test Function (called upon start) ############################################### -def dev_func(db_conn, log): - from modules.db import lookup_user - id = "203190147582394369" - id_type = "discord_user_id" - uui_info = lookup_user(db_conn, log, identifier=id, identifier_type=id_type) - if uui_info: - return list(uui_info.values()) - else: - return f"User with identifier '{id}' ({id_type}) not found" \ No newline at end of file +def dev_func(db_conn, log, enable: bool = False): + if enable: + id = "203190147582394369" + id_type = "discord_user_id" + uui_info = lookup_user(db_conn, log, identifier=id, identifier_type=id_type) + if uui_info: + return list(uui_info.values()) + else: + return f"User with identifier '{id}' ({id_type}) not found" \ No newline at end of file