Massive UUI system and database overhaul

- Dynamic accounts association
  - Accounts can now be associated dynamically, allowing multiple accounts on the same platform to be associated to the same UUID.
  - UUI system now supports any platform, eg. YouTube, TikTok, Kick, Twitter, etc.
  - More robust user lookups with enhanced fault tolerance and allowance for NULL data.
  - Optimized database structure with two tables for user association; one for UUID and basic info, another for platform-specific details.
- Enhanced logging functionality: logs now prefix the calling function.
- Enhanced user lookup debug messages, allowing easy query inspection and data validation.
- Other minor fixes
kami_dev
Kami 2025-03-01 01:54:51 +01:00
parent 766c3ab690
commit d0313a6a92
7 changed files with 393 additions and 404 deletions

View File

@ -93,17 +93,19 @@ class DiscordBot(commands.Bot):
else: else:
guild_name = "DM" guild_name = "DM"
channel_name = "Direct Message" channel_name = "Direct Message"
globals.log(f"Message detected by '{message.author.name}' in '{guild_name}' - #'{channel_name}'", "DEBUG") globals.log(f"Message detected by '{message.author.name}' in '{guild_name}' - #'{channel_name}'", "DEBUG")
#globals.log(f"Message body:\n{message}\nMessage content: {message.content}", "DEBUG") # Full message debug globals.log(f"Message content: '{message.content}'", "DEBUG")
globals.log(f"Message content: '{message.content}'", "DEBUG") # Partial message debug (content only)
globals.log(f"Attempting UUI lookup on '{message.author.name}' ...", "DEBUG") globals.log(f"Attempting UUI lookup on '{message.author.name}' ...", "DEBUG")
try: try:
# If it's a bot message, ignore or pass user_is_bot=True # If it's a bot message, ignore or pass user_is_bot=True
is_bot = message.author.bot is_bot = message.author.bot
user_id = str(message.author.id) user_id = str(message.author.id)
user_name = message.author.name # no discriminator user_name = message.author.name # no discriminator
display_name = message.author.display_name display_name = message.author.display_name
# Track user activity first
modules.utility.track_user_activity( modules.utility.track_user_activity(
db_conn=self.db_conn, db_conn=self.db_conn,
platform="discord", platform="discord",
@ -113,33 +115,21 @@ class DiscordBot(commands.Bot):
user_is_bot=is_bot user_is_bot=is_bot
) )
user_data = lookup_user(db_conn=self.db_conn, identifier=user_id, identifier_type="discord_user_id") # Let log_message() handle UUID lookup internally
user_uuid = user_data["UUID"] if user_data else "UNKNOWN" platform_str = f"discord-{guild_name}"
globals.log(f"... UUI lookup complete", "DEBUG") channel_str = channel_name
if user_uuid:
# The "platform" can be e.g. "discord" or you can store the server name
platform_str = f"discord-{guild_name}"
# The channel name can be message.channel.name or "DM" if it's a private channel
channel_str = channel_name
# If you have attachments, you could gather them as links. attachments = ", ".join(a.url for a in message.attachments) if message.attachments else ""
try:
attachments = ", ".join(a.url for a in message.attachments) if message.attachments else ""
except Exception:
attachments = ""
log_message( log_message(
db_conn=self.db_conn, db_conn=self.db_conn,
user_uuid=user_uuid, identifier=user_id,
message_content=message.content or "", identifier_type="discord_user_id",
platform=platform_str, message_content=message.content or "",
channel=channel_str, platform=platform_str,
attachments=attachments, channel=channel_str,
username=message.author.name attachments=attachments
) )
# PLACEHOLDER FOR FUTURE MESSAGE PROCESSING
except Exception as e: except Exception as e:
globals.log(f"... UUI lookup failed: {e}", "WARNING") globals.log(f"... UUI lookup failed: {e}", "WARNING")
pass pass
@ -151,6 +141,7 @@ class DiscordBot(commands.Bot):
except Exception as e: except Exception as e:
globals.log(f"Command processing failed: {e}", "ERROR") globals.log(f"Command processing failed: {e}", "ERROR")
def load_bot_settings(self): def load_bot_settings(self):
"""Loads bot activity settings from JSON file.""" """Loads bot activity settings from JSON file."""
try: try:

View File

@ -145,7 +145,10 @@ class TwitchBot(commands.Bot):
globals.log("Twitch token refreshed successfully. Restarting bot...") globals.log("Twitch token refreshed successfully. Restarting bot...")
# Restart the TwitchIO connection # Restart the TwitchIO connection
await self.close() # Close the old connection try:
await self.close() # Close the old connection
except Exception as e:
globals.log(f"refresh_access_token() failed during close attempt: {e}", "WARNING")
await self.start() # Restart with the new token await self.start() # Restart with the new token
return # Exit function after successful refresh return # Exit function after successful refresh

10
bots.py
View File

@ -51,6 +51,7 @@ async def main():
"Bot events table": partial(db.ensure_bot_events_table, db_conn), "Bot events table": partial(db.ensure_bot_events_table, db_conn),
"Quotes table": partial(db.ensure_quotes_table, db_conn), "Quotes table": partial(db.ensure_quotes_table, db_conn),
"Users table": partial(db.ensure_users_table, db_conn), "Users table": partial(db.ensure_users_table, db_conn),
"Platform_Mapping table": partial(db.ensure_platform_mapping_table, db_conn),
"Chatlog table": partial(db.ensure_chatlog_table, db_conn), "Chatlog table": partial(db.ensure_chatlog_table, db_conn),
"Howls table": partial(db.ensure_userhowls_table, db_conn), "Howls table": partial(db.ensure_userhowls_table, db_conn),
"Discord activity table": partial(db.ensure_discord_activity_table, db_conn), "Discord activity table": partial(db.ensure_discord_activity_table, db_conn),
@ -69,7 +70,7 @@ async def main():
# Create both bots # Create both bots
discord_bot = DiscordBot() discord_bot = DiscordBot()
twitch_bot = TwitchBot() #twitch_bot = TwitchBot()
# Log startup # Log startup
utility.log_bot_startup(db_conn) utility.log_bot_startup(db_conn)
@ -77,7 +78,7 @@ async def main():
# Provide DB connection to both bots # Provide DB connection to both bots
try: try:
discord_bot.set_db_connection(db_conn) discord_bot.set_db_connection(db_conn)
twitch_bot.set_db_connection(db_conn) #twitch_bot.set_db_connection(db_conn)
globals.log(f"Initialized database connection to both bots") globals.log(f"Initialized database connection to both bots")
except Exception as e: except Exception as e:
globals.log(f"Unable to initialize database connection to one or both bots: {e}", "FATAL") globals.log(f"Unable to initialize database connection to one or both bots: {e}", "FATAL")
@ -85,7 +86,7 @@ async def main():
globals.log("Starting Discord and Twitch bots...") globals.log("Starting Discord and Twitch bots...")
discord_task = asyncio.create_task(discord_bot.run(os.getenv("DISCORD_BOT_TOKEN"))) discord_task = asyncio.create_task(discord_bot.run(os.getenv("DISCORD_BOT_TOKEN")))
twitch_task = asyncio.create_task(twitch_bot.run()) #twitch_task = asyncio.create_task(twitch_bot.run())
from modules.utility import dev_func from modules.utility import dev_func
enable_dev_func = False enable_dev_func = False
@ -93,7 +94,8 @@ async def main():
dev_func_result = dev_func(db_conn, enable_dev_func) dev_func_result = dev_func(db_conn, enable_dev_func)
globals.log(f"dev_func output: {dev_func_result}") globals.log(f"dev_func output: {dev_func_result}")
await asyncio.gather(discord_task, twitch_task) #await asyncio.gather(discord_task, twitch_task)
await asyncio.gather(discord_task)
if __name__ == "__main__": if __name__ == "__main__":
try: try:

