# modules/db.py import os import re import time, datetime import sqlite3 import uuid import globals from globals import logger try: import mariadb except ImportError: mariadb = None # We handle gracefully if 'mariadb' isn't installed. def checkenable_db_fk(db_conn): """ 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() logger.debug("Enabled foreign key support in SQLite (PRAGMA foreign_keys=ON).") except Exception as e: logger.warning(f"Failed to enable foreign key support in SQLite: {e}") else: # For MariaDB/MySQL, they're typically enabled with InnoDB logger.debug("Assuming DB is MariaDB/MySQL with FKs enabled") def init_db_connection(config): """ Initializes a database connection based on config.json contents: - If config says 'use_mariadb', tries connecting to MariaDB. - If that fails (or not configured), falls back to SQLite. - Logs FATAL if neither can be established (the bot likely depends on DB). :param config: (dict) The loaded config.json data :param log: (function) Logging function (message, level="INFO") :return: a connection object (MariaDB or sqlite3.Connection), or None on failure """ db_settings = config.get("database", {}) use_mariadb = db_settings.get("use_mariadb", False) 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", "") password = db_settings.get("mariadb_password", "") dbname = db_settings.get("mariadb_dbname", "") port = int(db_settings.get("mariadb_port", 3306)) if user and password and dbname: try: conn = mariadb.connect( host=host, user=user, password=password, database=dbname, port=port ) conn.autocommit = False # We'll manage commits manually logger.info(f"Database connection established using MariaDB (host={host}, db={dbname}).") return conn except mariadb.Error as e: logger.warning(f"Error connecting to MariaDB: {e}") else: logger.warning("MariaDB config incomplete. Falling back to SQLite...") else: if use_mariadb and mariadb is None: logger.warning("mariadb module not installed but use_mariadb=True. Falling back to SQLite...") # Fallback to local SQLite sqlite_path = db_settings.get("sqlite_path", "local_database.sqlite") try: conn = sqlite3.connect(sqlite_path) logger.debug(f"Database connection established using local SQLite: {sqlite_path}") return conn except sqlite3.Error as e: logger.warning(f"Could not open local SQLite database '{sqlite_path}': {e}") # If neither MariaDB nor SQLite connected, that's fatal for the bot logger.fatal("No valid database connection could be established! Exiting...") return None def run_db_operation(conn, operation, query, params=None): """ Executes a parameterized query with basic screening for injection attempts: - 'operation' can be "read", "write", "update", "delete", "lookup", etc. - 'query' is the SQL statement, with placeholders (? in SQLite or %s in MariaDB both work). - 'params' is a tuple/list of parameters for the query (preferred for security). 1) We do a minimal check for suspicious patterns, e.g. multiple statements or known bad keywords. 2) We execute the query with parameters, and commit on write/update/delete. 3) On read/lookup, we fetch and return rows. Otherwise, return rowcount. NOTE: - This is still not a replacement for well-structured queries and security best practices. - Always use parameterized queries wherever possible to avoid injection. """ if conn is None: if logger: logger.fatal("run_db_operation called but no valid DB connection!") return None if params is None: params = () # Basic screening for malicious usage (multiple statements, forced semicolons, suspicious SQL keywords, etc.) # This is minimal and can be expanded if needed. lowered = query.strip().lower() # Check for multiple statements separated by semicolons (beyond the last one) if lowered.count(";") > 1: if logger: logger.warning("Query blocked: multiple SQL statements detected.") logger.warning(f"Offending query: {query}") return None # Potentially dangerous SQL keywords forbidden_keywords = ["drop table", "union select", "exec ", "benchmark(", "sleep("] for kw in forbidden_keywords: if kw in lowered: if logger: logger.warning(f"Query blocked due to forbidden keyword: '{kw}'") logger.warning(f"Offending query: {query}") return None cursor = conn.cursor() try: cursor.execute(query, params) # If it's a write/update/delete, commit the changes write_ops = ("write", "insert", "update", "delete", "change") if operation.lower() in write_ops: conn.commit() if logger: logger.debug(f"DB operation '{operation}' committed.") # If the query is an INSERT, return the last inserted row ID if query.strip().lower().startswith("insert"): try: return cursor.lastrowid except Exception as e: if logger: logger.error(f"Error retrieving lastrowid: {e}") return cursor.rowcount else: return cursor.rowcount # If it's read/lookup, fetch results read_ops = ("read", "lookup", "select") if operation.lower() in read_ops: rows = cursor.fetchall() return rows else: return cursor.rowcount # for insert/update/delete, rowcount can be helpful except Exception as e: # Rollback on any error conn.rollback() if logger: logger.error(f"Error during '{operation}' query execution: {e}") return None finally: cursor.close() ####################### # Ensure quotes table exists ####################### def ensure_quotes_table(db_conn): """ Checks if 'quotes' table exists. If not, attempts to create it. Raises an Exception or logs errors if creation fails. """ # 1) Determine if DB is sqlite or mariadb for the system table check is_sqlite = "sqlite3" in str(type(db_conn)).lower() # 2) Check existence if is_sqlite: # For SQLite: check the sqlite_master table check_sql = """ SELECT name FROM sqlite_master WHERE type='table' AND name='quotes' """ else: # For MariaDB/MySQL: check information_schema check_sql = """ SELECT table_name FROM information_schema.tables WHERE table_name = 'quotes' AND table_schema = DATABASE() """ from modules.db import run_db_operation rows = run_db_operation(db_conn, "read", check_sql) if rows and rows[0] and rows[0][0]: # The table 'quotes' already exists logger.debug("Table 'quotes' already exists, skipping creation.") return # We can just return # 3) Table does NOT exist => create it logger.info("Table 'quotes' does not exist; creating now...") if is_sqlite: create_table_sql = """ CREATE TABLE quotes ( ID INTEGER PRIMARY KEY AUTOINCREMENT, QUOTE_TEXT TEXT, QUOTEE TEXT, QUOTE_CHANNEL TEXT, QUOTE_DATETIME TEXT, QUOTE_GAME TEXT, QUOTE_REMOVED BOOLEAN DEFAULT 0, QUOTE_REMOVED_BY TEXT, QUOTE_REMOVED_DATETIME TEXT DEFAULT NULL, FOREIGN KEY (QUOTEE) REFERENCES users(UUID), FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ) """ else: create_table_sql = """ CREATE TABLE quotes ( ID INT PRIMARY KEY AUTO_INCREMENT, QUOTE_TEXT TEXT, QUOTEE VARCHAR(100), QUOTE_CHANNEL VARCHAR(100), QUOTE_DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, QUOTE_GAME VARCHAR(200), QUOTE_REMOVED BOOLEAN DEFAULT FALSE, QUOTE_REMOVED_BY VARCHAR(100), QUOTE_REMOVED_DATETIME DATETIME DEFAULT NULL, FOREIGN KEY (QUOTEE) REFERENCES users(UUID) ON DELETE SET NULL FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ON DELETE SET NULL ) """ result = run_db_operation(db_conn, "write", create_table_sql) if result is None: # If run_db_operation returns None on error, handle or raise: error_msg = "Failed to create 'quotes' table!" logger.critical(error_msg) raise RuntimeError(error_msg) logger.info("Successfully created table 'quotes'.") ####################### # Ensure 'users' table ####################### def ensure_users_table(db_conn): """ Ensures the 'Users' table exists and has the necessary columns. """ is_sqlite = "sqlite3" in str(type(db_conn)).lower() if is_sqlite: check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='Users'" else: check_sql = """ SELECT table_name FROM information_schema.tables WHERE table_name = 'Users' AND table_schema = DATABASE() """ rows = run_db_operation(db_conn, "read", check_sql) if rows and rows[0]: logger.debug("Table 'Users' already exists, checking for column updates.") # Ensure 'last_seen' column exists column_check_sql = "PRAGMA table_info(Users)" if is_sqlite else """ SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Users' AND COLUMN_NAME = 'last_seen' """ columns = run_db_operation(db_conn, "read", column_check_sql) if not any("last_seen" in col for col in columns): logger.info("Adding 'last_seen' column to 'Users'...") alter_sql = "ALTER TABLE Users ADD COLUMN last_seen TEXT DEFAULT CURRENT_TIMESTAMP" if is_sqlite else """ ALTER TABLE Users ADD COLUMN last_seen DATETIME DEFAULT CURRENT_TIMESTAMP """ run_db_operation(db_conn, "write", alter_sql) return logger.info("Table 'Users' does not exist; creating now...") create_sql = """ CREATE TABLE Users ( UUID TEXT PRIMARY KEY, Unified_Username TEXT, datetime_linked TEXT, user_is_banned BOOLEAN DEFAULT 0, user_is_bot BOOLEAN DEFAULT 0, last_seen TEXT DEFAULT CURRENT_TIMESTAMP ) """ if is_sqlite else """ CREATE TABLE Users ( UUID VARCHAR(36) PRIMARY KEY, Unified_Username VARCHAR(100), datetime_linked DATETIME, user_is_banned BOOLEAN DEFAULT FALSE, user_is_bot BOOLEAN DEFAULT FALSE, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP ) """ result = run_db_operation(db_conn, "write", create_sql) if result is None: error_msg = "Failed to create 'Users' table!" logger.critical(error_msg) raise RuntimeError(error_msg) logger.info("Successfully created table 'Users'.") ####################### # Ensure 'platform_mapping' table ####################### def ensure_platform_mapping_table(db_conn): """ Ensures the 'Platform_Mapping' table exists and has the necessary columns. """ is_sqlite = "sqlite3" in str(type(db_conn)).lower() if is_sqlite: check_sql = """ SELECT name FROM sqlite_master WHERE type='table' AND name='Platform_Mapping' """ else: check_sql = """ SELECT table_name FROM information_schema.tables WHERE table_name = 'Platform_Mapping' AND table_schema = DATABASE() """ rows = run_db_operation(db_conn, "read", check_sql) if rows and rows[0] and rows[0][0]: logger.debug("Table 'Platform_Mapping' already exists, checking for column updates.") # Check if last_seen column exists column_check_sql = """ PRAGMA table_info(Platform_Mapping) """ if is_sqlite else """ SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Platform_Mapping' AND COLUMN_NAME = 'last_seen' """ columns = run_db_operation(db_conn, "read", column_check_sql) # If column doesn't exist, add it if not any("last_seen" in col for col in columns): logger.info("Adding 'last_seen' column to 'Platform_Mapping'...") alter_sql = """ ALTER TABLE Platform_Mapping ADD COLUMN last_seen TEXT DEFAULT CURRENT_TIMESTAMP """ if is_sqlite else """ ALTER TABLE Platform_Mapping ADD COLUMN last_seen DATETIME DEFAULT CURRENT_TIMESTAMP """ run_db_operation(db_conn, "write", alter_sql) return logger.info("Table 'Platform_Mapping' does not exist; creating now...") if is_sqlite: create_sql = """ CREATE TABLE Platform_Mapping ( Platform_User_ID TEXT, Platform_Type TEXT, UUID TEXT, Display_Name TEXT, Username TEXT, last_seen TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (Platform_User_ID, Platform_Type), FOREIGN KEY (UUID) REFERENCES Users(UUID) ON DELETE CASCADE ) """ else: create_sql = """ CREATE TABLE Platform_Mapping ( Platform_User_ID VARCHAR(100), Platform_Type ENUM('Discord', 'Twitch'), UUID VARCHAR(36), Display_Name VARCHAR(100), Username VARCHAR(100), last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (Platform_User_ID, Platform_Type), FOREIGN KEY (UUID) REFERENCES Users(UUID) ON DELETE CASCADE ) """ result = run_db_operation(db_conn, "write", create_sql) if result is None: error_msg = "Failed to create 'Platform_Mapping' table!" logger.critical(error_msg) raise RuntimeError(error_msg) logger.info("Successfully created table 'Platform_Mapping'.") ######################## # Lookup user function ######################## def lookup_user(db_conn, identifier: str, identifier_type: str, target_identifier: str = None): """ Looks up a user in the 'Users' table using 'Platform_Mapping' for platform-specific IDs, UUID, usernames, and display names. identifier_type can be: - "uuid" (to lookup by UUID directly) - "unified_username" - "discord_user_id" / "twitch_user_id" (Platform-specific user ID) - "discord_display_name" / "twitch_display_name" (Platform-specific display name) - "discord_username" / "twitch_username" (Platform-specific raw username) Returns: If target_identifier is None: A dictionary with the following keys: { "UUID": str, "Unified_Username": str, "datetime_linked": str, "user_is_banned": bool or int, "user_is_bot": bool or int, "platform_user_id": str, "platform_display_name": str, "platform_username": str, "platform_type": str } If target_identifier is provided: The value from the record corresponding to that column. If the lookup fails or the parameters are invalid: None. """ PRINT_QUERY_DEBUG = False # Debug: Log the inputs if PRINT_QUERY_DEBUG: logger.debug(f"lookup_user() called with: identifier='{identifier}', identifier_type='{identifier_type}', target_identifier='{target_identifier}'") # Normalize identifier_type to lowercase identifier_type = identifier_type.lower() # Define valid identifier types with SQL column mappings valid_identifier_types = { "uuid": "u.UUID", "unified_username": "u.Unified_Username", "discord_user_id": "pm.Platform_User_ID", "twitch_user_id": "pm.Platform_User_ID", "discord_display_name": "pm.Display_Name", "twitch_display_name": "pm.Display_Name", "discord_username": "pm.Username", "twitch_username": "pm.Username", } # Extract platform from identifier type (if applicable) platform_map = { "discord_user_id": "Discord", "twitch_user_id": "Twitch", "discord_display_name": "Discord", "twitch_display_name": "Twitch", "discord_username": "Discord", "twitch_username": "Twitch", } if identifier_type not in valid_identifier_types: logger.warning(f"lookup_user error: invalid identifier_type '{identifier_type}'") return None column_to_lookup = valid_identifier_types[identifier_type] platform_filter = platform_map.get(identifier_type, None) # Construct query query = f""" SELECT u.UUID, u.Unified_Username, u.datetime_linked, u.user_is_banned, u.user_is_bot, pm.Platform_User_ID, pm.Display_Name, pm.Username, pm.Platform_Type FROM Users u LEFT JOIN Platform_Mapping pm ON u.UUID = pm.UUID WHERE {column_to_lookup} = ? """ params = [identifier] # Apply platform filter if applicable if platform_filter: query += " AND pm.Platform_Type = ?" params.append(platform_filter) query += " LIMIT 1" # Debug: Log the query and parameters if PRINT_QUERY_DEBUG: logger.debug(f"lookup_user() executing query: {query} with params={params}") # Run the query rows = run_db_operation(db_conn, "read", query, tuple(params)) # Debug: Log the result of the query if PRINT_QUERY_DEBUG: logger.debug(f"lookup_user() query result: {rows}") # Handle no result case if not rows: logger.info(f"lookup_user: No user found for {identifier_type}='{identifier}'") return None # Convert the row to a dictionary row = rows[0] user_data = { "uuid": row[0], # Ensure UUID is uppercase "unified_username": row[1], "datetime_linked": row[2], "user_is_banned": row[3], "user_is_bot": row[4], "platform_user_id": row[5], "platform_display_name": row[6], "platform_username": row[7], "platform_type": row[8] } # Debug: Log the constructed user data if PRINT_QUERY_DEBUG: logger.debug(f"lookup_user() constructed user_data: {user_data}") # If target_identifier is provided, return just that value if target_identifier: target_identifier = target_identifier.lower() if target_identifier in user_data: if PRINT_QUERY_DEBUG: logger.debug(f"lookup_user() returning target_identifier='{target_identifier}' with value='{user_data[target_identifier]}'") return user_data[target_identifier] else: logger.warning(f"lookup_user error: target_identifier '{target_identifier}' not found in user_data. Available keys: {list(user_data.keys())}") return None if PRINT_QUERY_DEBUG: logger.debug(f"lookup_user() returning full user_data: {user_data}") return user_data def user_lastseen(db_conn, UUID: str, platform_name: str = None, platform_user_id: str | int = None, lookup: bool = False, update: bool = False): """ Handles user last seen updates and lookups. - `lookup=True`: Fetches the last-seen timestamp. - `update=True`: Updates the last-seen timestamp. - If platform_name and platform_user_id are provided, the query will be scoped to that account. - Otherwise, it applies to all accounts unified under the UUI system. """ if not UUID: logger.error("UUID is required for user_lastseen()") return None if lookup: query = """ SELECT last_seen FROM Platform_Mapping WHERE UUID = ? """ if not platform_name or not platform_user_id else """ SELECT last_seen FROM Platform_Mapping WHERE UUID = ? AND Platform_Type = ? AND Platform_User_ID = ? """ params = (UUID,) if not platform_name or not platform_user_id else (UUID, platform_name, str(platform_user_id)) result = run_db_operation(db_conn, "read", query, params) if update: update_sql = """ UPDATE Platform_Mapping SET last_seen = CURRENT_TIMESTAMP WHERE UUID = ? """ if not platform_name or not platform_user_id else """ UPDATE Platform_Mapping SET last_seen = CURRENT_TIMESTAMP WHERE UUID = ? AND Platform_Type = ? AND Platform_User_ID = ? """ params = (UUID,) if not platform_name or not platform_user_id else (UUID, platform_name, str(platform_user_id)) run_db_operation(db_conn, "write", update_sql, params) logger.debug(f"Updated last_seen timestamp for UUID={UUID}") if lookup: if result and result[0]: return result[0][0] # Return last seen datetime return None # No data found elif update and not lookup: return True return False # No action taken def ensure_chatlog_table(db_conn): """ Ensures the 'chat_log' table exists, updating the schema to use a UUID primary key and an additional column for platform-specific message IDs. """ 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='chat_log' """ if is_sqlite else """ 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) if rows and rows[0] and rows[0][0]: logger.debug("Table 'chat_log' already exists, skipping creation.") return # Table does not exist, create it logger.info("Table 'chat_log' does not exist; creating now...") create_sql = """ CREATE TABLE chat_log ( UUID TEXT PRIMARY KEY, PLATFORM_MESSAGE_ID TEXT DEFAULT NULL, USER_UUID TEXT, MESSAGE_CONTENT TEXT, PLATFORM TEXT, CHANNEL TEXT, DATETIME TEXT DEFAULT CURRENT_TIMESTAMP, ATTACHMENTS TEXT, FOREIGN KEY (USER_UUID) REFERENCES users(UUID) ) """ if is_sqlite else """ CREATE TABLE chat_log ( UUID VARCHAR(36) PRIMARY KEY, PLATFORM_MESSAGE_ID VARCHAR(100) DEFAULT NULL, USER_UUID VARCHAR(36), MESSAGE_CONTENT TEXT, PLATFORM VARCHAR(100), CHANNEL VARCHAR(100), DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, ATTACHMENTS TEXT, FOREIGN KEY (USER_UUID) REFERENCES users(UUID) ON DELETE SET NULL ) """ result = run_db_operation(db_conn, "write", create_sql) if result is None: error_msg = "Failed to create 'chat_log' table!" logger.critical(error_msg) raise RuntimeError(error_msg) logger.info("Successfully created table 'chat_log'.") def log_message(db_conn, identifier, identifier_type, message_content, platform, channel, attachments=None, platform_message_id=None): """ Logs a message in 'chat_log' with UUID fetched using the Platform_Mapping structure. - Uses a UUID as the primary key for uniqueness across platforms. - Stores platform-specific message IDs when provided. - Logs a warning if a message ID is expected but not provided. """ # Get UUID using lookup_user user_data = lookup_user(db_conn, identifier, identifier_type) if not user_data: logger.warning(f"User not found for {identifier_type}='{identifier}'") return user_uuid = user_data["uuid"] message_uuid = str(uuid.uuid4()) # Generate a new UUID for the entry # Determine if a message ID is required for this platform requires_message_id = platform.startswith("discord") or platform == "twitch" if requires_message_id and not platform_message_id: logger.warning(f"Warning: Platform '{platform}' usually requires a message ID, but none was provided.") if attachments is None or not "https://" in attachments: attachments = "" insert_sql = """ INSERT INTO chat_log ( UUID, PLATFORM_MESSAGE_ID, USER_UUID, MESSAGE_CONTENT, PLATFORM, CHANNEL, ATTACHMENTS ) VALUES (?, ?, ?, ?, ?, ?, ?) """ params = (message_uuid, platform_message_id, user_uuid, message_content, platform, channel, attachments) rowcount = run_db_operation(db_conn, "write", insert_sql, params) if rowcount and rowcount > 0: logger.debug(f"Logged message for UUID={user_uuid} with Message UUID={message_uuid}.") else: logger.error("Failed to log message in 'chat_log'.") def ensure_userhowls_table(db_conn): """ 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) if rows and rows[0] and rows[0][0]: logger.debug("Table 'user_howls' already exists, skipping creation.") return logger.info("Table 'user_howls' does not exist; creating now...") 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) if result is None: err_msg = "Failed to create 'user_howls' table!" logger.error(err_msg) raise RuntimeError(err_msg) logger.info("Successfully created table 'user_howls'.") def insert_howl(db_conn, 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) if rowcount and rowcount > 0: logger.debug(f"Recorded a {howl_value}% howl for UUID={user_uuid}.") else: logger.error(f"Failed to record {howl_value}% howl for UUID={user_uuid}.") def get_howl_stats(db_conn, 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,)) 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): """ 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) 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): """ 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) if rows and rows[0]: logger.debug("Table 'discord_activity' already exists, skipping creation.") return logger.info("Creating 'discord_activity' table...") 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) except Exception as e: logger.error(f"Unable to create the table: discord_activity: {e}") if result is None: logger.critical("Failed to create 'discord_activity' table!") raise RuntimeError("Database table creation failed.") logger.info("Successfully created table 'discord_activity'.") def log_discord_activity(db_conn, guild_id, user_identifier, action, voice_channel, action_detail=None): """ Logs Discord activities with duplicate detection to prevent redundant logs. """ # Resolve UUID using the new Platform_Mapping user_data = lookup_user(db_conn, user_identifier, identifier_type="discord_user_id") if not user_data: logger.warning(f"User not found for Discord ID: {user_identifier}") return user_uuid = user_data["uuid"] # 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 # Duplicate Detection Logic def normalize_detail(detail): """Normalize detail for comparison (lowercase, stripped of whitespace).""" return detail.strip().lower() if detail else None DUPLICATE_THRESHOLD = datetime.timedelta(minutes=5) NUM_RECENT_ENTRIES = 5 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)) last_same, last_different = None, None 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: logger.error(f"Error parsing datetime '{dt_str}': {e}") continue normalized_existing = normalize_detail(detail) if normalized_existing == normalized_new: if last_same is None or dt > last_same: last_same = dt else: if last_different is None or dt > last_different: last_different = dt # Check duplicate conditions if last_same is not None: if (last_different is None) or (last_same > last_different): if now - last_same < DUPLICATE_THRESHOLD: logger.debug(f"Duplicate {action} event for {user_uuid} within threshold; skipping log.") return # Insert the new event insert_sql = """ INSERT INTO discord_activity (UUID, ACTION, GUILD_ID, VOICE_CHANNEL, ACTION_DETAIL) VALUES (?, ?, ?, ?, ?) """ if "sqlite3" in str(type(db_conn)).lower() else """ INSERT INTO discord_activity (UUID, ACTION, GUILD_ID, VOICE_CHANNEL, ACTION_DETAIL) VALUES (%s, %s, %s, %s, %s) """ params = (user_uuid, action, guild_id, channel_val, action_detail) rowcount = run_db_operation(db_conn, "write", insert_sql, params) if rowcount and rowcount > 0: detail_str = f" ({action_detail})" if action_detail else "" logger.debug(f"Logged Discord activity for UUID={user_uuid} in Guild {guild_id}: {action}{detail_str}") else: logger.error("Failed to log Discord activity.") def ensure_bot_events_table(db_conn): """ 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) if rows and rows[0]: logger.debug("Table 'bot_events' already exists, skipping creation.") return logger.info("Creating 'bot_events' table...") # 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) if result is None: logger.critical("Failed to create 'bot_events' table!") raise RuntimeError("Database table creation failed.") logger.info("Successfully created table 'bot_events'.") def log_bot_event(db_conn, 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) if rowcount and rowcount > 0: logger.debug(f"Logged bot event: {event_type} - {event_details}") else: logger.error("Failed to log bot event.") def get_event_summary(db_conn, 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: logger.warning(f"Invalid time span '{time_span}', defaulting to '7d'") 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) # 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): """ 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) if rows and rows[0]: logger.debug("Table 'link_codes' already exists, skipping creation.") return logger.info("Creating 'link_codes' table...") 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) if result is None: logger.critical("Failed to create 'link_codes' table!") raise RuntimeError("Database table creation failed.") logger.info("Successfully created table 'link_codes'.") def merge_uuid_data(db_conn, old_uuid, new_uuid): """ Merges data from old UUID to new UUID, updating references in Platform_Mapping. """ logger.info(f"Merging UUID data: {old_uuid} -> {new_uuid}") # Update references in Platform_Mapping update_mapping_sql = """ UPDATE Platform_Mapping SET UUID = ? WHERE UUID = ? """ run_db_operation(db_conn, "update", update_mapping_sql, (new_uuid, old_uuid)) tables_to_update = [ "chat_log", "user_howls", "discord_activity", "community_events" ] 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)) logger.debug(f"Updated {rowcount} rows in {table} (transferred {old_uuid} -> {new_uuid})") # Finally, delete the old UUID from Users table delete_sql = "DELETE FROM Users WHERE UUID = ?" rowcount = run_db_operation(db_conn, "write", delete_sql, (old_uuid,)) logger.info(f"Deleted old UUID {old_uuid} from 'Users' table ({rowcount} rows affected)") logger.info(f"UUID merge complete: {old_uuid} -> {new_uuid}") def ensure_community_events_table(db_conn): """ Checks if 'community_events' table exists. If not, attempts to create it. Raises an Exception or logs errors if creation fails. """ # 1) Determine if DB is sqlite or MariaDB/MySQL is_sqlite = "sqlite3" in str(type(db_conn)).lower() # 2) Check for existence if is_sqlite: check_sql = """ SELECT name FROM sqlite_master WHERE type='table' AND name='community_events' """ else: check_sql = """ SELECT table_name FROM information_schema.tables WHERE table_name = 'community_events' AND table_schema = DATABASE() """ from modules.db import run_db_operation rows = run_db_operation(db_conn, "read", check_sql) if rows and rows[0] and rows[0][0]: logger.debug("Table 'community_events' already exists, skipping creation.") return logger.debug("Table 'community_events' does not exist; creating now...") if is_sqlite: create_table_sql = """ CREATE TABLE community_events ( EVENT_ID INTEGER PRIMARY KEY AUTOINCREMENT, EVENT_PLATFORM TEXT NOT NULL, EVENT_TYPE TEXT NOT NULL, EVENT_DETAILS TEXT, EVENT_USER TEXT NOT NULL, DATETIME TEXT NOT NULL, EVENT_EXTRAS TEXT ) """ else: create_table_sql = """ CREATE TABLE community_events ( EVENT_ID INT PRIMARY KEY AUTO_INCREMENT, EVENT_PLATFORM VARCHAR(50) NOT NULL, EVENT_TYPE VARCHAR(100) NOT NULL, EVENT_DETAILS TEXT, EVENT_USER VARCHAR(100) NOT NULL, DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, EVENT_EXTRAS TEXT ) """ result = run_db_operation(db_conn, "write", create_table_sql) if result is None: error_msg = "Failed to create 'community_events' table!" logger.critical(error_msg) raise RuntimeError(error_msg) logger.debug("Successfully created table 'community_events'.") async def handle_community_event(db_conn, is_discord, ctx, args): """ Handles community event commands including add, info, list, and search. """ if len(args) == 0: args = ["list"] sub = args[0].lower() if sub == "add": if len(args) < 2: return "Please provide the event type after 'add'." event_type = args[1] event_details = " ".join(args[2:]).strip() if len(args) > 2 else None event_extras = None # Support extras using "||" separator if event_details and "||" in event_details: parts = event_details.split("||", 1) event_details = parts[0].strip() event_extras = parts[1].strip() platform = "Discord" if is_discord else "Twitch" user_id = str(ctx.author.id) # Get UUID using lookup_user() user_data = lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform.lower()}_user_id") if not user_data: logger.error(f"User not found: {ctx.author.name} ({user_id}) on {platform}") return "Could not log event: user data missing." user_uuid = user_data["uuid"] # Insert new event. Adjust for SQLite or MariaDB. insert_sql = """ INSERT INTO community_events (EVENT_PLATFORM, EVENT_TYPE, EVENT_DETAILS, EVENT_USER, DATETIME, EVENT_EXTRAS) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?) """ if "sqlite3" in str(type(db_conn)).lower() else """ INSERT INTO community_events (EVENT_PLATFORM, EVENT_TYPE, EVENT_DETAILS, EVENT_USER, DATETIME, EVENT_EXTRAS) VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP, %s) """ params = (platform, event_type, event_details, user_uuid, event_extras) result = run_db_operation(db_conn, "write", insert_sql, params) if result is not None: logger.debug(f"New event added: {event_type} by {ctx.author.name}") return f"Successfully logged event: {event_type}" else: return "Failed to log event." elif sub == "info": if len(args) < 2 or not args[1].isdigit(): return "Please specify a valid event ID." event_id = int(args[1]) select_sql = "SELECT * FROM community_events WHERE EVENT_ID = ?" if "sqlite3" not in str(type(db_conn)).lower(): select_sql = "SELECT * FROM community_events WHERE EVENT_ID = %s" rows = run_db_operation(db_conn, "read", select_sql, (event_id,)) if not rows: return f"No event found with ID {event_id}." row = rows[0] return ( f"Event #{row[0]}:\n" f"Platform: {row[1]}\n" f"Type: {row[2]}\n" f"Details: {row[3] or 'N/A'}\n" f"User UUID: {row[4]}\n" f"Datetime: {row[5]}\n" f"Extras: {row[6] or 'N/A'}" ) else: return await handle_community_event(db_conn, is_discord, ctx, ["list"])