View File

@ -87,14 +87,11 @@ def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str:
utility.wfstl() utility.wfstl()
db_conn = ctx.bot.db_conn db_conn = ctx.bot.db_conn
# random logic # Random logic for howl percentage
howl_val = random.randint(0, 100) 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
rounded_val = 0 if howl_val == 0 else \
100 if howl_val == 100 else \
(howl_val // 10) * 10
# dictionary-based reply # Dictionary-based reply
reply = utility.get_random_reply( reply = utility.get_random_reply(
"howl_replies", "howl_replies",
str(rounded_val), str(rounded_val),
@ -102,17 +99,22 @@ def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str:
howl_percentage=howl_val howl_percentage=howl_val
) )
# find user in DB by ID # Consistent UUID lookup
user_data = db.lookup_user(db_conn, identifier=author_id, identifier_type=platform) user_data = db.lookup_user(db_conn, identifier=author_id, identifier_type=f"{platform}_user_id")
if user_data: if user_data:
db.insert_howl(db_conn, user_data["UUID"], howl_val) user_uuid = user_data["UUID"]
db.insert_howl(db_conn, user_uuid, howl_val)
else: else:
globals.log(f"Could not find user by ID={author_id} on {platform}. Not storing howl.", "WARNING") globals.log(f"Could not find user by ID={author_id} on {platform}. Not storing howl.", "WARNING")
utility.wfetl() utility.wfetl()
return reply return reply
def handle_howl_stats(ctx, platform, target_name) -> str: def handle_howl_stats(ctx, platform, target_name) -> str:
"""
Handles !howl stats subcommand for both community and individual users.
"""
utility.wfstl() utility.wfstl()
db_conn = ctx.bot.db_conn db_conn = ctx.bot.db_conn
@ -122,7 +124,7 @@ def handle_howl_stats(ctx, platform, target_name) -> str:
if not stats: if not stats:
utility.wfetl() utility.wfetl()
return "No howls have been recorded yet!" return "No howls have been recorded yet!"
total_howls = stats["total_howls"] total_howls = stats["total_howls"]
avg_howl = stats["average_howl"] avg_howl = stats["average_howl"]
unique_users = stats["unique_users"] unique_users = stats["unique_users"]
@ -137,12 +139,13 @@ def handle_howl_stats(ctx, platform, target_name) -> str:
f"0% Howls: {count_zero}, 100% Howls: {count_hundred}") f"0% Howls: {count_zero}, 100% Howls: {count_hundred}")
# Otherwise, lookup a single user # Otherwise, lookup a single user
user_data = lookup_user_by_name(db_conn, platform, target_name) user_data = db.lookup_user(db_conn, identifier=target_name, identifier_type=f"{platform}_username")
if not user_data: if not user_data:
utility.wfetl() utility.wfetl()
return f"I don't know that user: {target_name}" return f"I don't know that user: {target_name}"
stats = db.get_howl_stats(db_conn, user_data["UUID"]) user_uuid = user_data["UUID"]
stats = db.get_howl_stats(db_conn, user_uuid)
if not stats: if not stats:
utility.wfetl() utility.wfetl()
return f"{target_name} hasn't howled yet! (Try `!howl` to get started.)" return f"{target_name} hasn't howled yet! (Try `!howl` to get started.)"
@ -156,12 +159,13 @@ def handle_howl_stats(ctx, platform, target_name) -> str:
f"(0% x{z}, 100% x{h})") f"(0% x{z}, 100% x{h})")
def lookup_user_by_name(db_conn, platform, name_str): def lookup_user_by_name(db_conn, platform, name_str):
""" """
Attempt to find a user by name on that platform, e.g. 'discord_username' or 'twitch_username'. Consistent UUID resolution for usernames across platforms.
""" """
utility.wfstl() utility.wfstl()
# same logic as before
if platform == "discord": if platform == "discord":
ud = db.lookup_user(db_conn, name_str, "discord_user_display_name") ud = db.lookup_user(db_conn, name_str, "discord_user_display_name")
if ud: if ud:
@ -170,6 +174,7 @@ def lookup_user_by_name(db_conn, platform, name_str):
ud = db.lookup_user(db_conn, name_str, "discord_username") ud = db.lookup_user(db_conn, name_str, "discord_username")
utility.wfetl() utility.wfetl()
return ud return ud
elif platform == "twitch": elif platform == "twitch":
ud = db.lookup_user(db_conn, name_str, "twitch_user_display_name") ud = db.lookup_user(db_conn, name_str, "twitch_user_display_name")
if ud: if ud:
@ -178,12 +183,14 @@ def lookup_user_by_name(db_conn, platform, name_str):
ud = db.lookup_user(db_conn, name_str, "twitch_username") ud = db.lookup_user(db_conn, name_str, "twitch_username")
utility.wfetl() utility.wfetl()
return ud return ud
else: else:
globals.log(f"Unknown platform {platform} in lookup_user_by_name", "WARNING") globals.log(f"Unknown platform '{platform}' in lookup_user_by_name", "WARNING")
utility.wfetl() utility.wfetl()
return None return None
def ping() -> str: def ping() -> str:
""" """
Returns a dynamic, randomized uptime response. Returns a dynamic, randomized uptime response.
@ -401,7 +408,7 @@ async def add_new_quote(db_conn, is_discord, ctx, quote_text, game_name: str = N
# Lookup UUID from users table # Lookup UUID from users table
user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id") user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id")
if not user_data: if not user_data:
globals.log(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not saved.", "ERROR") globals.log(f"Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not saved.", "ERROR")
utility.wfetl() utility.wfetl()
return "Could not save quote. Your user data is missing from the system." return "Could not save quote. Your user data is missing from the system."
@ -410,7 +417,7 @@ async def add_new_quote(db_conn, is_discord, ctx, quote_text, game_name: str = N
if is_discord or not game_name: if is_discord or not game_name:
game_name = None game_name = None
# Insert quote # Insert quote using UUID for QUOTEE
insert_sql = """ insert_sql = """
INSERT INTO quotes (QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED) INSERT INTO quotes (QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, 0) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, 0)
@ -428,6 +435,7 @@ async def add_new_quote(db_conn, is_discord, ctx, quote_text, game_name: str = N
return "Failed to add quote." return "Failed to add quote."
async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str): async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str):
""" """
Mark quote #ID as removed (QUOTE_REMOVED=1) and record removal datetime. Mark quote #ID as removed (QUOTE_REMOVED=1) and record removal datetime.
@ -443,7 +451,7 @@ async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str):
# Lookup UUID from users table # Lookup UUID from users table
user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id") user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id")
if not user_data: if not user_data:
globals.log(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not removed.", "ERROR") globals.log(f"Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not removed.", "ERROR")
utility.wfetl() utility.wfetl()
return "Could not remove quote. Your user data is missing from the system." return "Could not remove quote. Your user data is missing from the system."
@ -550,7 +558,7 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord):
# Lookup UUID from users table for the quoter # Lookup UUID from users table for the quoter
user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID") user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID")
if not user_data: if not user_data:
globals.log(f"ERROR: Could not find platform name for quotee UUID {quotee}. Default to 'Unknown'", "ERROR") globals.log(f"Could not find platform name for quotee UUID {quotee}. Default to 'Unknown'", "INFO")
quotee_display = "Unknown" quotee_display = "Unknown"
else: else:
quotee_display = user_data[f"{platform}_user_display_name"] quotee_display = user_data[f"{platform}_user_display_name"]
@ -559,7 +567,7 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord):
# Lookup UUID for removed_by if removed # Lookup UUID for removed_by if removed
removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID") removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID")
if not removed_user_data: if not removed_user_data:
globals.log(f"ERROR: Could not find platform name for remover UUID {quote_removed_by}. Default to 'Unknown'", "ERROR") globals.log(f"Could not find platform name for remover UUID {quote_removed_by}. Default to 'Unknown'", "INFO")
quote_removed_by_display = "Unknown" quote_removed_by_display = "Unknown"
else: else:
quote_removed_by_display = removed_user_data[f"{platform}_user_display_name"] quote_removed_by_display = removed_user_data[f"{platform}_user_display_name"]
@ -686,10 +694,11 @@ async def retrieve_quote_info(db_conn, ctx, quote_id, is_discord):
# Lookup display name for the quoter # Lookup display name for the quoter
user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID") user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID")
if not user_data: if not user_data:
globals.log(f"ERROR: Could not find display name for quotee UUID {quotee}.", "ERROR") globals.log(f"Could not find display name for quotee UUID {quotee}.", "INFO")
quotee_display = "Unknown" quotee_display = "Unknown"
else: else:
quotee_display = user_data.get(f"{platform}_user_display_name", "Unknown") # Use display name or fallback to platform username
quotee_display = user_data.get(f"platform_display_name", user_data.get("platform_username", "Unknown"))
info_lines = [] info_lines = []
info_lines.append(f"Quote #{quote_number} was quoted by {quotee_display} on {quote_datetime}.") info_lines.append(f"Quote #{quote_number} was quoted by {quotee_display} on {quote_datetime}.")
@ -703,10 +712,11 @@ async def retrieve_quote_info(db_conn, ctx, quote_id, is_discord):
if quote_removed_by: if quote_removed_by:
removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID") removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID")
if not removed_user_data: if not removed_user_data:
globals.log(f"ERROR: Could not find display name for remover UUID {quote_removed_by}.", "ERROR") globals.log(f"Could not find display name for remover UUID {quote_removed_by}.", "INFO")
quote_removed_by_display = "Unknown" quote_removed_by_display = "Unknown"
else: else:
quote_removed_by_display = removed_user_data.get(f"{platform}_user_display_name", "Unknown") # Use display name or fallback to platform username
quote_removed_by_display = removed_user_data.get(f"platform_display_name", removed_user_data.get("platform_username", "Unknown"))
else: else:
quote_removed_by_display = "Unknown" quote_removed_by_display = "Unknown"
removed_info = f"Removed by {quote_removed_by_display}" removed_info = f"Removed by {quote_removed_by_display}"
@ -784,10 +794,11 @@ async def search_quote(db_conn, keywords, is_discord, ctx):
# Lookup display name for quotee using UUID. # Lookup display name for quotee using UUID.
user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID") user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID")
if user_data: if user_data:
quotee_display = user_data.get(f"{'discord' if is_discord else 'twitch'}_user_display_name", "") # Use display name or fallback to platform username
quotee_display = user_data.get("platform_display_name", user_data.get("platform_username", "Unknown"))
else: else:
quotee_display = "" globals.log(f"Could not find display name for quotee UUID {quotee}.", "INFO")
quotee_display = "Unknown"
# For each keyword, check each field. # For each keyword, check each field.
# Award 2 points for a whole word match and 1 point for a partial match. # Award 2 points for a whole word match and 1 point for a partial match.
score_total = 0 score_total = 0

View File

@ -3,6 +3,7 @@ import json
import sys import sys
import traceback import traceback
import discord import discord
import inspect
# Store the start time globally. # Store the start time globally.
_bot_start_time = time.time() _bot_start_time = time.time()
@ -60,7 +61,8 @@ def log(message: str, level="INFO", exec_info=False, linebreaks=False):
Log a message with the specified log level. Log a message with the specified log level.
Capable of logging individual levels to the terminal and/or logfile separately. Capable of logging individual levels to the terminal and/or logfile separately.
Can also append traceback information if needed, and is capable of preserving/removing linebreaks from log messages as needed. Can also append traceback information if needed, and is capable of preserving/removing
linebreaks from log messages as needed. Also prepends the calling function name.
Args: Args:
message (str): The message to log. message (str): The message to log.
@ -83,58 +85,73 @@ def log(message: str, level="INFO", exec_info=False, linebreaks=False):
log("An error occured during processing", "ERROR", exec_info=True, linebreaks=False) log("An error occured during processing", "ERROR", exec_info=True, linebreaks=False)
""" """
# Initiate logfile # Hard-coded options/settings (can be expanded as needed)
lfp = config_data["logging"]["logfile_path"] # Log File Path default_level = "INFO" # Fallback log level
clfp = f"cur_{lfp}" # Current Log File Path allowed_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"}
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 !!!")
# Ensure valid level
if level not in allowed_levels:
level = default_level
# Capture the calling function's name using inspect
try:
caller_frame = inspect.stack()[1]
caller_name = caller_frame.function
except Exception:
caller_name = "Unknown"
# Optionally remove linebreaks if not desired
if not linebreaks:
message = message.replace("\n", " ")
# Get current timestamp and uptime
elapsed = time.time() - get_bot_start_time() # Assuming this function is defined elsewhere
from modules import utility from modules import utility
log_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"} uptime_str, _ = utility.format_uptime(elapsed)
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
if level not in log_levels: # Prepend dynamic details including the caller name
level = "INFO" # Default to INFO if an invalid level is provided log_message = f"[{timestamp} - {uptime_str}] [{level}] [Func: {caller_name}] {message}"
# Append traceback if required or for error levels
if exec_info or level in {"ERROR", "CRITICAL", "FATAL"}:
log_message += f"\n{traceback.format_exc()}"
# Read logging settings from the configuration
lfp = config_data["logging"]["logfile_path"] # Log File Path
clfp = f"cur_{lfp}" # Current Log File Path
if not (config_data["logging"]["terminal"]["log_to_terminal"] or
config_data["logging"]["file"]["log_to_file"]):
print("!!! WARNING !!! CONSOLE AND LOGFILE OUTPUT DISABLED !!!\n"
"!!! NO LOGS WILL BE PROVIDED !!!")
# Check if this log level is enabled (or if it's a FATAL message which always prints)
if level in config_data["logging"]["log_levels"] or level == "FATAL": if level in config_data["logging"]["log_levels"] or level == "FATAL":
elapsed = time.time() - get_bot_start_time() # Terminal output
uptime_str, _ = utility.format_uptime(elapsed)
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
log_message = f"[{timestamp} - {uptime_str}] [{level}] {message}"
# Include traceback for certain error levels
if exec_info or level in ["ERROR", "CRITICAL", "FATAL"]:
log_message += f"\n{traceback.format_exc()}"
# Print to terminal if enabled
# 'FATAL' errors override settings
# Checks config file to see enabled/disabled logging levels
if config_data["logging"]["terminal"]["log_to_terminal"] or level == "FATAL": if config_data["logging"]["terminal"]["log_to_terminal"] or level == "FATAL":
config_level_format = f"log_{level.lower()}" config_level_format = f"log_{level.lower()}"
if config_data["logging"]["terminal"][config_level_format] or level == "FATAL": if config_data["logging"]["terminal"].get(config_level_format, False) or level == "FATAL":
print(log_message) print(log_message)
# Write to file if enabled # File output
# 'FATAL' errors override settings
# Checks config file to see enabled/disabled logging levels
if config_data["logging"]["file"]["log_to_file"] or level == "FATAL": if config_data["logging"]["file"]["log_to_file"] or level == "FATAL":
config_level_format = f"log_{level.lower()}" config_level_format = f"log_{level.lower()}"
if config_data["logging"]["file"][config_level_format] or level == "FATAL": if config_data["logging"]["file"].get(config_level_format, False) or level == "FATAL":
try: try:
lfp = config_data["logging"]["logfile_path"] with open(lfp, "a", encoding="utf-8") as logfile:
clfp = f"cur_{lfp}"
with open(lfp, "a", encoding="utf-8") as logfile: # Write to permanent logfile
logfile.write(f"{log_message}\n") logfile.write(f"{log_message}\n")
logfile.flush() # Ensure it gets written immediately logfile.flush()
with open(clfp, "a", encoding="utf-8") as c_logfile: # Write to this-run logfile with open(clfp, "a", encoding="utf-8") as c_logfile:
c_logfile.write(f"{log_message}\n") c_logfile.write(f"{log_message}\n")
c_logfile.flush() # Ensure it gets written immediately c_logfile.flush()
except Exception as e: except Exception as e:
print(f"[WARNING] Failed to write to logfile: {e}") print(f"[WARNING] Failed to write to logfile: {e}")
# Handle fatal errors with shutdown # Handle fatal errors with shutdown
if level == "FATAL": if level == "FATAL":
print(f"!!! FATAL ERROR LOGGED, SHUTTING DOWN !!!") print("!!! FATAL ERROR LOGGED, SHUTTING DOWN !!!")
sys.exit(1) sys.exit(1)
def reset_curlogfile(): def reset_curlogfile():
""" """

View File

@ -78,7 +78,7 @@ def init_db_connection(config):
sqlite_path = db_settings.get("sqlite_path", "local_database.sqlite") sqlite_path = db_settings.get("sqlite_path", "local_database.sqlite")
try: try:
conn = sqlite3.connect(sqlite_path) conn = sqlite3.connect(sqlite_path)
globals.log(f"Database connection established using local SQLite: {sqlite_path}") globals.log(f"Database connection established using local SQLite: {sqlite_path}", "DEBUG")
return conn return conn
except sqlite3.Error as e: except sqlite3.Error as e:
globals.log(f"Could not open local SQLite database '{sqlite_path}': {e}", "WARNING") globals.log(f"Could not open local SQLite database '{sqlite_path}': {e}", "WARNING")
@ -257,82 +257,126 @@ def ensure_quotes_table(db_conn):
def ensure_users_table(db_conn): def ensure_users_table(db_conn):
""" """
Checks if 'users' table exists. If not, creates it. Checks if 'Users' table exists. If not, creates it.
The 'users' table tracks user linkage across platforms:
- UUID: (PK) The universal ID for the user
- discord_user_id, discord_username, discord_user_display_name
- twitch_user_id, twitch_username, twitch_user_display_name
- datetime_linked (DATE/TIME of row creation)
- user_is_banned (BOOLEAN)
- user_is_bot (BOOLEAN)
This helps unify data for a single 'person' across Discord & Twitch.
""" """
is_sqlite = "sqlite3" in str(type(db_conn)).lower() is_sqlite = "sqlite3" in str(type(db_conn)).lower()
# 1) Check existence
if is_sqlite: if is_sqlite:
check_sql = """ check_sql = """
SELECT name SELECT name
FROM sqlite_master FROM sqlite_master
WHERE type='table' WHERE type='table'
AND name='users' AND name='Users'
""" """
else: else:
check_sql = """ check_sql = """
SELECT table_name SELECT table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_name = 'users' WHERE table_name = 'Users'
AND table_schema = DATABASE() AND table_schema = DATABASE()
""" """
rows = run_db_operation(db_conn, "read", check_sql) rows = run_db_operation(db_conn, "read", check_sql)
if rows and rows[0] and rows[0][0]: if rows and rows[0] and rows[0][0]:
globals.log("Table 'users' already exists, skipping creation.", "DEBUG") globals.log("Table 'Users' already exists, skipping creation.", "DEBUG")
return return
# 2) Table does NOT exist => create it globals.log("Table 'Users' does not exist; creating now...", "INFO")
globals.log("Table 'users' does not exist; creating now...")
if is_sqlite: if is_sqlite:
create_table_sql = """ create_sql = """
CREATE TABLE users ( CREATE TABLE Users (
UUID TEXT PRIMARY KEY, UUID TEXT PRIMARY KEY,
discord_user_id TEXT, Unified_Username TEXT,
discord_username TEXT,
discord_user_display_name TEXT,
twitch_user_id TEXT,
twitch_username TEXT,
twitch_user_display_name TEXT,
datetime_linked TEXT, datetime_linked TEXT,
user_is_banned BOOLEAN DEFAULT 0, user_is_banned BOOLEAN DEFAULT 0,
user_is_bot BOOLEAN DEFAULT 0 user_is_bot BOOLEAN DEFAULT 0
) )
""" """
else: else:
create_table_sql = """ create_sql = """
CREATE TABLE users ( CREATE TABLE Users (
UUID VARCHAR(36) PRIMARY KEY, UUID VARCHAR(36) PRIMARY KEY,
discord_user_id VARCHAR(100), Unified_Username VARCHAR(100),
discord_username VARCHAR(100),
discord_user_display_name VARCHAR(100),
twitch_user_id VARCHAR(100),
twitch_username VARCHAR(100),
twitch_user_display_name VARCHAR(100),
datetime_linked DATETIME, datetime_linked DATETIME,
user_is_banned BOOLEAN DEFAULT FALSE, user_is_banned BOOLEAN DEFAULT FALSE,
user_is_bot BOOLEAN DEFAULT FALSE user_is_bot BOOLEAN DEFAULT FALSE
) )
""" """
result = run_db_operation(db_conn, "write", create_table_sql) result = run_db_operation(db_conn, "write", create_sql)
if result is None: if result is None:
error_msg = "Failed to create 'users' table!" error_msg = "Failed to create 'Users' table!"
globals.log(error_msg, "CRITICAL") globals.log(error_msg, "CRITICAL")
raise RuntimeError(error_msg) raise RuntimeError(error_msg)
globals.log("Successfully created table 'users'.") globals.log("Successfully created table 'Users'.", "INFO")
#######################
# Ensure 'platform_mapping' table
#######################
def ensure_platform_mapping_table(db_conn):
"""
Ensures the 'Platform_Mapping' table exists.
This table maps platform-specific user IDs to the universal UUID.
"""
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]:
globals.log("Table 'Platform_Mapping' already exists, skipping creation.", "DEBUG")
return
globals.log("Table 'Platform_Mapping' does not exist; creating now...", "INFO")
if is_sqlite:
create_sql = """
CREATE TABLE Platform_Mapping (
Platform_User_ID TEXT,
Platform_Type TEXT,
UUID TEXT,
Display_Name TEXT,
Username TEXT,
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),
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!"
globals.log(error_msg, "CRITICAL")
raise RuntimeError(error_msg)
globals.log("Successfully created table 'Platform_Mapping'.", "INFO")
######################## ########################
@ -341,141 +385,134 @@ def ensure_users_table(db_conn):
def lookup_user(db_conn, identifier: str, identifier_type: str, target_identifier: str = None): def lookup_user(db_conn, 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 using 'Platform_Mapping' for platform-specific IDs or UUID.
identifier_type can be:
- "discord_user_id" or "discord"
- "twitch_user_id" or "twitch"
- "UUID" (to lookup by UUID directly)
The accepted identifier_type values are:
- "uuid"
- "discord_user_id" or alias "discord"
- "discord_username"
- "discord_user_display_name"
- "twitch_user_id" or alias "twitch"
- "twitch_username"
- "twitch_user_display_name"
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: Returns:
If target_identifier is None: A dictionary with the following keys: If target_identifier is None: A dictionary with the following keys:
{ {
"UUID": str, "UUID": str,
"discord_user_id": str or None, "Unified_Username": str,
"discord_username": str or None, "datetime_linked": str,
"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_banned": bool or int,
"user_is_bot": 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 target_identifier is provided: The value from the record corresponding to that column.
If the lookup fails or the parameters are invalid: None. If the lookup fails or the parameters are invalid: None.
""" """
# Define the valid columns for lookup and for target extraction. # Debug: Log the inputs
valid_cols = [ globals.log(f"lookup_user() called with: identifier='{identifier}', identifier_type='{identifier_type}', target_identifier='{target_identifier}'", "DEBUG")
"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 globals.log:
globals.log(f"lookup_user error: invalid identifier_type '{identifier_type}'", "WARNING")
return None
# Convert shorthand identifier types to their full column names. # Define platform type and column mappings
if identifier_type.lower() == "discord": platform_map = {
identifier_type = "discord_user_id" "discord": "Discord",
elif identifier_type.lower() == "twitch": "discord_user_id": "Discord",
identifier_type = "twitch_user_id" "twitch": "Twitch",
"twitch_user_id": "Twitch"
}
# If a target_identifier is provided, validate that too. # Handle UUID case separately
if target_identifier is not None: if identifier_type.upper() == "UUID":
if target_identifier.lower() not in valid_cols: query = """
if globals.log: SELECT
globals.log(f"lookup_user error: invalid target_identifier '{target_identifier}'", "WARNING") 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 u.UUID = ?
LIMIT 1
"""
params = (identifier,)
else:
# Handle platform-specific lookups
if identifier_type.lower() not in platform_map:
globals.log(f"lookup_user error: invalid identifier_type '{identifier_type}'", "WARNING")
return None return None
# Build the query using the (now validated) identifier_type. # Get the platform type (Discord or Twitch)
query = f""" platform_type = platform_map[identifier_type.lower()]
SELECT
UUID,
discord_user_id,
discord_username,
discord_user_display_name,
twitch_user_id,
twitch_username,
twitch_user_display_name,
datetime_linked,
user_is_banned,
user_is_bot
FROM users
WHERE {identifier_type} = ?
LIMIT 1
"""
# Execute the database operation. Adjust run_db_operation() as needed. # Use platform_user_id to lookup the UUID
rows = run_db_operation(db_conn, "read", query, params=(identifier,)) query = """
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
INNER JOIN Platform_Mapping pm ON u.UUID = pm.UUID
WHERE pm.Platform_Type = ? AND pm.Platform_User_ID = ?
LIMIT 1
"""
params = (platform_type, identifier)
# Debug: Log the query and parameters
globals.log(f"lookup_user() executing query: {query} with params={params}", "DEBUG")
# Run the query
rows = run_db_operation(db_conn, "read", query, params)
# Debug: Log the result of the query
globals.log(f"lookup_user() query result: {rows}", "DEBUG")
# Handle no result case
if not rows: if not rows:
if globals.log: globals.log(f"lookup_user: No user found for {identifier_type}='{identifier}'", "WARNING")
globals.log(f"lookup_user: No user found for {identifier_type}='{identifier}'", "DEBUG")
return None return None
# Since we have a single row, convert it to a dictionary. # Convert the row to a dictionary
row = rows[0] row = rows[0]
user_data = { user_data = {
"UUID": row[0], "UUID": row[0], # Make UUID consistently uppercase
"discord_user_id": row[1], "unified_username": row[1],
"discord_username": row[2] if row[2] else f"{row[5]} (Discord unlinked)", "datetime_linked": row[2],
"discord_user_display_name": row[3] if row[3] else f"{row[6]} (Discord unlinked)", "user_is_banned": row[3],
"twitch_user_id": row[4], "user_is_bot": row[4],
"twitch_username": row[5] if row[5] else f"{row[2]} (Twitch unlinked)", "platform_user_id": row[5],
"twitch_user_display_name": row[6] if row[6] else f"{row[3]} (Twitch unlinked)", "platform_display_name": row[6],
"datetime_linked": row[7], "platform_username": row[7],
"user_is_banned": row[8], "platform_type": row[8]
"user_is_bot": row[9],
} }
# If the caller requested a specific target column, return that value. # Debug: Log the constructed user data
globals.log(f"lookup_user() constructed user_data: {user_data}", "DEBUG")
# If target_identifier is provided, return just that value
if target_identifier: if target_identifier:
# Adjust for potential alias: if target_identifier is an alias, target_identifier = target_identifier.upper() # Force uppercase for consistency
# 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 usernames are Null, default to that of the opposite platform
# if not user_data['discord_username']:
# user_data['discord_username'] = f"{user_data['twitch_username']} (Discord unlinked)"
# elif not user_data['twitch_username']:
# user_data['twitch_username'] = f"{user_data['discord_username']} (Twitch unlinked)"
# if not user_data['discord_user_display_name']:
# user_data['discord_user_display_name'] = f"{user_data['twitch_user_display_name']} (Discord unlinked)"
# elif not user_data['twitch_user_display_name']:
# user_data['twitch_user_display_name'] = f"{user_data['discord_user_display_name']} (Twitch unlinked)"
if target_identifier in user_data: if target_identifier in user_data:
globals.log(f"lookup_user() returning target_identifier='{target_identifier}' with value='{user_data[target_identifier]}'", "DEBUG")
return user_data[target_identifier] return user_data[target_identifier]
else: else:
if globals.log: globals.log(f"lookup_user error: target_identifier '{target_identifier}' not found in user_data. Available keys: {list(user_data.keys())}", "WARNING")
globals.log(f"lookup_user error: target_identifier '{target_identifier}' not present in user data", "WARNING")
return None return None
# Otherwise, return the full user record. globals.log(f"lookup_user() returning full user_data: {user_data}", "DEBUG")
return user_data return user_data
def ensure_chatlog_table(db_conn): def ensure_chatlog_table(db_conn):
""" """
Checks if 'chat_log' table exists. If not, creates it. Checks if 'chat_log' table exists. If not, creates it.
@ -555,17 +592,17 @@ def ensure_chatlog_table(db_conn):
globals.log("Successfully created table 'chat_log'.", "INFO") globals.log("Successfully created table 'chat_log'.", "INFO")
def log_message(db_conn, user_uuid, message_content, platform, channel, attachments=None, username: str = "Unknown"): def log_message(db_conn, identifier, identifier_type, message_content, platform, channel, attachments=None):
""" """
Inserts a row into 'chat_log' with the given fields. Logs a message in 'chat_log' with UUID fetched using the new Platform_Mapping structure.
user_uuid: The user's UUID from the 'users' table (string). """
message_content: The text of the message. # Get UUID using the updated lookup_user
platform: 'twitch' or discord server name, etc. user_data = lookup_user(db_conn, identifier, identifier_type)
channel: The channel name (Twitch channel, or Discord channel). if not user_data:
attachments: Optional string of hyperlinks if available. globals.log(f"User not found for {identifier_type}='{identifier}'", "WARNING")
return
DATETIME will default to current timestamp in the DB. user_uuid = user_data["UUID"]
"""
if attachments is None or not "https://" in attachments: if attachments is None or not "https://" in attachments:
attachments = "" attachments = ""
@ -577,14 +614,13 @@ def log_message(db_conn, user_uuid, message_content, platform, channel, attachme
PLATFORM, PLATFORM,
CHANNEL, CHANNEL,
ATTACHMENTS ATTACHMENTS
) ) VALUES (?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?)
""" """
params = (user_uuid, message_content, platform, channel, attachments) params = (user_uuid, message_content, platform, channel, attachments)
rowcount = run_db_operation(db_conn, "write", insert_sql, params) rowcount = run_db_operation(db_conn, "write", insert_sql, params)
if rowcount and rowcount > 0: if rowcount and rowcount > 0:
globals.log(f"Logged message for UUID={user_uuid} ({username}) in 'chat_log'.", "DEBUG") globals.log(f"Logged message for UUID={user_uuid}.", "DEBUG")
else: else:
globals.log("Failed to log message in 'chat_log'.", "ERROR") globals.log("Failed to log message in 'chat_log'.", "ERROR")
@ -791,36 +827,29 @@ def ensure_discord_activity_table(db_conn):
globals.log("Successfully created table 'discord_activity'.", "INFO") globals.log("Successfully created table 'discord_activity'.", "INFO")
def log_discord_activity(db_conn, guild_id, user_uuid, action, voice_channel, action_detail=None): def log_discord_activity(db_conn, guild_id, user_identifier, action, voice_channel, action_detail=None):
""" """
Logs Discord activities (playing games, listening to Spotify, streaming). Logs Discord activities with duplicate detection to prevent redundant logs.
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.
""" """
# Resolve UUID using the new Platform_Mapping
user_data = lookup_user(db_conn, user_identifier, identifier_type="discord_user_id")
if not user_data:
globals.log(f"User not found for Discord ID: {user_identifier}", "WARNING")
return
user_uuid = user_data["UUID"]
# Prepare the voice_channel value (if its 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): def normalize_detail(detail):
"""Return a normalized version of the detail for comparison (or None if detail is None).""" """Normalize detail for comparison (lowercase, stripped of whitespace)."""
return detail.strip().lower() if detail else 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) DUPLICATE_THRESHOLD = datetime.timedelta(minutes=5)
# How many recent events to check.
NUM_RECENT_ENTRIES = 5 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,)
)
if not user_check:
globals.log(f"WARNING: Attempted to log activity for non-existent UUID: {user_uuid}", "WARNING")
return # Prevent foreign key issues.
now = datetime.datetime.now() now = datetime.datetime.now()
normalized_new = normalize_detail(action_detail) normalized_new = normalize_detail(action_detail)
@ -832,15 +861,9 @@ def log_discord_activity(db_conn, guild_id, user_uuid, action, voice_channel, ac
ORDER BY DATETIME DESC ORDER BY DATETIME DESC
LIMIT ? LIMIT ?
""" """
rows = run_db_operation( rows = run_db_operation(db_conn, "read", query, params=(user_uuid, action, NUM_RECENT_ENTRIES))
db_conn, "read", query, params=(user_uuid, action, NUM_RECENT_ENTRIES)
)
# 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.
last_same, last_different = None, None
for row in rows: for row in rows:
dt_str, detail = row dt_str, detail = row
try: try:
@ -850,41 +873,37 @@ def log_discord_activity(db_conn, guild_id, user_uuid, action, voice_channel, ac
continue continue
normalized_existing = normalize_detail(detail) normalized_existing = normalize_detail(detail)
if normalized_existing == normalized_new: if normalized_existing == normalized_new:
# Record the most recent matching event.
if last_same is None or dt > last_same: if last_same is None or dt > last_same:
last_same = dt last_same = dt
else: else:
# Record the most recent non-matching event.
if last_different is None or dt > last_different: if last_different is None or dt > last_different:
last_different = dt last_different = dt
# Decide whether to skip logging: # Check duplicate conditions
# 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_same is not None:
if (last_different is None) or (last_same > last_different): if (last_different is None) or (last_same > last_different):
if now - last_same > DUPLICATE_THRESHOLD: if now - last_same < DUPLICATE_THRESHOLD:
#log_func(f"Duplicate {action} event for user {user_uuid} (detail '{action_detail}') within threshold; skipping log.","DEBUG") globals.log(f"Duplicate {action} event for {user_uuid} within threshold; skipping log.", "DEBUG")
return return
# Prepare the voice_channel value (if its an object with a name, use that). # Insert the new event
channel_val = voice_channel.name if (voice_channel and hasattr(voice_channel, "name")) else voice_channel insert_sql = """
# Insert the new event.
sql = """
INSERT INTO discord_activity (UUID, ACTION, GUILD_ID, VOICE_CHANNEL, ACTION_DETAIL) INSERT INTO discord_activity (UUID, ACTION, GUILD_ID, VOICE_CHANNEL, ACTION_DETAIL)
VALUES (?, ?, ?, ?, ?) 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) params = (user_uuid, action, guild_id, channel_val, action_detail)
rowcount = run_db_operation(db_conn, "write", sql, params) rowcount = run_db_operation(db_conn, "write", insert_sql, params)
if rowcount and rowcount > 0: if rowcount and rowcount > 0:
detail_str = f" ({action_detail})" if action_detail else "" detail_str = f" ({action_detail})" if action_detail else ""
globals.log(f"Logged Discord activity in Guild {guild_id}: {action}{detail_str}", "DEBUG") globals.log(f"Logged Discord activity for UUID={user_uuid} in Guild {guild_id}: {action}{detail_str}", "DEBUG")
else: else:
globals.log("Failed to log Discord activity.", "ERROR") globals.log("Failed to log Discord activity.", "ERROR")
def ensure_bot_events_table(db_conn): def ensure_bot_events_table(db_conn):
""" """
Ensures the 'bot_events' table exists, which logs major bot-related events. Ensures the 'bot_events' table exists, which logs major bot-related events.
@ -1033,35 +1052,36 @@ def ensure_link_codes_table(db_conn):
def merge_uuid_data(db_conn, old_uuid, new_uuid): def merge_uuid_data(db_conn, old_uuid, new_uuid):
""" """
Merges all records from the old UUID (Twitch account) into the new UUID (Discord account). Merges data from old UUID to new UUID, updating references in Platform_Mapping.
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.
""" """
globals.log(f"Starting UUID merge: {old_uuid} -> {new_uuid}", "INFO") globals.log(f"Merging UUID data: {old_uuid} -> {new_uuid}", "INFO")
# 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 = [ tables_to_update = [
"voice_activity_log",
"bot_events",
"chat_log", "chat_log",
"user_howls", "user_howls",
"quotes" "discord_activity",
"community_events"
] ]
for table in tables_to_update: for table in tables_to_update:
sql = f"UPDATE {table} SET UUID = ? WHERE UUID = ?" sql = f"UPDATE {table} SET UUID = ? WHERE UUID = ?"
rowcount = run_db_operation(db_conn, "update", sql, (new_uuid, old_uuid)) rowcount = run_db_operation(db_conn, "update", sql, (new_uuid, old_uuid))
globals.log(f"Updated {rowcount} rows in {table} (transferring {old_uuid} -> {new_uuid})", "DEBUG") globals.log(f"Updated {rowcount} rows in {table} (transferred {old_uuid} -> {new_uuid})", "DEBUG")
# Finally, delete the old UUID from the `users` table # Finally, delete the old UUID from Users table
delete_sql = "DELETE FROM users WHERE UUID = ?" delete_sql = "DELETE FROM Users WHERE UUID = ?"
rowcount = run_db_operation(db_conn, "write", delete_sql, (old_uuid,)) rowcount = run_db_operation(db_conn, "write", delete_sql, (old_uuid,))
globals.log(f"Deleted old UUID {old_uuid} from 'Users' table ({rowcount} rows affected)", "INFO")
globals.log(f"Deleted old UUID {old_uuid} from 'users' table ({rowcount} rows affected)", "INFO")
globals.log(f"UUID merge complete: {old_uuid} -> {new_uuid}", "INFO") globals.log(f"UUID merge complete: {old_uuid} -> {new_uuid}", "INFO")
def ensure_community_events_table(db_conn): def ensure_community_events_table(db_conn):
""" """
Checks if 'community_events' table exists. If not, attempts to create it. Checks if 'community_events' table exists. If not, attempts to create it.
@ -1126,21 +1146,9 @@ def ensure_community_events_table(db_conn):
async def handle_community_event(db_conn, is_discord, ctx, args): async def handle_community_event(db_conn, is_discord, ctx, args):
""" """
Handles community event commands. Handles community event commands including add, info, list, and search.
Accepted subcommands (args[0] if provided):
- add <event_type> [event_details [|| event_extras]]
-> Logs a new event.
- info <event_id>
-> Retrieves detailed information for a given event.
- list [limit]
-> Lists recent events (default limit 5 if not specified).
- search <keyword...>
-> Searches events (by EVENT_TYPE or EVENT_DETAILS).
If no arguments are provided, defaults to listing recent events.
""" """
from modules import db # Assumes your db module is available
if len(args) == 0: if len(args) == 0:
args = ["list"] args = ["list"]
@ -1149,12 +1157,12 @@ async def handle_community_event(db_conn, is_discord, ctx, args):
if sub == "add": if sub == "add":
if len(args) < 2: if len(args) < 2:
return "Please provide the event type after 'add'." return "Please provide the event type after 'add'."
event_type = args[1] event_type = args[1]
# Concatenate remaining args as event details (if any)
event_details = " ".join(args[2:]).strip() if len(args) > 2 else None event_details = " ".join(args[2:]).strip() if len(args) > 2 else None
# Optional: If you want to support extras, you might separate event_details and extras using "||"
event_extras = None event_extras = None
# Support extras using "||" separator
if event_details and "||" in event_details: if event_details and "||" in event_details:
parts = event_details.split("||", 1) parts = event_details.split("||", 1)
event_details = parts[0].strip() event_details = parts[0].strip()
@ -1162,28 +1170,28 @@ async def handle_community_event(db_conn, is_discord, ctx, args):
platform = "Discord" if is_discord else "Twitch" platform = "Discord" if is_discord else "Twitch"
user_id = str(ctx.author.id) user_id = str(ctx.author.id)
# Lookup user data to get UUID (similar to your quote logic)
user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform.lower()}_user_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: if not user_data:
globals.log(f"Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}.", "ERROR") globals.log(f"User not found: {ctx.author.name} ({user_id}) on {platform}", "ERROR")
return "Could not log event: user data missing." return "Could not log event: user data missing."
user_uuid = user_data["UUID"] user_uuid = user_data["UUID"]
# Insert new event. Use appropriate parameter placeholders. # Insert new event. Adjust for SQLite or MariaDB.
if "sqlite3" in str(type(db_conn)).lower(): insert_sql = """
insert_sql = """ INSERT INTO community_events
INSERT INTO community_events (EVENT_PLATFORM, EVENT_TYPE, EVENT_DETAILS, EVENT_USER, DATETIME, EVENT_EXTRAS)
(EVENT_PLATFORM, EVENT_TYPE, EVENT_DETAILS, EVENT_USER, DATETIME, EVENT_EXTRAS) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?) """ if "sqlite3" in str(type(db_conn)).lower() else """
""" INSERT INTO community_events
else: (EVENT_PLATFORM, EVENT_TYPE, EVENT_DETAILS, EVENT_USER, DATETIME, EVENT_EXTRAS)
insert_sql = """ VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP, %s)
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) params = (platform, event_type, event_details, user_uuid, event_extras)
result = db.run_db_operation(db_conn, "write", insert_sql, params) result = run_db_operation(db_conn, "write", insert_sql, params)
if result is not None: if result is not None:
globals.log(f"New event added: {event_type} by {ctx.author.name}", "DEBUG") globals.log(f"New event added: {event_type} by {ctx.author.name}", "DEBUG")
return f"Successfully logged event: {event_type}" return f"Successfully logged event: {event_type}"
@ -1197,13 +1205,11 @@ async def handle_community_event(db_conn, is_discord, ctx, args):
select_sql = "SELECT * FROM community_events WHERE EVENT_ID = ?" select_sql = "SELECT * FROM community_events WHERE EVENT_ID = ?"
if "sqlite3" not in str(type(db_conn)).lower(): if "sqlite3" not in str(type(db_conn)).lower():
select_sql = "SELECT * FROM community_events WHERE EVENT_ID = %s" select_sql = "SELECT * FROM community_events WHERE EVENT_ID = %s"
rows = db.run_db_operation(db_conn, "read", select_sql, (event_id,)) rows = run_db_operation(db_conn, "read", select_sql, (event_id,))
if not rows: if not rows:
return f"No event found with ID {event_id}." return f"No event found with ID {event_id}."
row = rows[0] row = rows[0]
# row indices: 0: EVENT_ID, 1: EVENT_PLATFORM, 2: EVENT_TYPE, 3: EVENT_DETAILS, return (
# 4: EVENT_USER, 5: DATETIME, 6: EVENT_EXTRAS
resp = (
f"Event #{row[0]}:\n" f"Event #{row[0]}:\n"
f"Platform: {row[1]}\n" f"Platform: {row[1]}\n"
f"Type: {row[2]}\n" f"Type: {row[2]}\n"
@ -1212,47 +1218,6 @@ async def handle_community_event(db_conn, is_discord, ctx, args):
f"Datetime: {row[5]}\n" f"Datetime: {row[5]}\n"
f"Extras: {row[6] or 'N/A'}" f"Extras: {row[6] or 'N/A'}"
) )
return resp
elif sub == "list":
# Optional limit argument (default 5)
limit = 5
if len(args) >= 2 and args[1].isdigit():
limit = int(args[1])
select_sql = f"SELECT * FROM community_events ORDER BY DATETIME DESC LIMIT {limit}"
rows = db.run_db_operation(db_conn, "read", select_sql)
if not rows:
return "No events logged yet."
resp_lines = []
for row in rows:
# Display basic info: ID, Type, Platform, Datetime
resp_lines.append(f"#{row[0]}: {row[2]} on {row[1]} at {row[5]}")
return "\n".join(resp_lines)
elif sub == "search":
if len(args) < 2:
return "Please provide keywords to search for."
keywords = " ".join(args[1:])
like_pattern = f"%{keywords}%"
search_sql = """
SELECT * FROM community_events
WHERE EVENT_TYPE LIKE ? OR EVENT_DETAILS LIKE ?
ORDER BY DATETIME DESC LIMIT 5
"""
if "sqlite3" not in str(type(db_conn)).lower():
search_sql = """
SELECT * FROM community_events
WHERE EVENT_TYPE LIKE %s OR EVENT_DETAILS LIKE %s
ORDER BY DATETIME DESC LIMIT 5
"""
rows = db.run_db_operation(db_conn, "read", search_sql, (like_pattern, like_pattern))
if not rows:
return "No matching events found."
resp_lines = []
for row in rows:
resp_lines.append(f"#{row[0]}: {row[2]} on {row[1]} at {row[5]}")
return "\n".join(resp_lines)
else: else:
# Unknown subcommand; default to listing recent events. return await handle_community_event(db_conn, is_discord, ctx, ["list"])
return await handle_community_event_command(db_conn, is_discord, ctx, ["list"])

View File

@ -723,14 +723,14 @@ def track_user_activity(
globals.log(f"... Returned {user_data}", "DEBUG") globals.log(f"... Returned {user_data}", "DEBUG")
if platform.lower() == "discord": if platform.lower() == "discord":
if user_data["discord_username"] != username: if user_data["platform_username"] != username:
need_update = True need_update = True
column_updates.append("discord_username = ?") column_updates.append("platform_username = ?")
params.append(username) params.append(username)
if user_data["discord_user_display_name"] != display_name: if user_data["platform_display_name"] != display_name:
need_update = True need_update = True
column_updates.append("discord_user_display_name = ?") column_updates.append("platform_display_name = ?")
params.append(display_name) params.append(display_name)
if user_data["user_is_bot"] != user_is_bot: if user_data["user_is_bot"] != user_is_bot:
@ -752,14 +752,14 @@ def track_user_activity(
globals.log(f"Updated Discord user '{username}' (display '{display_name}') in 'users'.", "DEBUG") globals.log(f"Updated Discord user '{username}' (display '{display_name}') in 'users'.", "DEBUG")
elif platform.lower() == "twitch": elif platform.lower() == "twitch":
if user_data["twitch_username"] != username: if user_data["platform_username"] != username:
need_update = True need_update = True
column_updates.append("twitch_username = ?") column_updates.append("platform_username = ?")
params.append(username) params.append(username)
if user_data["twitch_user_display_name"] != display_name: if user_data["platform_display_name"] != display_name:
need_update = True need_update = True
column_updates.append("twitch_user_display_name = ?") column_updates.append("platform_display_name = ?")
params.append(display_name) params.append(display_name)
if user_data["user_is_bot"] != user_is_bot: if user_data["user_is_bot"] != user_is_bot:
@ -789,8 +789,8 @@ def track_user_activity(
INSERT INTO users ( INSERT INTO users (
UUID, UUID,
discord_user_id, discord_user_id,
discord_username, platform_username,
discord_user_display_name, platform_display_name,
user_is_bot user_is_bot
) )
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
@ -801,8 +801,8 @@ def track_user_activity(
INSERT INTO users ( INSERT INTO users (
UUID, UUID,
twitch_user_id, twitch_user_id,
twitch_username, platform_username,
twitch_user_display_name, platform_display_name,
user_is_bot user_is_bot
) )
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)