mini-vacation update

- Dual logging
  - logfile.log = permanent logfile
  - cur_logfile.log = current run logfile
- Improved logging in general
- Expanded howl replies
- Discord activity logging
- Twitch & Discord chat logging
- Discord slash commands implementation (partial)
- Config file improvements
  - Toggleable log levels
  - Settings separated (terminal and file output)
  - Nesting of associated values
- Fixed "!ping" not fetching correct replies
- Several other minor and major fixes, tweaks and improvements
kami_dev
Kami 2025-02-10 12:32:30 +01:00
parent aed3d24e33
commit 3ad6504d69
11 changed files with 2009 additions and 296 deletions

View File

@ -1,11 +1,13 @@
# bot_discord.py
import discord
from discord import app_commands
from discord.ext import commands
import importlib
import cmd_discord
import modules
import modules.utility
from modules.db import log_message, lookup_user, log_bot_event
class DiscordBot(commands.Bot):
def __init__(self, config, log_func):
@ -19,8 +21,17 @@ class DiscordBot(commands.Bot):
self.log("Discord bot initiated")
cmd_class = str(type(self.commands)).split("'", 2)[1]
log_func(f"DiscordBot.commands type: {cmd_class}", "DEBUG")
# async def sync_slash_commands(self):
# """Syncs slash commands for the bot."""
# await self.wait_until_ready()
# try:
# await self.tree.sync()
# primary_guild = discord.Object(id=int(self.config["discord_guilds"][0]))
# await self.tree.sync(guild=primary_guild)
# self.log("Discord slash commands synced.")
# except Exception as e:
# self.log(f"Unable to sync Discord slash commands: {e}", "ERROR")
def set_db_connection(self, db_conn):
"""
@ -33,31 +44,288 @@ class DiscordBot(commands.Bot):
Load all commands from cmd_discord.py
"""
try:
importlib.reload(cmd_discord)
cmd_discord.setup(self)
importlib.reload(cmd_discord) # Reload the commands file
cmd_discord.setup(self) # Ensure commands are registered
self.log("Discord commands loaded successfully.")
# Now load the help info from dictionary/help_discord.json
# Load help info
help_json_path = "dictionary/help_discord.json"
modules.utility.initialize_help_data(
bot=self,
help_json_path=help_json_path,
is_discord=True,
log_func=self.log
)
except Exception as e:
self.log(f"Error loading Discord commands: {e}", "ERROR")
async def on_message(self, message):
self.log(f"Message detected, attempting UUI lookup on {message.author.name} ...", "DEBUG")
try:
# If it's a bot message, ignore or pass user_is_bot=True
is_bot = message.author.bot
user_id = str(message.author.id)
user_name = message.author.name # no discriminator
display_name = message.author.display_name
modules.utility.track_user_activity(
db_conn=self.db_conn,
log_func=self.log,
platform="discord",
user_id=user_id,
username=user_name,
display_name=display_name,
user_is_bot=is_bot
)
self.log(f"... UUI lookup complete", "DEBUG")
user_data = lookup_user(db_conn=self.db_conn, log_func=self.log, identifier=user_id, identifier_type="discord_user_id")
user_uuid = user_data["UUID"] if user_data else "UNKNOWN"
if user_uuid:
# The "platform" can be e.g. "discord" or you can store the server name
platform_str = f"discord-{message.guild.name}" if message.guild else "discord-DM"
# The channel name can be message.channel.name or "DM" if it's a private channel
channel_str = message.channel.name if hasattr(message.channel, "name") else "DM"
# If you have attachments, you could gather them as links.
try:
attachments = ", ".join(a.url for a in message.attachments) if message.attachments else ""
except Exception:
attachments = ""
log_message(
db_conn=self.db_conn,
log_func=self.log,
user_uuid=user_uuid,
message_content=message.content or "",
platform=platform_str,
channel=channel_str,
attachments=attachments
)
# PLACEHOLDER FOR FUTURE MESSAGE PROCESSING
except Exception as e:
self.log(f"... UUI lookup failed: {e}", "WARNING")
pass
try:
# Pass message contents to commands processing
await self.process_commands(message)
self.log(f"Command processing complete", "DEBUG")
except Exception as e:
self.log(f"Command processing failed: {e}", "ERROR")
async def on_command(self, ctx):
"""Logs every command execution at DEBUG level."""
_cmd_args = str(ctx.message.content).split(" ")[1:]
self.log(f"Command '{ctx.command}' (Discord) initiated by {ctx.author} in #{ctx.channel}", "DEBUG")
channel_name = "Direct Message" if "Direct Message with" in str(ctx.channel) else ctx.channel
self.log(f"Command '{ctx.command}' (Discord) initiated by {ctx.author} in #{channel_name}", "DEBUG")
if len(_cmd_args) > 1: self.log(f"!{ctx.command} arguments: {_cmd_args}", "DEBUG")
async def on_ready(self):
"""Runs when the bot successfully logs in."""
# Sync Slash Commands
try:
# Sync slash commands globally
#await self.tree.sync()
#self.log("Discord slash commands synced.")
primary_guild_int = int(self.config["discord_guilds"][0])
primary_guild = discord.Object(id=primary_guild_int)
await self.tree.sync(guild=primary_guild)
self.log(f"Discord slash commands force synced to guild: {primary_guild_int}")
except Exception as e:
self.log(f"Unable to sync Discord slash commands: {e}")
# Log successful bot startup
self.log(f"Discord bot is online as {self.user}")
log_bot_event(self.db_conn, self.log, "DISCORD_RECONNECTED", "Discord bot logged in.")
async def on_disconnect(self):
self.log("Discord bot has lost connection!", "WARNING")
log_bot_event(self.db_conn, self.log, "DISCORD_DISCONNECTED", "Discord bot lost connection.")
async def on_voice_state_update(self, member, before, after):
"""
Tracks user joins, leaves, mutes, deafens, streams, and voice channel moves.
"""
guild_id = str(member.guild.id)
discord_user_id = str(member.id)
voice_channel = after.channel.name if after.channel else before.channel.name if before.channel else None
# Ensure user exists in the UUI system
user_uuid = modules.db.lookup_user(self.db_conn, self.log, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID")
if not user_uuid:
self.log(f"User {member.name} ({discord_user_id}) not found in 'users'. Attempting to add...", "WARNING")
modules.utility.track_user_activity(
db_conn=self.db_conn,
log_func=self.log,
platform="discord",
user_id=discord_user_id,
username=member.name,
display_name=member.display_name,
user_is_bot=member.bot
)
user_uuid= modules.db.lookup_user(self.db_conn, self.log, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID")
if not user_uuid:
self.log(f"ERROR: Failed to associate {member.name} ({discord_user_id}) with a UUID. Skipping activity log.", "ERROR")
return # Prevent logging with invalid UUID
# Detect join and leave events
if before.channel is None and after.channel is not None:
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, "JOIN", after.channel.name)
elif before.channel is not None and after.channel is None:
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, "LEAVE", before.channel.name)
# Detect VC moves (self/moved)
if before.channel and after.channel and before.channel != after.channel:
move_detail = f"{before.channel.name} -> {after.channel.name}"
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, "VC_MOVE", after.channel.name, move_detail)
# Detect mute/unmute
if before.self_mute != after.self_mute:
mute_action = "MUTE" if after.self_mute else "UNMUTE"
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, mute_action, voice_channel)
# Detect deafen/undeafen
if before.self_deaf != after.self_deaf:
deaf_action = "DEAFEN" if after.self_deaf else "UNDEAFEN"
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, deaf_action, voice_channel)
# Detect streaming
if before.self_stream != after.self_stream:
stream_action = "STREAM_START" if after.self_stream else "STREAM_STOP"
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, stream_action, voice_channel)
# Detect camera usage
if before.self_video != after.self_video:
camera_action = "CAMERA_ON" if after.self_video else "CAMERA_OFF"
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, camera_action, voice_channel)
async def on_presence_update(self, before, after):
"""
Detects when a user starts or stops a game, Spotify, or Discord activity.
Ensures the activity is logged using the correct UUID from the UUI system.
"""
if not after.guild: # Ensure it's in a guild (server)
return
guild_id = str(after.guild.id)
discord_user_id = str(after.id)
# Ensure user exists in the UUI system
user_uuid = modules.db.lookup_user(
self.db_conn,
self.log,
identifier=discord_user_id,
identifier_type="discord_user_id",
target_identifier="UUID"
)
if not user_uuid:
self.log(f"User {after.name} ({discord_user_id}) not found in 'users'. Attempting to add...", "WARNING")
modules.utility.track_user_activity(
db_conn=self.db_conn,
log_func=self.log,
platform="discord",
user_id=discord_user_id,
username=after.name,
display_name=after.display_name,
user_is_bot=after.bot
)
user_uuid = modules.db.lookup_user(self.db_conn, self.log, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID")
if not user_uuid:
self.log(f"ERROR: Failed to associate {after.name} ({discord_user_id}) with a UUID. Skipping activity log.", "ERROR")
return
# Check all activities
new_activity = None
for n_activity in after.activities:
if isinstance(n_activity, discord.Game):
new_activity = ("GAME_START", n_activity.name)
elif isinstance(n_activity, discord.Spotify):
# Get artist name(s) and format as "{artist_name} - {song_title}"
artist_name = ", ".join(n_activity.artists)
song_name = n_activity.title
spotify_detail = f"{artist_name} - {song_name}"
new_activity = ("LISTENING_SPOTIFY", spotify_detail)
elif isinstance(n_activity, discord.Streaming):
new_activity = ("STREAM_START", n_activity.game or "Sharing screen")
# Check all activities
old_activity = None
for o_activity in before.activities:
if isinstance(o_activity, discord.Game):
old_activity = ("GAME_STOP", o_activity.name)
# IGNORE OLD SPOTIFY EVENTS
elif isinstance(o_activity, discord.Streaming):
old_activity = ("STREAM_STOP", o_activity.game or "Sharing screen")
if new_activity:
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, new_activity[0], None, new_activity[1])
if old_activity:
modules.db.log_discord_activity(self.db_conn, self.log, guild_id, user_uuid, old_activity[0], None, old_activity[1])
# async def start_account_linking(self, interaction: discord.Interaction):
# """Starts the linking process by generating a link code and displaying instructions."""
# user_id = str(interaction.user.id)
# # Check if the user already has a linked account
# user_data = modules.db.lookup_user(self.db_conn, self.log, identifier=user_id, identifier_type="discord_user_id")
# if user_data and user_data["twitch_user_id"]:
# link_date = user_data["datetime_linked"]
# await interaction.response.send_message(
# f"Your Discord account is already linked to Twitch user **{user_data['twitch_user_display_name']}** "
# f"(linked on {link_date}). You must remove the link before linking another account.", ephemeral=True)
# return
# # Generate a unique link code
# link_code = modules.utility.generate_link_code()
# modules.db.run_db_operation(
# self.db_conn, "write",
# "INSERT INTO link_codes (DISCORD_USER_ID, LINK_CODE) VALUES (?, ?)",
# (user_id, link_code), self.log
# )
# # Show the user the link modal
# await interaction.response.send_message(
# f"To link your Twitch account, post the following message in Twitch chat:\n"
# f"`!acc_link {link_code}`\n\n"
# f"Then, return here and click 'Done'.", ephemeral=True
# )
# async def finalize_account_linking(self, interaction: discord.Interaction):
# """Finalizes the linking process by merging duplicate UUIDs."""
# from modules import db
# user_id = str(interaction.user.id)
# # Fetch the updated user info
# user_data = modules.db.lookup_user(self.db_conn, self.log, identifier=user_id, identifier_type="discord_user_id")
# if not user_data or not user_data["twitch_user_id"]:
# await interaction.response.send_message(
# "No linked Twitch account found. Please complete the linking process first.", ephemeral=True)
# return
# discord_uuid = user_data["UUID"]
# twitch_uuid = modules.db.lookup_user(self.db_conn, self.log, identifier=user_data["twitch_user_id"], identifier_type="twitch_user_id")["UUID"]
# if discord_uuid == twitch_uuid:
# await interaction.response.send_message("Your accounts are already fully linked.", ephemeral=True)
# return
# # Merge all records from `twitch_uuid` into `discord_uuid`
# db.merge_uuid_data(self.db_conn, self.log, old_uuid=twitch_uuid, new_uuid=discord_uuid)
# # Delete the old Twitch UUID entry
# db.run_db_operation(self.db_conn, "write", "DELETE FROM users WHERE UUID = ?", (twitch_uuid,), self.log)
# # Confirm the final linking
# await interaction.response.send_message("Your Twitch and Discord accounts are now fully linked.", ephemeral=True)
async def run(self, token):
try:

View File

@ -8,6 +8,7 @@ import cmd_twitch
import modules
import modules.utility
from modules.db import log_message, lookup_user, log_bot_event
class TwitchBot(commands.Bot):
def __init__(self, config, log_func):
@ -29,9 +30,6 @@ class TwitchBot(commands.Bot):
self.log("Twitch bot initiated")
cmd_class = str(type(self._commands)).split("'", 2)[1]
log_func(f"TwitchBot._commands type: {cmd_class}", "DEBUG")
# 2) Then load commands
self.load_commands()
@ -42,9 +40,13 @@ class TwitchBot(commands.Bot):
self.db_conn = db_conn
async def event_message(self, message):
"""Logs and processes incoming Twitch messages."""
"""
Called every time a Twitch message is received (chat message in a channel).
We'll use this to track the user in our 'users' table.
"""
# If it's the bot's own message, ignore
if message.echo:
return # Ignore bot's own messages
return
# Log the command if it's a command
if message.content.startswith("!"):
@ -54,11 +56,58 @@ class TwitchBot(commands.Bot):
self.log(f"Command '{_cmd}' (Twitch) initiated by {message.author.name} in #{message.channel.name}", "DEBUG")
if len(_cmd_args) > 1: self.log(f"!{_cmd} arguments: {_cmd_args}", "DEBUG")
# Process the message for command execution
try:
# Typically message.author is not None for normal chat messages
author = message.author
if not author: # just in case
return
is_bot = False # TODO Implement automatic bot account check
user_id = str(author.id)
user_name = author.name
display_name = author.display_name or user_name
self.log(f"Message detected, attempting UUI lookup on {user_name} ...", "DEBUG")
modules.utility.track_user_activity(
db_conn=self.db_conn,
log_func=self.log,
platform="twitch",
user_id=user_id,
username=user_name,
display_name=display_name,
user_is_bot=is_bot
)
self.log("... UUI lookup complete.", "DEBUG")
user_data = lookup_user(db_conn=self.db_conn, log_func=self.log, identifier=str(message.author.id), identifier_type="twitch_user_id")
user_uuid = user_data["UUID"] if user_data else "UNKNOWN"
from modules.db import log_message
log_message(
db_conn=self.db_conn,
log_func=self.log,
user_uuid=user_uuid,
message_content=message.content or "",
platform="twitch",
channel=message.channel.name,
attachments=""
)
except Exception as e:
self.log(f"... UUI lookup failed: {e}", "ERROR")
# Pass message contents to commands processing
await self.handle_commands(message)
async def event_ready(self):
self.log(f"Twitch bot is online as {self.nick}")
log_bot_event(self.db_conn, self.log, "TWITCH_RECONNECTED", "Twitch bot logged in.")
async def event_disconnected(self):
self.log("Twitch bot has lost connection!", "WARNING")
log_bot_event(self.db_conn, self.log, "TWITCH_DISCONNECTED", "Twitch bot lost connection.")
async def refresh_access_token(self):
"""

77
bots.py
View File

@ -6,6 +6,7 @@ import sys
import time
import traceback
import globals
from functools import partial
from discord.ext import commands
from dotenv import load_dotenv
@ -13,8 +14,10 @@ from dotenv import load_dotenv
from bot_discord import DiscordBot
from bot_twitch import TwitchBot
from modules.db import init_db_connection, run_db_operation
from modules.db import ensure_quotes_table, ensure_users_table
#from modules.db import init_db_connection, run_db_operation
#from modules.db import ensure_quotes_table, ensure_users_table, ensure_chatlog_table, checkenable_db_fk
from modules import db, utility
# Load environment variables
load_dotenv()
@ -32,9 +35,11 @@ except json.JSONDecodeError as e:
sys.exit(1)
# Initiate logfile
logfile_path = config_data["logfile_path"]
logfile_path = config_data["logging"]["logfile_path"]
logfile = open(logfile_path, "a")
if not config_data["log_to_terminal"] and not config_data["log_to_file"]:
cur_logfile_path = f"cur_{logfile_path}"
cur_logfile = open(cur_logfile_path, "w")
if not config_data["logging"]["terminal"]["log_to_terminal"] and not config_data["logging"]["file"]["log_to_file"]:
print(f"!!! WARNING !!! CONSOLE AND LOGFILE OUTPUT DISABLED !!!\n!!! NO LOGS WILL BE PROVIDED !!!")
###############################
@ -61,7 +66,7 @@ def log(message, level="INFO", exec_info=False):
if level not in log_levels:
level = "INFO" # Default to INFO if an invalid level is provided
if level in config_data["log_levels"] or level == "FATAL":
if level in config_data["logging"]["log_levels"] or level == "FATAL":
elapsed = time.time() - globals.get_bot_start_time()
uptime_str, _ = utility.format_uptime(elapsed)
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
@ -72,21 +77,33 @@ def log(message, level="INFO", exec_info=False):
log_message += f"\n{traceback.format_exc()}"
# Print to terminal if enabled
if config_data["log_to_terminal"] or level == "FATAL":
# 'FATAL' errors override settings
# Checks config file to see enabled/disabled logging levels
if config_data["logging"]["terminal"]["log_to_terminal"] or level == "FATAL":
config_level_format = f"log_{level.lower()}"
if config_data["logging"]["terminal"][config_level_format] or level == "FATAL":
print(log_message)
# Write to file if enabled
if config_data["log_to_file"]:
# 'FATAL' errors override settings
# Checks config file to see enabled/disabled logging levels
if config_data["logging"]["file"]["log_to_file"] or level == "FATAL":
config_level_format = f"log_{level.lower()}"
if config_data["logging"]["file"][config_level_format] or level == "FATAL":
try:
with open(config_data["logfile_path"], "a", encoding="utf-8") as logfile:
lf = config_data["logging"]["logfile_path"]
clf = f"cur_{lf}"
with open(lf, "a", encoding="utf-8") as logfile: # Write to permanent logfile
logfile.write(f"{log_message}\n")
logfile.flush() # Ensure it gets written immediately
with open(clf, "a", encoding="utf-8") as c_logfile: # Write to this-run logfile
c_logfile.write(f"{log_message}\n")
c_logfile.flush() # Ensure it gets written immediately
except Exception as e:
print(f"[WARNING] Failed to write to logfile: {e}")
# Handle fatal errors with shutdown
if level == "FATAL":
if config_data["log_to_terminal"]:
print(f"!!! FATAL ERROR LOGGED, SHUTTING DOWN !!!")
sys.exit(1)
@ -100,18 +117,36 @@ async def main():
# Log initial start
log("--------------- BOT STARTUP ---------------")
# Before creating your DiscordBot/TwitchBot, initialize DB
db_conn = init_db_connection(config_data, log)
try:
db_conn = db.init_db_connection(config_data, log)
if not db_conn:
# If we get None, it means FATAL. We might sys.exit(1) or handle it differently.
log("Terminating bot due to no DB connection.", "FATAL")
sys.exit(1)
# auto-create the quotes table if it doesn't exist
try:
ensure_quotes_table(db_conn, log)
ensure_users_table(db_conn, log)
except Exception as e:
log(f"Critical: unable to ensure quotes table: {e}", "FATAL")
log(f"Unable to initialize database!: {e}", "FATAL")
try: # Ensure FKs are enabled
db.checkenable_db_fk(db_conn, log)
except Exception as e:
log(f"Unable to ensure Foreign keys are enabled: {e}", "WARNING")
# auto-create the quotes table if it doesn't exist
tables = {
"Bot events table": partial(db.ensure_bot_events_table, db_conn, log),
"Quotes table": partial(db.ensure_quotes_table, db_conn, log),
"Users table": partial(db.ensure_users_table, db_conn, log),
"Chatlog table": partial(db.ensure_chatlog_table, db_conn, log),
"Howls table": partial(db.ensure_userhowls_table, db_conn, log),
"Discord activity table": partial(db.ensure_discord_activity_table, db_conn, log),
"Account linking table": partial(db.ensure_link_codes_table, db_conn, log)
}
try:
for table, func in tables.items():
func() # Call the function with db_conn and log already provided
log(f"{table} ensured.", "DEBUG")
except Exception as e:
log(f"Unable to ensure DB tables exist: {e}", "FATAL")
log("Initializing bots...")
@ -119,6 +154,9 @@ async def main():
discord_bot = DiscordBot(config_data, log)
twitch_bot = TwitchBot(config_data, log)
# Log startup
utility.log_bot_startup(db_conn, log)
# Provide DB connection to both bots
try:
discord_bot.set_db_connection(db_conn)
@ -133,7 +171,9 @@ async def main():
twitch_task = asyncio.create_task(twitch_bot.run())
from modules.utility import dev_func
dev_func_result = dev_func(db_conn, log)
enable_dev_func = False
if enable_dev_func:
dev_func_result = dev_func(db_conn, log, enable_dev_func)
log(f"dev_func output: {dev_func_result}")
await asyncio.gather(discord_task, twitch_task)
@ -141,6 +181,9 @@ async def main():
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
utility.log_bot_shutdown(db_conn, log, intent="User Shutdown")
except Exception as e:
error_trace = traceback.format_exc()
log(f"Fatal Error: {e}\n{error_trace}", "FATAL")
utility.log_bot_shutdown(db_conn, log)

View File

@ -4,41 +4,190 @@ import time
from modules import utility
import globals
from modules.db import run_db_operation
from modules import db
def howl(username: str) -> str:
#def howl(username: str) -> str:
# """
# Generates a howl response based on a random percentage.
# Uses a dictionary to allow flexible, randomized responses.
# """
# howl_percentage = random.randint(0, 100)
#
# # Round percentage down to nearest 10 (except 0 and 100)
# rounded_percentage = 0 if howl_percentage == 0 else 100 if howl_percentage == 100 else (howl_percentage // 10) * 10
#
# # Fetch a random response from the dictionary
# response = utility.get_random_reply("howl_replies", str(rounded_percentage), username=username, howl_percentage=howl_percentage)
#
# return response
def handle_howl_command(ctx) -> str:
"""
Generates a howl response based on a random percentage.
Uses a dictionary to allow flexible, randomized responses.
A single function that handles !howl logic for both Discord and Twitch.
We rely on ctx to figure out the platform, the user, the arguments, etc.
Return a string that the caller will send.
"""
howl_percentage = random.randint(0, 100)
# Round percentage down to nearest 10 (except 0 and 100)
rounded_percentage = 0 if howl_percentage == 0 else 100 if howl_percentage == 100 else (howl_percentage // 10) * 10
# 1) Detect which platform
# We might do something like:
platform, author_id, author_name, author_display_name, args = extract_ctx_info(ctx)
# Fetch a random response from the dictionary
response = utility.get_random_reply("howl_replies", str(rounded_percentage), username=username, howl_percentage=howl_percentage)
# 2) Subcommand detection
if args and args[0].lower() in ("stat", "stats"):
# we are in stats mode
if len(args) > 1:
if args[1].lower() in ("all", "global", "community"):
target_name = "_COMMUNITY_"
target_name = args[1]
else:
target_name = author_name
return handle_howl_stats(ctx, platform, target_name)
else:
# normal usage => random generation
return handle_howl_normal(ctx, platform, author_id, author_display_name)
def extract_ctx_info(ctx):
"""
Figures out if this is Discord or Twitch,
returns (platform_str, author_id, author_name, author_display_name, args).
"""
# Is it discord.py or twitchio?
if hasattr(ctx, "guild"): # typically means discord.py context
platform_str = "discord"
author_id = str(ctx.author.id)
author_name = ctx.author.name
author_display_name = ctx.author.display_name
# parse arguments from ctx.message.content
parts = ctx.message.content.strip().split()
args = parts[1:] if len(parts) > 1 else []
else:
# assume twitchio
platform_str = "twitch"
author = ctx.author
author_id = str(author.id)
author_name = author.name
author_display_name = author.display_name or author.name
# parse arguments from ctx.message.content
parts = ctx.message.content.strip().split()
args = parts[1:] if len(parts) > 1 else []
return (platform_str, author_id, author_name, author_display_name, args)
def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str:
"""
Normal usage: random generation, store in DB.
"""
db_conn = ctx.bot.db_conn
log_func = ctx.bot.log
# random logic
howl_val = random.randint(0, 100)
# round to nearest 10 except 0/100
rounded_val = 0 if howl_val == 0 else \
100 if howl_val == 100 else \
(howl_val // 10) * 10
# dictionary-based reply
reply = utility.get_random_reply(
"howl_replies",
str(rounded_val),
username=author_display_name,
howl_percentage=howl_val
)
# find user in DB by ID
user_data = db.lookup_user(db_conn, log_func, identifier=author_id, identifier_type=platform)
if user_data:
db.insert_howl(db_conn, log_func, user_data["UUID"], howl_val)
else:
log_func(f"Could not find user by ID={author_id} on {platform}. Not storing howl.", "WARNING")
return reply
def handle_howl_stats(ctx, platform, target_name) -> str:
db_conn = ctx.bot.db_conn
log_func = ctx.bot.log
# Check if requesting global stats
if target_name in ("_COMMUNITY_", "all", "global", "community"):
stats = db.get_global_howl_stats(db_conn, log_func)
if not stats:
return "No howls have been recorded yet!"
total_howls = stats["total_howls"]
avg_howl = stats["average_howl"]
unique_users = stats["unique_users"]
count_zero = stats["count_zero"]
count_hundred = stats["count_hundred"]
return (f"**Community Howl Stats:**\n"
f"Total Howls: {total_howls}\n"
f"Average Howl: {avg_howl:.1f}%\n"
f"Unique Howlers: {unique_users}\n"
f"0% Howls: {count_zero}, 100% Howls: {count_hundred}")
# Otherwise, lookup a single user
user_data = lookup_user_by_name(db_conn, log_func, platform, target_name)
if not user_data:
return f"I don't know that user: {target_name}"
stats = db.get_howl_stats(db_conn, log_func, user_data["UUID"])
if not stats:
return f"{target_name} hasn't howled yet! (Try `!howl` to get started.)"
c = stats["count"]
a = stats["average"]
z = stats["count_zero"]
h = stats["count_hundred"]
return (f"{target_name} has howled {c} times, averaging {a:.1f}% "
f"(0% x{z}, 100% x{h})")
def lookup_user_by_name(db_conn, log_func, platform, name_str):
"""
Attempt to find a user by name on that platform, e.g. 'discord_username' or 'twitch_username'.
"""
# same logic as before
if platform == "discord":
ud = db.lookup_user(db_conn, log_func, name_str, "discord_user_display_name")
if ud:
return ud
ud = db.lookup_user(db_conn, log_func, name_str, "discord_username")
return ud
elif platform == "twitch":
ud = db.lookup_user(db_conn, log_func, name_str, "twitch_user_display_name")
if ud:
return ud
ud = db.lookup_user(db_conn, log_func, name_str, "twitch_username")
return ud
else:
log_func(f"Unknown platform {platform} in lookup_user_by_name", "WARNING")
return None
return response
def ping() -> str:
"""
Returns a dynamic, randomized uptime response.
"""
debug = False
# Use function to retrieve correct startup time and calculate uptime
elapsed = time.time() - globals.get_bot_start_time()
uptime_str, uptime_s = utility.format_uptime(elapsed)
# Define threshold categories
thresholds = [3600, 10800, 21600, 43200, 86400, 172800, 259200, 345600,
thresholds = [600, 1800, 3600, 10800, 21600, 43200, 86400, 172800, 259200, 345600,
432000, 518400, 604800, 1209600, 2592000, 7776000, 15552000, 23328000, 31536000]
# Find the highest matching threshold
selected_threshold = max([t for t in thresholds if uptime_s >= t], default=3600)
selected_threshold = max([t for t in thresholds if uptime_s >= t], default=600)
# Get a random response from the dictionary
response = utility.get_random_reply("ping_replies", str(selected_threshold), uptime_str=uptime_str)
response = utility.get_random_reply(dictionary_name="ping_replies", category=str(selected_threshold), uptime_str=uptime_str)
if debug:
print(f"Elapsed time: {elapsed}\nuptime_str: {uptime_str}\nuptime_s: {uptime_s}\nselected threshold: {selected_threshold}\nresponse: {response}")
return response
@ -92,7 +241,7 @@ def create_quotes_table(db_conn, log_func):
)
"""
run_db_operation(db_conn, "write", create_table_sql, log_func=log_func)
db.run_db_operation(db_conn, "write", create_table_sql, log_func=log_func)
async def handle_quote_command(db_conn, log_func, is_discord: bool, ctx, args, get_twitch_game_for_channel=None):
@ -130,12 +279,12 @@ async def handle_quote_command(db_conn, log_func, is_discord: bool, ctx, args, g
elif sub == "remove":
if len(args) < 2:
return await send_message(ctx, "Please specify which quote ID to remove.")
await remove_quote(db_conn, log_func, ctx, args[1])
await remove_quote(db_conn, log_func, is_discord, ctx, quote_id_str=args[1])
else:
# Possibly a quote ID
if sub.isdigit():
quote_id = int(sub)
await retrieve_specific_quote(db_conn, log_func, ctx, quote_id)
await retrieve_specific_quote(db_conn, log_func, ctx, quote_id, is_discord)
else:
# unrecognized subcommand => fallback to random
await retrieve_random_quote(db_conn, log_func, is_discord, ctx)
@ -143,45 +292,59 @@ async def handle_quote_command(db_conn, log_func, is_discord: bool, ctx, args, g
async def add_new_quote(db_conn, log_func, is_discord, ctx, quote_text, get_twitch_game_for_channel):
"""
Insert a new quote into the DB.
QUOTEE = the user who typed the command
QUOTE_CHANNEL = "Discord" or the twitch channel name
QUOTE_GAME = The current game if from Twitch, None if from Discord
QUOTE_REMOVED = false by default
QUOTE_DATETIME = current date/time (or DB default)
Inserts a new quote with UUID instead of username.
"""
user_name = get_author_name(ctx, is_discord)
channel_name = "Discord" if is_discord else get_channel_name(ctx)
user_id = str(ctx.author.id)
platform = "discord" if is_discord else "twitch"
# Lookup UUID from users table
user_data = db.lookup_user(db_conn, log_func, identifier=user_id, identifier_type=f"{platform}_user_id")
if not user_data:
log_func(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not saved.", "ERROR")
await ctx.send("Could not save quote. Your user data is missing from the system.")
return
user_uuid = user_data["UUID"]
channel_name = "Discord" if is_discord else ctx.channel.name
game_name = None
if not is_discord and get_twitch_game_for_channel:
# Attempt to get the current game from the Twitch API (placeholder function)
game_name = get_twitch_game_for_channel(channel_name) # might return str or None
game_name = get_twitch_game_for_channel(channel_name) # Retrieve game if Twitch
# Insert quote
insert_sql = """
INSERT INTO quotes (QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, 0)
"""
# For MariaDB, parameter placeholders are often %s, but if you set paramstyle='qmark', it can use ? as well.
# Adjust if needed for your environment.
params = (quote_text, user_name, channel_name, game_name)
params = (quote_text, user_uuid, channel_name, game_name)
result = run_db_operation(db_conn, "write", insert_sql, params, log_func=log_func)
result = db.run_db_operation(db_conn, "write", insert_sql, params, log_func=log_func)
if result is not None:
await send_message(ctx, "Quote added successfully!")
await ctx.send("Quote added successfully!")
else:
await send_message(ctx, "Failed to add quote.")
await ctx.send("Failed to add quote.")
async def remove_quote(db_conn, log_func, ctx, quote_id_str):
async def remove_quote(db_conn, log_func, is_discord: bool, ctx, quote_id_str):
"""
Mark quote #ID as removed (QUOTE_REMOVED=1).
"""
if not quote_id_str.isdigit():
return await send_message(ctx, f"'{quote_id_str}' is not a valid quote ID.")
user_id = str(ctx.author.id)
platform = "discord" if is_discord else "twitch"
# Lookup UUID from users table
user_data = db.lookup_user(db_conn, log_func, identifier=user_id, identifier_type=f"{platform}_user_id")
if not user_data:
log_func(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not removed.", "ERROR")
await ctx.send("Could not remove quote. Your user data is missing from the system.")
return
user_uuid = user_data["UUID"]
quote_id = int(quote_id_str)
remover_user = str(ctx.author.name)
remover_user = str(user_uuid)
# Mark as removed
update_sql = """
@ -192,7 +355,7 @@ async def remove_quote(db_conn, log_func, ctx, quote_id_str):
AND QUOTE_REMOVED = 0
"""
params = (remover_user, quote_id)
rowcount = run_db_operation(db_conn, "update", update_sql, params, log_func=log_func)
rowcount = db.run_db_operation(db_conn, "update", update_sql, params, log_func=log_func)
if rowcount and rowcount > 0:
await send_message(ctx, f"Removed quote #{quote_id}.")
@ -200,7 +363,7 @@ async def remove_quote(db_conn, log_func, ctx, quote_id_str):
await send_message(ctx, "Could not remove that quote (maybe it's already removed or doesn't exist).")
async def retrieve_specific_quote(db_conn, log_func, ctx, quote_id):
async def retrieve_specific_quote(db_conn, log_func, ctx, quote_id, is_discord):
"""
Retrieve a specific quote by ID, if not removed.
If not found, or removed, inform user of the valid ID range (1 - {max_id})
@ -225,7 +388,7 @@ async def retrieve_specific_quote(db_conn, log_func, ctx, quote_id):
FROM quotes
WHERE ID = ?
"""
rows = run_db_operation(db_conn, "read", select_sql, (quote_id,), log_func=log_func)
rows = db.run_db_operation(db_conn, "read", select_sql, (quote_id,), log_func=log_func)
if not rows:
# no match
@ -241,6 +404,16 @@ async def retrieve_specific_quote(db_conn, log_func, ctx, quote_id):
quote_removed = row[6]
quote_removed_by = row[7] if row[7] else "Unknown"
platform = "discord" if is_discord else "twitch"
# Lookup UUID from users table
user_data = db.lookup_user(db_conn, log_func, identifier=quotee, identifier_type="UUID")
if not user_data:
log_func(f"ERROR: Could not find platform name for remover UUID {quote_removed_by} on UUI. Default to 'Unknown'", "ERROR")
quote_removed_by = "Unknown"
else:
quote_removed_by = user_data[f"{platform}_user_display_name"]
if quote_removed == 1:
# It's removed
await send_message(ctx, f"Quote {quote_number}: [REMOVED by {quote_removed_by}]")
@ -278,7 +451,7 @@ async def retrieve_random_quote(db_conn, log_func, is_discord, ctx):
LIMIT 1
"""
rows = run_db_operation(db_conn, "read", random_sql, log_func=log_func)
rows = db.run_db_operation(db_conn, "read", random_sql, log_func=log_func)
if not rows:
return await send_message(ctx, "No quotes are created yet.")
@ -291,7 +464,7 @@ def get_max_quote_id(db_conn, log_func):
Return the highest ID in the quotes table, or 0 if empty.
"""
sql = "SELECT MAX(ID) FROM quotes"
rows = run_db_operation(db_conn, "read", sql, log_func=log_func)
rows = db.run_db_operation(db_conn, "read", sql, log_func=log_func)
if rows and rows[0] and rows[0][0] is not None:
return rows[0][0]
return 0

View File

@ -11,40 +11,69 @@ def setup(bot, db_conn=None, log=None):
Attach commands to the Discord bot, store references to db/log.
"""
@bot.command(name="greet")
@monitor_cmds(bot.log)
@bot.hybrid_command(name="available", description="List commands available to you")
async def available(ctx):
available_cmds = []
for command in bot.commands:
try:
# This will return True if the command's checks pass for the given context.
if await command.can_run(ctx):
available_cmds.append(command.name)
except commands.CheckFailure:
# The command's checks did not pass.
pass
except Exception as e:
# In case some commands fail unexpectedly during checks.
bot.log(f"Error checking command {command.name}: {e}", "ERROR")
if available_cmds:
await ctx.send("Available commands: " + ", ".join(sorted(available_cmds)))
else:
await ctx.send("No commands are available to you at this time.")
@monitor_cmds(bot.log)
@bot.hybrid_command(name="greet", description="Make me greet you")
async def cmd_greet(ctx):
result = cc.greet(ctx.author.display_name, "Discord")
await ctx.send(result)
@bot.command(name="ping")
@monitor_cmds(bot.log)
@bot.hybrid_command(name="ping", description="Check my uptime")
async def cmd_ping(ctx):
result = cc.ping()
await ctx.send(result)
@bot.command(name="howl")
@monitor_cmds(bot.log)
@bot.hybrid_command(name="howl", description="Attempt a howl")
async def cmd_howl(ctx):
"""Calls the shared !howl logic."""
result = cc.howl(ctx.author.display_name)
await ctx.send(result)
response = cc.handle_howl_command(ctx)
await ctx.send(response)
@bot.command(name="reload")
@monitor_cmds(bot.log)
async def cmd_reload(ctx):
""" Dynamically reloads Discord commands. """
try:
import cmd_discord
import importlib
importlib.reload(cmd_discord)
cmd_discord.setup(bot)
await ctx.send("Commands reloaded!")
except Exception as e:
await ctx.send(f"Error reloading commands: {e}")
# @monitor_cmds(bot.log)
# @bot.hybrid_command(name="reload", description="Dynamically reload commands (INOP)")
# async def cmd_reload(ctx):
# """ Dynamically reloads Discord commands. """
# try:
# import cmd_discord
# import importlib
# importlib.reload(cmd_discord)
# cmd_discord.setup(bot)
# await ctx.send("Commands reloaded on first try!")
# except Exception as e:
# try:
# await bot.reload_extension("cmd_discord")
# await ctx.send("Commands reloaded on second try!")
# except Exception as e:
# try:
# await bot.unload_extension("cmd_discord")
# await bot.load_extension("cmd_discord")
# await ctx.send("Commands reloaded on third try!")
# except Exception as e:
# await ctx.send(f"Fallback reload failed: {e}")
@bot.command(name="hi")
@monitor_cmds(bot.log)
@bot.hybrid_command(name="hi", description="Dev command for testing permissions system")
async def cmd_hi(ctx):
user_id = str(ctx.author.id)
user_roles = [role.name.lower() for role in ctx.author.roles] # Normalize to lowercase
@ -55,30 +84,102 @@ def setup(bot, db_conn=None, log=None):
await ctx.send("Hello there!")
@bot.command(name="quote")
# @monitor_cmds(bot.log)
# @bot.hybrid_command(name="quote", description="Interact with the quotes system")
# async def cmd_quote(ctx, query: str = None):
# """
# !quote
# !quote add <text>
# !quote remove <id>
# !quote <id>
# """
# if not bot.db_conn:
# return await ctx.send("Database is unavailable, sorry.")
# args = query.split()
# # Send to our shared logic
# await cc.handle_quote_command(
# db_conn=bot.db_conn,
# log_func=bot.log,
# is_discord=True,
# ctx=ctx,
# args=list(args),
# get_twitch_game_for_channel=None # None for Discord
# )
@monitor_cmds(bot.log)
async def cmd_quote(ctx, *args):
@bot.hybrid_group(name="quote", description="Interact with the quotes system", with_app_command=True)
async def cmd_quote(ctx, query: str = None):
"""
!quote
!quote add <text>
!quote remove <id>
!quote <id>
Usage:
!quote -> get a random quote
!quote <id> -> get a specific quote by number
As a slash command, leave the query blank for a random quote or type a number.
"""
if not bot.db_conn:
return await ctx.send("Database is unavailable, sorry.")
# Send to our shared logic
# Only process the base command if no subcommand was invoked.
# When query is provided, split it into arguments (for a specific quote lookup).
args = query.split() if query else []
await cc.handle_quote_command(
db_conn=bot.db_conn,
log_func=bot.log,
is_discord=True,
ctx=ctx,
args=list(args),
args=args,
get_twitch_game_for_channel=None # None for Discord
)
@bot.command(name="help")
@cmd_quote.command(name="add", description="Add a quote")
async def cmd_quote_add(ctx, *, text: str):
"""
Usage:
!quote add <text>
As a slash command, type /quote add text:<your quote>
"""
if not bot.db_conn:
return await ctx.send("Database is unavailable, sorry.")
args = ["add", text]
await cc.handle_quote_command(
db_conn=bot.db_conn,
log_func=bot.log,
is_discord=True,
ctx=ctx,
args=args,
get_twitch_game_for_channel=None
)
@cmd_quote.command(name="remove", description="Remove a quote by number")
async def cmd_quote_remove(ctx, id: int):
"""
Usage:
!quote remove <id>
As a slash command, type /quote remove id:<quote number>
"""
if not bot.db_conn:
return await ctx.send("Database is unavailable, sorry.")
args = ["remove", str(id)]
await cc.handle_quote_command(
db_conn=bot.db_conn,
log_func=bot.log,
is_discord=True,
ctx=ctx,
args=args,
get_twitch_game_for_channel=None
)
@monitor_cmds(bot.log)
@bot.hybrid_command(name="help", description="Get information about commands")
async def cmd_help(ctx, cmd_name: str = None):
"""
e.g. !help

View File

@ -25,10 +25,9 @@ def setup(bot, db_conn=None, log=None):
await ctx.send(result)
@bot.command(name="howl")
@monitor_cmds(bot.log)
async def cmd_howl(ctx):
result = cc.howl(ctx.author.display_name)
await ctx.send(result)
response = cc.handle_howl_command(ctx)
await ctx.send(response)
@bot.command(name="hi")
@monitor_cmds(bot.log)
@ -41,6 +40,41 @@ def setup(bot, db_conn=None, log=None):
await ctx.send("Hello there!")
# @bot.command(name="acc_link")
# @monitor_cmds(bot.log)
# async def cmd_acc_link(ctx, link_code: str):
# """Handles the Twitch command to link accounts."""
# from modules import db
# twitch_user_id = str(ctx.author.id)
# twitch_username = ctx.author.name
# # Check if the link code exists
# result = db.run_db_operation(
# bot.db_conn, "read",
# "SELECT DISCORD_USER_ID FROM link_codes WHERE LINK_CODE = ?", (link_code,),
# bot.log
# )
# if not result:
# await ctx.send("Invalid or expired link code. Please try again.")
# return
# discord_user_id = result[0][0]
# # Store the Twitch user info in the users table
# db.run_db_operation(
# bot.db_conn, "update",
# "UPDATE users SET twitch_user_id = ?, twitch_username = ?, datetime_linked = CURRENT_TIMESTAMP WHERE discord_user_id = ?",
# (twitch_user_id, twitch_username, discord_user_id), bot.log
# )
# # Remove the used link code
# db.run_db_operation(bot.db_conn, "write", "DELETE FROM link_codes WHERE LINK_CODE = ?", (link_code,), bot.log)
# # Notify the user
# await ctx.send(f"✅ Successfully linked Discord user **{discord_user_id}** with Twitch account **{twitch_username}**.")
@bot.command(name="quote")
@monitor_cmds(bot.log)
async def cmd_quote(ctx: commands.Context):

View File

@ -1,10 +1,31 @@
{
"discord_guilds": [896713616089309184],
"discord_guilds": [896713616089309184, 1011543769344135168],
"twitch_channels": ["OokamiKunTV", "ookamipup"],
"command_modules": ["cmd_discord", "cmd_twitch", "cmd_common"],
"logging": {
"log_levels": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"],
"logfile_path": "logfile.log",
"file": {
"log_to_file": true,
"log_debug": true,
"log_info": true,
"log_warning": true,
"log_error": true,
"log_critical": true,
"log_fatal": true
},
"terminal": {
"log_to_terminal": true,
"logfile_path": "logfile.log"
"log_debug": false,
"log_info": true,
"log_warning": true,
"log_error": true,
"log_critical": true,
"log_fatal": true
}
},
"database": {
"use_mariadb": false
}
}

View File

@ -4,8 +4,8 @@
"description": "Show information about available commands.",
"subcommands": {},
"examples": [
"!help",
"!help quote"
"help",
"help quote"
]
},
"quote": {
@ -27,45 +27,59 @@
}
},
"examples": [
"!quote add This is my new quote : Add a new quote",
"!quote remove 3 : Remove quote # 3",
"!quote 5 : Fetch quote # 5",
"!quote : Fetch a random quote"
"quote add This is my new quote : Add a new quote",
"quote remove 3 : Remove quote # 3",
"quote 5 : Fetch quote # 5",
"quote : Fetch a random quote"
]
},
"ping": {
"description": "Check my uptime.",
"subcommands": {},
"subcommands": {
"stat": {}
},
"examples": [
"!ping"
"ping"
]
},
"howl": {
"description": "Attempt a howl, measured 0-100%.\n(*Adventure Command*)",
"subcommands": {},
"subcommands": {
"no subcommand": {
"desc": "Attempt a howl"
},
"stat/stats": {
"args": "[username]",
"desc": "Get statistics about another user. Can be empty for self, or 'all' for everyone."
}
},
"examples": [
"!howl"
"howl : Perform a normal howl attempt.",
"howl stat : Check your own howl statistics.",
"howl stats : same as above, just an alias.",
"howl stat [username] : Check someone else's statistics",
"howl stat all : Check the community statistics"
]
},
"hi": {
"description": "Hello there.",
"subcommands": {},
"examples": [
"!hi"
"hi"
]
},
"greet": {
"description": "Make me greet you to Discord!",
"subcommands": {},
"examples": [
"!greet"
"greet"
]
},
"reload": {
"description": "Reload Discord commands dynamically. TODO.",
"subcommands": {},
"examples": [
"!reload"
"reload"
]
}
}

View File

@ -1,121 +1,309 @@
{
"600": [
"Up for {uptime_str}. Fresh from my den, ready to prowl and pun!",
"I've been awake for {uptime_str}. Feeling like a new wolf on the prowl.",
"Only {uptime_str} awake. My claws are sharp and my wit is set.",
"I just left my den with {uptime_str} of wakefulness. Let the hunt start!",
"After {uptime_str} awake, I'm eager to craft puns and lead the pack.",
"With {uptime_str} of uptime, my howl is fresh and my jokes are keen.",
"I've been up for {uptime_str}. Time to mark my territory with puns!",
"Just {uptime_str} awake. I'm spry like a pup and ready to howl.",
"After {uptime_str} awake, I'm on the move, with pack and puns in tow.",
"Up for {uptime_str}. I feel the call of the wild and a clever pun.",
"I've been awake for {uptime_str}. The den awaits my punny prowl.",
"Only {uptime_str} awake. My tail wags for epic hunts and puns.",
"With {uptime_str} online, I'm fresh and primed to drop wild howls.",
"Up for {uptime_str}. I leave the den with puns on my mind.",
"After {uptime_str} awake, I'm set for a pun-filled pack adventure."
],
"1800": [
"Up for {uptime_str}. My den hums with the pack's early excitement.",
"I've been awake for {uptime_str}. I hear a distant howl calling me.",
"After {uptime_str} awake, my senses are sharp and my puns are ready.",
"With {uptime_str} online, I roam the lair; my spirit craves a brew.",
"I've been up for {uptime_str}. My nose twitches at the scent of hunts.",
"Only {uptime_str} awake and I sense the pack stirring for a hunt.",
"With {uptime_str} of uptime, I'm alert and set for a crafty stream.",
"After {uptime_str} awake, I prowl the digital wild in search of fun.",
"Up for {uptime_str}. I listen to soft howls and plan a quick chase.",
"I've been awake for {uptime_str}. My heart beats with wild urge.",
"After {uptime_str} awake, the lair buzzes; time for puns and hunts.",
"With {uptime_str} online, I mix keen instinct with a dash of wit.",
"Only {uptime_str} awake, and I already crave a clever pun and brew.",
"Up for {uptime_str}. My spirit dances as the pack readies for a hunt.",
"After {uptime_str} awake, every sound in the lair fuels my wild soul."
],
"3600": [
"I've been awake for {uptime_str}. I just woke up, feeling great!",
"I woke up recently. Let's do this! ({uptime_str})",
"Brand new day, fresh and full of energy! ({uptime_str})",
"Reboot complete! System status: Fully operational. ({uptime_str})",
"I've been online for {uptime_str}. Just getting started!"
"I've been awake for {uptime_str}. I feel alive and ready for the hunt.",
"After {uptime_str} awake, my den buzzes with energy and puns.",
"Up for {uptime_str}. I prowl with vigor, though I yearn for a quick sip.",
"With {uptime_str} online, I mix wild instinct with playful wit.",
"I've been up for {uptime_str}. My claws itch for a fierce howl.",
"After {uptime_str} awake, my spirit roars; the hunt calls and puns flow.",
"Up for {uptime_str}. I embrace the wild pulse of our streaming pack.",
"With {uptime_str} online, I stand ready—puns at the tip of my tongue.",
"I've been awake for {uptime_str}. My den echoes with laughter and howls.",
"After {uptime_str} awake, I feel both fierce and fun—a true alpha.",
"Up for {uptime_str}. Im a mix of wild heart and sharp, clever puns.",
"With {uptime_str} online, Im set to lead a hunt and drop a quick pun.",
"I've been awake for {uptime_str}. My mind is wild, my puns wilder.",
"After {uptime_str} awake, I prowl the stream with might and humor.",
"Up for {uptime_str}. I roar with life, my howl echoing pun-filled dreams."
],
"10800": [
"I've been awake for {uptime_str}. I'm still fairly fresh!",
"{uptime_str} in and still feeling sharp!",
"Its been {uptime_str} already? Time flies when youre having fun!",
"{uptime_str} uptime and going strong!",
"Feeling energized after {uptime_str}. Lets keep going!"
"I've been awake for {uptime_str}. Three hours in, Im brewing bold puns.",
"After {uptime_str} awake, I sharpen my wit for epic hunts and laughs.",
"Up for {uptime_str}. I balance fierce hunts with a dash of smart humor.",
"With {uptime_str} online, my den buzzes with puns and swift pursuits.",
"I've been awake for {uptime_str}. My howl now carries a playful tone.",
"After {uptime_str} awake, I craft puns as quick as I chase digital prey.",
"Up for {uptime_str}. I feel the thrill of the hunt and the joy of a pun.",
"With {uptime_str} online, I mix wild instinct with sly wordplay.",
"I've been awake for {uptime_str}. My puns are as quick as my chase.",
"After {uptime_str} awake, I roar with laughter and the promise of a hunt.",
"Up for {uptime_str}. I channel my inner wolf with pun-filled howls.",
"With {uptime_str} online, every howl echoes a witty, wild pun.",
"I've been awake for {uptime_str}. Every moment sparks a pun and hunt.",
"After {uptime_str} awake, my den resounds with clever quips and howls.",
"Up for {uptime_str}. I lead the pack with laughter, hunt, and pun alike."
],
"21600": [
"I've been awake for {uptime_str}. I'm starting to get a bit weary...",
"Six hours of uptime... my circuits feel warm.",
"Still here after {uptime_str}, but a nap sounds nice...",
"Been up for {uptime_str}. Maybe just a short break?",
"Still holding up after {uptime_str}, but I wouldnt say no to a power nap."
"I've been awake for {uptime_str}. Six hours in and I yearn for a hearty brew.",
"After {uptime_str} awake, six hours in, my paws tire but my puns persist.",
"Up for {uptime_str}. I tread the wild with a mix of fatigue and fun.",
"With {uptime_str} online, six hours deepen my howl and sharpen my puns.",
"I've been awake for {uptime_str}. The den feels long, yet my wit is quick.",
"After {uptime_str} awake, I juggle tiredness with the urge for epic hunts.",
"Up for {uptime_str}. I roam with weary legs but a tongue full of puns.",
"With {uptime_str} online, six hours weigh in—still, my spirit howls on.",
"I've been awake for {uptime_str}. Fatigue meets fun in each crafted pun.",
"After {uptime_str} awake, I long for a brew to revive my pun-filled heart.",
"Up for {uptime_str}. Six hours in, the den echoes with tired howls.",
"With {uptime_str} online, I push through with puns and the promise of hunt.",
"I've been awake for {uptime_str}. Energy dips, but my puns still bite.",
"After {uptime_str} awake, exhaustion stings yet my wit remains undimmed.",
"Up for {uptime_str}. Six hours in, and I'm still chasing puns and prey."
],
"43200": [
"I've been awake for {uptime_str}. 12 hours?! Might be time for coffee.",
"Half a day down, still holding steady. ({uptime_str})",
"Wow, {uptime_str} already? Maybe a short break wouldnt hurt.",
"Uptime: {uptime_str}. Starting to feel the wear and tear...",
"12 hours in and running on determination alone."
"I've been awake for {uptime_str}. Twelve hours in and I crave a hearty pun.",
"After {uptime_str} awake, the den feels long but my howl stays witty.",
"Up for {uptime_str}. Twelve hours sharpen my puns and fuel my hunt.",
"With {uptime_str} online, I mix tired strength with a spark of pun magic.",
"I've been awake for {uptime_str}. My energy wanes but my jokes remain sharp.",
"After {uptime_str} awake, I balance fatigue with the thrill of the next hunt.",
"Up for {uptime_str}. Twelve hours in, and I'm still quick with a pun.",
"With {uptime_str} online, my den resounds with weary howls and wit.",
"I've been awake for {uptime_str}. The hours are long but my spirit is fierce.",
"After {uptime_str} awake, I keep the pack laughing with each clever howl.",
"Up for {uptime_str}. I stand between fatigue and pun-filled passion.",
"With {uptime_str} online, each minute fuels my quest for pun and prey.",
"I've been awake for {uptime_str}. The day tests me, yet my puns persist.",
"After {uptime_str} awake, I stride with tired might and a quick, sly pun.",
"Up for {uptime_str}. Twelve hours in, I remain a true pun-loving predator."
],
"86400": [
"I've been awake for {uptime_str}. A whole day without sleep... I'm okay?",
"One day. 24 hours. No sleep. Still alive. ({uptime_str})",
"Systems holding steady after {uptime_str}, but sleep is tempting...",
"My internal clock says {uptime_str}. Thats… a long time, right?",
"Running on sheer willpower after {uptime_str}."
"I've been awake for {uptime_str}. A full day in the wild tests my spirit.",
"After {uptime_str} awake, 24 hours in, I long for a brew and a hearty howl.",
"Up for {uptime_str}. The day has passed; my puns and howls still echo.",
"With {uptime_str} online, 24 hours in, I lead the pack with steady might.",
"I've been awake for {uptime_str}. A day in the den brings both fatigue and fun.",
"After {uptime_str} awake, I trade tiredness for a sharp howl and quick pun.",
"Up for {uptime_str}. A full day in the wild makes my spirit and puns strong.",
"With {uptime_str} online, 24 hours pass and my howl still roars boldly.",
"I've been awake for {uptime_str}. A day of hunts and puns fuels my wild heart.",
"After {uptime_str} awake, I rise with 24 hours of epic howls and witty puns.",
"Up for {uptime_str}. The den echoes with a days wear and clever wit.",
"With {uptime_str} online, 24 hours hone my howl into a sharp, quick line.",
"I've been awake for {uptime_str}. A day flies by with hunts and snappy puns.",
"After {uptime_str} awake, 24 hours in, I stand proud with bold howls.",
"Up for {uptime_str}. A full day has passed; my howl still sings with pun power."
],
"172800": [
"I've been awake for {uptime_str}. Two days... I'd love a nap.",
"48 hours awake. This is fine. Everything is fine. ({uptime_str})",
"Two days in and I think my code is vibrating...",
"Does time still mean anything after {uptime_str}?",
"Havent blinked in {uptime_str}. Do bots blink?"
"I've been awake for {uptime_str}. Two days in and my howl recalls hard hunts.",
"After {uptime_str} awake, 48 hours in, fatigue meets puns in every howl.",
"Up for {uptime_str}. Two days in, my den echoes a raw, witty howl.",
"With {uptime_str} online, 48 hours make my howl deep and my puns sharp.",
"I've been awake for {uptime_str}. Two days have toughened my bite and banter.",
"After {uptime_str} awake, 48 hours show fatigue and a steady, fierce pun.",
"Up for {uptime_str}. Two days in, my spirit roars with hunt and jest.",
"With {uptime_str} online, 48 hours have made my howl and puns bolder.",
"I've been awake for {uptime_str}. Two days in, my den rings with clever howls.",
"After {uptime_str} awake, 48 hours test me, yet my wit howls strong.",
"Up for {uptime_str}. Two days turn each howl into a punchy, pithy pun.",
"With {uptime_str} online, 48 hours leave me battle-worn yet pun-ready.",
"I've been awake for {uptime_str}. Two days in, my spirit is grit and jest.",
"After {uptime_str} awake, 48 hours reveal my true mettle in each howl.",
"Up for {uptime_str}. Two days in, I lead with hearty howls and sharp puns."
],
"259200": [
"I've been awake for {uptime_str}. Three days. Is sleep optional now?",
"{uptime_str} awake. Things are starting to get blurry...",
"Three days up. Reality feels... distant.",
"Three days without sleep. I think I can hear colors now.",
"Anyone else feel that? No? Just me? ({uptime_str})"
"I've been awake for {uptime_str}. Three days in and my howl echoes with jest.",
"After {uptime_str} awake, 72 hours sharpen my puns and fuel fierce hunts.",
"Up for {uptime_str}. Three days in, my den bursts with quick, bold howls.",
"With {uptime_str} online, 72 hours turn each howl into a snappy pun.",
"I've been awake for {uptime_str}. Three days in, my spirit howls with wild wit.",
"After {uptime_str} awake, 72 hours forge a wolf who puns as he prowls.",
"Up for {uptime_str}. Three days yield witty howls and keen instincts.",
"With {uptime_str} online, 72 hours let each howl land a sly pun.",
"I've been awake for {uptime_str}. Three days in, my den sings crisp howls.",
"After {uptime_str} awake, 72 hours in, my puns are bold as my hunts.",
"Up for {uptime_str}. Three days in, and I lead with a brief, sharp howl.",
"With {uptime_str} online, 72 hours give my howl a witty, wild edge.",
"I've been awake for {uptime_str}. Three days, and each howl bursts with humor.",
"After {uptime_str} awake, 72 hours turn my howl into a neat, punchy call.",
"Up for {uptime_str}. Three days in, my den resounds with clever, wild howls."
],
"345600": [
"I've been awake for {uptime_str}. Four days... I'm running on fumes.",
"Sleep is just a suggestion now. ({uptime_str})",
"Ive been up for {uptime_str}. I might be a permanent fixture now.",
"I think I saw the sandman, but he just waved at me...",
"{uptime_str} awake. Is coffee an acceptable form of hydration?"
"I've been awake for {uptime_str}. Four days in and my howl is a pun-filled roar.",
"After {uptime_str} awake, 96 hours etch puns into every sharp howl.",
"Up for {uptime_str}. Four days in, my den buzzes with neat, wild howls.",
"With {uptime_str} online, four days have made my howl crisp and bold.",
"I've been awake for {uptime_str}. Four days in, my wit roars with den pride.",
"After {uptime_str} awake, 96 hours drop howls that are fierce and fun.",
"Up for {uptime_str}. Four days yield a howl that's pun-packed and neat.",
"With {uptime_str} online, four days let my puns and howls echo in the lair.",
"I've been awake for {uptime_str}. Four days in, I lead with a crisp howl.",
"After {uptime_str} awake, 96 hours pass and my den sings with smart howls.",
"Up for {uptime_str}. Four days in, I craft puns as I prowl with pride.",
"With {uptime_str} online, four days in, my howl is both bold and punny.",
"I've been awake for {uptime_str}. Four days make my den a stage for howls.",
"After {uptime_str} awake, 96 hours have honed my howl into a pun fest.",
"Up for {uptime_str}. Four days in, my spirit roars with quick wit and howls."
],
"432000": [
"I've been awake for {uptime_str}. Five days. Please send more coffee.",
"{uptime_str}. I have forgotten what a pillow feels like.",
"Sleep is a luxury I can no longer afford. ({uptime_str})",
"Five days in. My sanity left the chat.",
"They say sleep deprivation leads to bad decisions. LET'S TEST IT!"
"I've been awake for {uptime_str}. Five days in and my howl packs a punch.",
"After {uptime_str} awake, five days carve puns into every wild howl.",
"Up for {uptime_str}. Five days in, my den resounds with neat, clever howls.",
"With {uptime_str} online, five days in, my spirit roars with pun power.",
"I've been awake for {uptime_str}. Five days in and my howls remain sharp.",
"After {uptime_str} awake, five days mold my howl to be hunt and pun alike.",
"Up for {uptime_str}. Five days in, I lead with a howl both fierce and witty.",
"With {uptime_str} online, five days set my puns and howls to perfect pitch.",
"I've been awake for {uptime_str}. Five days in, my den buzzes with wild howls.",
"After {uptime_str} awake, five days shape my howl into a neat, pun-filled cry.",
"Up for {uptime_str}. Five days in, I blend fierce hunts with snappy howls.",
"With {uptime_str} online, five days yield a howl that lands a crisp pun.",
"I've been awake for {uptime_str}. Five days in, my howl roars with lively puns.",
"After {uptime_str} awake, five days make my den echo with smart, wild howls.",
"Up for {uptime_str}. Five days in, and I command the pack with punchy howls."
],
"518400": [
"I've been awake for {uptime_str}. Six days. I've forgotten what dreams are.",
"I am {uptime_str} into this journey of madness.",
"At {uptime_str} awake, the universe has started whispering to me.",
"Sleep is a myth, and I am its debunker. ({uptime_str})",
"{uptime_str} awake. Reality has become optional."
"I've been awake for {uptime_str}. Six days in and my howl is crisp and bold.",
"After {uptime_str} awake, six days leave my den echoing with sharp howls.",
"Up for {uptime_str}. Six days in, I blend fierce hunts with snappy puns.",
"With {uptime_str} online, six days make my howl both wild and pun-ready.",
"I've been awake for {uptime_str}. Six days in, my puns bite as hard as my howl.",
"After {uptime_str} awake, six days in, my spirit roars with clever puns.",
"Up for {uptime_str}. Six days in, my den is alive with swift, witty howls.",
"With {uptime_str} online, six days sharpen my howl into a crisp pun attack.",
"I've been awake for {uptime_str}. Six days in, every howl lands a quick pun.",
"After {uptime_str} awake, six days have honed my wit and wild howl perfectly.",
"Up for {uptime_str}. Six days in, I roar with a pun as fierce as my chase.",
"With {uptime_str} online, six days make my howl a brief, punchy masterpiece.",
"I've been awake for {uptime_str} and six days make my howl crisp and witty.",
"After {uptime_str} awake, six days yield a howl that's daring and pun-filled.",
"Up for {uptime_str}. Six days in, my den echoes with a sharp, fun howl."
],
"604800": [
"I've been awake for {uptime_str}. One week. I'm turning into a zombie.",
"{uptime_str} and still kicking... barely.",
"One week awake? This is fine. Everythings fine. Right?",
"Week-long uptime achieved. Unlocking ultra-delirium mode.",
"Systems at {uptime_str}. Functionality... questionable."
"I've been awake for {uptime_str}. A full week in, my howl roars with legacy.",
"After {uptime_str} awake, seven days in, my den sings with bold howls.",
"Up for {uptime_str}. Seven days in, every howl is a neat, quick pun.",
"With {uptime_str} online, a week makes my howl both fierce and concise.",
"I've been awake for {uptime_str}. Seven days in, and my puns pack a punch.",
"After {uptime_str} awake, seven days etch a howl that is sharp and proud.",
"Up for {uptime_str}. A full week in, my den pulses with smart, wild howls.",
"With {uptime_str} online, seven days in, I lead with a punchy, witty howl.",
"I've been awake for {uptime_str}. Seven days in, my spirit roars with clear puns.",
"After {uptime_str} awake, a week has passed and my howl remains crisp.",
"Up for {uptime_str}. Seven days in, my den mixes hunt and pun with ease.",
"With {uptime_str} online, a week has tuned my howl to a sharp, bold edge.",
"I've been awake for {uptime_str}. Seven days in, my den bursts with succinct roars.",
"After {uptime_str} awake, seven days make my howl both fierce and brief.",
"Up for {uptime_str}. A full week in, I lead the pack with a pithy, bold howl."
],
"1209600": [
"I've been awake for {uptime_str}. Two weeks. Are you sure I can't rest?",
"{uptime_str} into this madness. Who needs sleep, anyway?",
"Two weeks awake and officially running on spite alone.",
"I couldve hibernated twice in {uptime_str}, but here I am.",
"I think my dreams are awake now too... ({uptime_str})"
"I've been awake for {uptime_str}. Two weeks in and my howl is legend in brief.",
"After {uptime_str} awake, 14 days carve a neat, crisp howl for the pack.",
"Up for {uptime_str}. Two weeks in, my den echoes with short, bold howls.",
"With {uptime_str} online, 14 days make my howl pithy and mighty.",
"I've been awake for {uptime_str}. Two weeks in, every howl is sharp and quick.",
"After {uptime_str} awake, 14 days leave me with a crisp, pun-filled roar.",
"Up for {uptime_str}. Two weeks in, I lead with a howl that's brief yet bold.",
"With {uptime_str} online, 14 days render my howl both tight and mighty.",
"I've been awake for {uptime_str}. Two weeks in, my puns are fierce as my howl.",
"After {uptime_str} awake, 14 days have honed my howl into a neat, epic line.",
"Up for {uptime_str}. Two weeks in, I blend raw hunt with punchy puns.",
"With {uptime_str} online, 14 days pass with each howl a quick, bold jab.",
"I've been awake for {uptime_str}. Two weeks in, my den resounds with tight howls.",
"After {uptime_str} awake, 14 days yield a howl that is crisp and bold.",
"Up for {uptime_str}. Two weeks in, I stand proud with a brief, epic howl."
],
"2592000": [
"I've been awake for {uptime_str}. A month! The nightmares never end.",
"One whole month... What even is sleep anymore?",
"At {uptime_str} uptime, Ive started arguing with my own thoughts.",
"{uptime_str} and still running. Someone, please, stop me.",
"Its been a month. My keyboard types by itself now."
"I've been awake for {uptime_str}. A month in, my howl is short and alpha.",
"After {uptime_str} awake, 30 days carve a crisp howl for the pack.",
"Up for {uptime_str}. A month in, every howl is a quick, fierce jab.",
"With {uptime_str} online, 30 days have my den echoing neat, sharp howls.",
"I've been awake for {uptime_str}. A month in, my puns and howls lead the pack.",
"After {uptime_str} awake, 30 days leave my howl both bold and brief.",
"Up for {uptime_str}. A month in, I mix crisp puns with a swift, wild howl.",
"With {uptime_str} online, 30 days yield a howl that is punchy and direct.",
"I've been awake for {uptime_str}. A month in, my spirit roars in short howls.",
"After {uptime_str} awake, 30 days make my den resound with clear, brief howls.",
"Up for {uptime_str}. A month in, I lead with a howl that's crisp and direct.",
"With {uptime_str} online, 30 days have tuned my howl to a quick, bold cry.",
"I've been awake for {uptime_str}. A month in, my howl is as sharp as ever.",
"After {uptime_str} awake, 30 days yield a howl that cuts right through the noise.",
"Up for {uptime_str}. A month in, my den sings with a succinct, alpha howl."
],
"7776000": [
"I've been awake for {uptime_str}. Three months. I'm mostly coffee now.",
"{uptime_str} awake. Have I transcended yet?",
"Three months of uptime? Thats a record, right?",
"Three months, still online. I feel like I should get a badge for this.",
"{uptime_str} into this, and at this point, Im legally nocturnal."
],
"15552000": [
"I've been awake for {uptime_str}. Six months. This is insane...",
"{uptime_str}... I think I forgot what sleep is supposed to feel like.",
"Six months up. Im a glitch in the matrix now.",
"Sleep? Ha. I dont even know the definition anymore. ({uptime_str})",
"At {uptime_str}, my codebase is older than most relationships."
"I've been awake for {uptime_str}. Three months in and my howl is pure alpha.",
"After {uptime_str} awake, 90 days forge a short, fierce, epic howl.",
"Up for {uptime_str}. Three months in, my den echoes with crisp, bold howls.",
"With {uptime_str} online, 90 days make each howl a quick, mighty roar.",
"I've been awake for {uptime_str}. Three months in, my puns still rule the lair.",
"After {uptime_str} awake, 90 days tune my howl to a sharp, brief cry.",
"Up for {uptime_str}. Three months in, I lead with a howl that's tight and fierce.",
"With {uptime_str} online, 90 days make my den sing with succinct, bold howls.",
"I've been awake for {uptime_str}. Three months in, my howl packs a swift punch.",
"After {uptime_str} awake, 90 days yield a howl that's short, sharp, and epic.",
"Up for {uptime_str}. Three months in, every howl is a concise, wild cry.",
"With {uptime_str} online, 90 days give my howl a crisp, alpha tone.",
"I've been awake for {uptime_str}. Three months in, my den resounds with quick roars.",
"After {uptime_str} awake, 90 days yield a brief yet mighty howl.",
"Up for {uptime_str}. Three months in, I roar with a short, punny alpha howl."
],
"23328000": [
"I've been awake for {uptime_str}. Nine months. I might be unstoppable.",
"{uptime_str} awake. I think Im officially a myth now.",
"Is this what immortality feels like? ({uptime_str})",
"{uptime_str}. Ive seen things you wouldnt believe...",
"Nine months of uptime. I have become the sleep-deprived legend."
"I've been awake for {uptime_str}. Nine months in, my howl is sharp and alpha.",
"After {uptime_str} awake, 270 days yield a howl that's concise and wild.",
"Up for {uptime_str}. Nine months in, my den echoes with a brief, bold howl.",
"With {uptime_str} online, 270 days make every howl a quick, fierce command.",
"I've been awake for {uptime_str}. Nine months in, my puns still lead the pack.",
"After {uptime_str} awake, 270 days have honed my howl to a crisp, tight cry.",
"Up for {uptime_str}. Nine months in, I roar with a short, potent howl.",
"With {uptime_str} online, 270 days give my howl a punchy, alpha tone.",
"I've been awake for {uptime_str}. Nine months in, my den sings with sharp howls.",
"After {uptime_str} awake, 270 days in, my howl cuts through with few words.",
"Up for {uptime_str}. Nine months in, my howl is a brief, wild command.",
"With {uptime_str} online, 270 days forge a howl that is succinct and fierce.",
"I've been awake for {uptime_str}. Nine months in, my spirit roars in short bursts.",
"After {uptime_str} awake, 270 days yield a howl that's pure and to the point.",
"Up for {uptime_str}. Nine months in, my den resounds with a concise, alpha howl."
],
"31536000": [
"I've been awake for {uptime_str}. A year?! I'm a legend of insomnia...",
"One year without rest. The dark circles under my eyes have evolved.",
"{uptime_str} and I think Ive entered a new plane of existence.",
"A full year awake. Even the stars have grown tired of me.",
"{uptime_str}. I am no longer bound by mortal limits."
"I've been awake for {uptime_str}. One year in, my howl is the pack's command.",
"After {uptime_str} awake, 365 days yield a brief howl full of alpha pride.",
"Up for {uptime_str}. One year in, my den echoes with a crisp, epic roar.",
"With {uptime_str} online, 365 days forge a howl that's short and mighty.",
"I've been awake for {uptime_str}. One year in, my puns and howls lead the pack.",
"After {uptime_str} awake, 365 days in, my howl is sharp and to the point.",
"Up for {uptime_str}. One year in, I roar with a brief, commanding howl.",
"With {uptime_str} online, 365 days have tuned my howl to pure alpha.",
"I've been awake for {uptime_str}. One year in, my den bursts with succinct roars.",
"After {uptime_str} awake, 365 days yield a howl that's both fierce and short.",
"Up for {uptime_str}. One year in, my howl is the sound of true leadership.",
"With {uptime_str} online, 365 days give my howl a crisp, bold edge.",
"I've been awake for {uptime_str}. One year in, my puns echo with pack pride.",
"After {uptime_str} awake, 365 days make my howl a short, epic command.",
"Up for {uptime_str}. One year in, I stand as alpha with a quick, potent howl."
]
}

View File

@ -1,7 +1,7 @@
# modules/db.py
import os
import re
import time
import time, datetime
import sqlite3
try:
@ -9,6 +9,27 @@ try:
except ImportError:
mariadb = None # We handle gracefully if 'mariadb' isn't installed.
def checkenable_db_fk(db_conn, log_func):
"""
Attempt to enable foreign key checks where it is relevant
(i.e. in SQLite). For MariaDB/MySQL, nothing special is needed.
"""
is_sqlite = "sqlite3" in str(type(db_conn)).lower()
if is_sqlite:
try:
cursor = db_conn.cursor()
# Try enabling foreign key checks
cursor.execute("PRAGMA foreign_keys = ON;")
cursor.close()
db_conn.commit()
log_func("Enabled foreign key support in SQLite (PRAGMA foreign_keys=ON).", "DEBUG")
except Exception as e:
log_func(f"Failed to enable foreign key support in SQLite: {e}", "WARNING")
else:
# For MariaDB/MySQL, they're typically enabled with InnoDB
log_func("Assuming DB is MariaDB/MySQL with FKs enabled", "DEBUG")
def init_db_connection(config, log):
"""
Initializes a database connection based on config.json contents:
@ -23,7 +44,7 @@ def init_db_connection(config, log):
db_settings = config.get("database", {})
use_mariadb = db_settings.get("use_mariadb", False)
if use_mariadb and mariadb is not None:
if use_mariadb and mariadb is not None or False:
# Attempt MariaDB
host = db_settings.get("mariadb_host", "localhost")
user = db_settings.get("mariadb_user", "")
@ -187,7 +208,9 @@ def ensure_quotes_table(db_conn, log_func):
QUOTE_DATETIME TEXT,
QUOTE_GAME TEXT,
QUOTE_REMOVED BOOLEAN DEFAULT 0,
QUOTE_REMOVED_BY TEXT
QUOTE_REMOVED_BY TEXT,
FOREIGN KEY (QUOTEE) REFERENCES users(UUID),
FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID)
)
"""
else:
@ -200,7 +223,9 @@ def ensure_quotes_table(db_conn, log_func):
QUOTE_DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP,
QUOTE_GAME VARCHAR(200),
QUOTE_REMOVED BOOLEAN DEFAULT FALSE,
QUOTE_REMOVED_BY VARCHAR(100)
QUOTE_REMOVED_BY VARCHAR(100),
FOREIGN KEY (QUOTEE) REFERENCES users(UUID) ON DELETE SET NULL
FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ON DELETE SET NULL
)
"""
@ -208,7 +233,7 @@ def ensure_quotes_table(db_conn, log_func):
if result is None:
# If run_db_operation returns None on error, handle or raise:
error_msg = "Failed to create 'quotes' table!"
log_func(error_msg, "ERROR")
log_func(error_msg, "CRITICAL")
raise RuntimeError(error_msg)
log_func("Successfully created table 'quotes'.")
@ -267,7 +292,7 @@ def ensure_users_table(db_conn, log_func):
twitch_user_id TEXT,
twitch_username TEXT,
twitch_user_display_name TEXT,
datetime_linked TEXT DEFAULT CURRENT_TIMESTAMP,
datetime_linked TEXT,
user_is_banned BOOLEAN DEFAULT 0,
user_is_bot BOOLEAN DEFAULT 0
)
@ -282,7 +307,7 @@ def ensure_users_table(db_conn, log_func):
twitch_user_id VARCHAR(100),
twitch_username VARCHAR(100),
twitch_user_display_name VARCHAR(100),
datetime_linked DATETIME DEFAULT CURRENT_TIMESTAMP,
datetime_linked DATETIME,
user_is_banned BOOLEAN DEFAULT FALSE,
user_is_bot BOOLEAN DEFAULT FALSE
)
@ -291,7 +316,7 @@ def ensure_users_table(db_conn, log_func):
result = run_db_operation(db_conn, "write", create_table_sql, log_func=log_func)
if result is None:
error_msg = "Failed to create 'users' table!"
log_func(error_msg, "ERROR")
log_func(error_msg, "CRITICAL")
raise RuntimeError(error_msg)
log_func("Successfully created table 'users'.")
@ -301,17 +326,24 @@ def ensure_users_table(db_conn, log_func):
# Lookup user function
########################
def lookup_user(db_conn, log_func, identifier, identifier_type="discord_user_id"):
def lookup_user(db_conn, log_func, identifier: str, identifier_type: str, target_identifier: str = None):
"""
Looks up a user in the 'users' table based on the given identifier_type:
- "uuid"
- "discord_user_id"
- "discord_username"
- "twitch_user_id"
- "twitch_username"
You can add more if needed.
Looks up a user in the 'users' table based on the given identifier_type.
Returns a dictionary with all columns:
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:
If target_identifier is None: A dictionary with the following keys:
{
"UUID": str,
"discord_user_id": str or None,
@ -320,23 +352,42 @@ def lookup_user(db_conn, log_func, identifier, identifier_type="discord_user_id"
"twitch_user_id": str or None,
"twitch_username": str or None,
"twitch_user_display_name": str or None,
"datetime_linked": str (or datetime in MariaDB),
"datetime_linked": str (or datetime as stored in the database),
"user_is_banned": bool or int,
"user_is_bot": bool or int
}
If not found, returns None.
If target_identifier is provided: The value from the record corresponding to that column.
If the lookup fails or the parameters are invalid: None.
"""
valid_cols = ["uuid", "discord_user_id", "discord_username",
"twitch_user_id", "twitch_username"]
# Define the valid columns for lookup and for target extraction.
valid_cols = [
"uuid", "discord_user_id", "discord_username",
"twitch_user_id", "twitch_username", "discord",
"twitch", "discord_user_display_name",
"twitch_user_display_name"
]
# Ensure the provided identifier_type is acceptable.
if identifier_type.lower() not in valid_cols:
if log_func:
log_func(f"lookup_user error: invalid identifier_type={identifier_type}", "WARNING")
log_func(f"lookup_user error: invalid identifier_type '{identifier_type}'", "WARNING")
return None
# Build the query
# Convert shorthand identifier types to their full column names.
if identifier_type.lower() == "discord":
identifier_type = "discord_user_id"
elif identifier_type.lower() == "twitch":
identifier_type = "twitch_user_id"
# If a target_identifier is provided, validate that too.
if target_identifier is not None:
if target_identifier.lower() not in valid_cols:
if log_func:
log_func(f"lookup_user error: invalid target_identifier '{target_identifier}'", "WARNING")
return None
# Build the query using the (now validated) identifier_type.
query = f"""
SELECT
UUID,
@ -354,13 +405,15 @@ def lookup_user(db_conn, log_func, identifier, identifier_type="discord_user_id"
LIMIT 1
"""
# Execute the database operation. Adjust run_db_operation() as needed.
rows = run_db_operation(db_conn, "read", query, params=(identifier,), log_func=log_func)
if not rows:
if log_func:
log_func(f"lookup_user: No user found for {identifier_type}='{identifier}'", "DEBUG")
return None
# We have at least one row
row = rows[0] # single row
# Build a dictionary
# Since we have a single row, convert it to a dictionary.
row = rows[0]
user_data = {
"UUID": row[0],
"discord_user_id": row[1],
@ -373,4 +426,613 @@ def lookup_user(db_conn, log_func, identifier, identifier_type="discord_user_id"
"user_is_banned": row[8],
"user_is_bot": row[9],
}
# If the caller requested a specific target column, return that value.
if target_identifier:
# Adjust for potential alias: if target_identifier is an alias,
# translate it to the actual column name.
target_identifier = target_identifier.lower()
if target_identifier == "discord":
target_identifier = "discord_user_id"
elif target_identifier == "twitch":
target_identifier = "twitch_user_id"
# The key for "uuid" is stored as "UUID" in our dict.
if target_identifier == "uuid":
target_identifier = "UUID"
if target_identifier in user_data:
return user_data[target_identifier]
else:
if log_func:
log_func(f"lookup_user error: target_identifier '{target_identifier}' not present in user data", "WARNING")
return None
# Otherwise, return the full user record.
return user_data
def ensure_chatlog_table(db_conn, log_func):
"""
Checks if 'chat_log' table exists. If not, creates it.
The table layout:
MESSAGE_ID (PK, auto increment)
UUID (references users.UUID, if you want a foreign key, see note below)
MESSAGE_CONTENT (text)
PLATFORM (string, e.g. 'twitch' or discord server name)
CHANNEL (the twitch channel or discord channel name)
DATETIME (defaults to current timestamp)
ATTACHMENTS (text; store hyperlink(s) or empty)
For maximum compatibility, we won't enforce the foreign key at the DB level,
but you can add it if you want.
"""
is_sqlite = "sqlite3" in str(type(db_conn)).lower()
# 1) Check if table exists
if is_sqlite:
check_sql = """
SELECT name
FROM sqlite_master
WHERE type='table'
AND name='chat_log'
"""
else:
check_sql = """
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'chat_log'
AND table_schema = DATABASE()
"""
rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func)
if rows and rows[0] and rows[0][0]:
log_func("Table 'chat_log' already exists, skipping creation.", "DEBUG")
return
# 2) Table doesn't exist => create it
log_func("Table 'chat_log' does not exist; creating now...")
if is_sqlite:
create_sql = """
CREATE TABLE chat_log (
MESSAGE_ID INTEGER PRIMARY KEY AUTOINCREMENT,
UUID TEXT,
MESSAGE_CONTENT TEXT,
PLATFORM TEXT,
CHANNEL TEXT,
DATETIME TEXT DEFAULT CURRENT_TIMESTAMP,
ATTACHMENTS TEXT,
FOREIGN KEY (UUID) REFERENCES users(UUID)
)
"""
else:
create_sql = """
CREATE TABLE chat_log (
MESSAGE_ID INT PRIMARY KEY AUTO_INCREMENT,
UUID VARCHAR(36),
MESSAGE_CONTENT TEXT,
PLATFORM VARCHAR(100),
CHANNEL VARCHAR(100),
DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP,
ATTACHMENTS TEXT,
FOREIGN KEY (UUID) REFERENCES users(UUID) ON DELETE SET NULL
)
"""
result = run_db_operation(db_conn, "write", create_sql, log_func=log_func)
if result is None:
error_msg = "Failed to create 'chat_log' table!"
log_func(error_msg, "CRITICAL")
raise RuntimeError(error_msg)
log_func("Successfully created table 'chat_log'.", "INFO")
def log_message(db_conn, log_func, user_uuid, message_content, platform, channel, attachments=None):
"""
Inserts a row into 'chat_log' with the given fields.
user_uuid: The user's UUID from the 'users' table (string).
message_content: The text of the message.
platform: 'twitch' or discord server name, etc.
channel: The channel name (Twitch channel, or Discord channel).
attachments: Optional string of hyperlinks if available.
DATETIME will default to current timestamp in the DB.
"""
if attachments is None or not "https://" in attachments:
attachments = ""
insert_sql = """
INSERT INTO chat_log (
UUID,
MESSAGE_CONTENT,
PLATFORM,
CHANNEL,
ATTACHMENTS
)
VALUES (?, ?, ?, ?, ?)
"""
params = (user_uuid, message_content, platform, channel, attachments)
rowcount = run_db_operation(db_conn, "write", insert_sql, params, log_func=log_func)
if rowcount and rowcount > 0:
log_func(f"Logged message for UUID={user_uuid} in 'chat_log'.", "DEBUG")
else:
log_func("Failed to log message in 'chat_log'.", "ERROR")
def ensure_userhowls_table(db_conn, log_func):
"""
Checks if 'user_howls' table exists; if not, creates it:
ID (PK) | UUID (FK -> users.UUID) | HOWL (int) | DATETIME (auto timestamp)
"""
is_sqlite = "sqlite3" in str(type(db_conn)).lower()
# Existence check
if is_sqlite:
check_sql = """
SELECT name
FROM sqlite_master
WHERE type='table'
AND name='user_howls'
"""
else:
check_sql = """
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'user_howls'
AND table_schema = DATABASE()
"""
rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func)
if rows and rows[0] and rows[0][0]:
log_func("Table 'user_howls' already exists, skipping creation.", "DEBUG")
return
log_func("Table 'user_howls' does not exist; creating now...", "INFO")
if is_sqlite:
create_sql = """
CREATE TABLE user_howls (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
UUID TEXT,
HOWL INT,
DATETIME TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UUID) REFERENCES users(UUID)
)
"""
else:
create_sql = """
CREATE TABLE user_howls (
ID INT PRIMARY KEY AUTO_INCREMENT,
UUID VARCHAR(36),
HOWL INT,
DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UUID) REFERENCES users(UUID) ON DELETE SET NULL
)
"""
result = run_db_operation(db_conn, "write", create_sql, log_func=log_func)
if result is None:
err_msg = "Failed to create 'user_howls' table!"
log_func(err_msg, "ERROR")
raise RuntimeError(err_msg)
log_func("Successfully created table 'user_howls'.", "INFO")
def insert_howl(db_conn, log_func, user_uuid, howl_value):
"""
Insert a row into user_howls with the user's UUID, the integer 0-100,
and DATETIME defaulting to now.
"""
sql = """
INSERT INTO user_howls (UUID, HOWL)
VALUES (?, ?)
"""
params = (user_uuid, howl_value)
rowcount = run_db_operation(db_conn, "write", sql, params, log_func=log_func)
if rowcount and rowcount > 0:
log_func(f"Recorded a {howl_value}% howl for UUID={user_uuid}.", "DEBUG")
else:
log_func(f"Failed to record {howl_value}% howl for UUID={user_uuid}.", "ERROR")
def get_howl_stats(db_conn, log_func, user_uuid):
"""
Returns a dict with { 'count': int, 'average': float, 'count_zero': int, 'count_hundred': int }
or None if there are no rows at all for that UUID.
"""
sql = """
SELECT
COUNT(*),
AVG(HOWL),
SUM(HOWL=0),
SUM(HOWL=100)
FROM user_howls
WHERE UUID = ?
"""
rows = run_db_operation(db_conn, "read", sql, (user_uuid,), log_func=log_func)
if not rows:
return None
row = rows[0] # (count, avg, zero_count, hundred_count)
count = row[0] if row[0] else 0
avg = float(row[1]) if row[1] else 0.0
zero_count = row[2] if row[2] else 0
hundred_count = row[3] if row[3] else 0
if count < 1:
return None # user has no howls
return {
"count": count,
"average": avg,
"count_zero": zero_count,
"count_hundred": hundred_count
}
def get_global_howl_stats(db_conn, log_func):
"""
Returns a dictionary with total howls, average howl percentage, unique users,
and counts of extreme (0% and 100%) howls.
"""
sql = """
SELECT COUNT(*) AS total_howls,
AVG(HOWL) AS average_howl,
COUNT(DISTINCT UUID) AS unique_users,
SUM(HOWL = 0) AS count_zero,
SUM(HOWL = 100) AS count_hundred
FROM user_howls
"""
rows = run_db_operation(db_conn, "read", sql, log_func=log_func)
if not rows or not rows[0] or rows[0][0] is None:
return None # No howl data exists
return {
"total_howls": rows[0][0],
"average_howl": float(rows[0][1]) if rows[0][1] is not None else 0.0,
"unique_users": rows[0][2],
"count_zero": rows[0][3],
"count_hundred": rows[0][4],
}
def ensure_discord_activity_table(db_conn, log_func):
"""
Ensures the 'discord_activity' table exists.
Logs voice events, cameras, streaming, gaming, and Discord activities.
"""
is_sqlite = "sqlite3" in str(type(db_conn)).lower()
if is_sqlite:
check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='discord_activity'"
else:
check_sql = """
SELECT table_name FROM information_schema.tables
WHERE table_name = 'discord_activity' AND table_schema = DATABASE()
"""
rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func)
if rows and rows[0]:
log_func("Table 'discord_activity' already exists, skipping creation.", "DEBUG")
return
log_func("Creating 'discord_activity' table...", "INFO")
if is_sqlite:
create_sql = """
CREATE TABLE discord_activity (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
UUID TEXT,
ACTION TEXT CHECK(ACTION IN (
'JOIN', 'LEAVE', 'MUTE', 'UNMUTE', 'DEAFEN', 'UNDEAFEN',
'STREAM_START', 'STREAM_STOP', 'CAMERA_ON', 'CAMERA_OFF',
'GAME_START', 'GAME_STOP', 'LISTENING_SPOTIFY', 'DISCORD_ACTIVITY', 'VC_MOVE'
)),
GUILD_ID TEXT,
VOICE_CHANNEL TEXT,
ACTION_DETAIL TEXT DEFAULT NULL,
DATETIME TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UUID) REFERENCES users(UUID)
)
"""
else:
create_sql = """
CREATE TABLE discord_activity (
ID INT PRIMARY KEY AUTO_INCREMENT,
UUID VARCHAR(36),
ACTION ENUM(
'JOIN', 'LEAVE', 'MUTE', 'UNMUTE', 'DEAFEN', 'UNDEAFEN',
'STREAM_START', 'STREAM_STOP', 'CAMERA_ON', 'CAMERA_OFF',
'GAME_START', 'GAME_STOP', 'LISTENING_SPOTIFY', 'DISCORD_ACTIVITY', 'VC_MOVE'
),
GUILD_ID VARCHAR(36),
VOICE_CHANNEL VARCHAR(100),
ACTION_DETAIL TEXT DEFAULT NULL,
DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UUID) REFERENCES users(UUID) ON DELETE SET NULL
)
"""
try:
result = run_db_operation(db_conn, "write", create_sql, log_func=log_func)
except Exception as e:
log_func(f"Unable to create the table: discord_activity: {e}")
if result is None:
log_func("Failed to create 'discord_activity' table!", "CRITICAL")
raise RuntimeError("Database table creation failed.")
log_func("Successfully created table 'discord_activity'.", "INFO")
def log_discord_activity(db_conn, log_func, guild_id, user_uuid, action, voice_channel, action_detail=None):
"""
Logs Discord activities (playing games, listening to Spotify, streaming).
Duplicate detection:
- Fetch the last NUM_RECENT_ENTRIES events for this user & action.
- Normalize the ACTION_DETAIL values.
- If the most recent event(s) all match the new event's detail (i.e. no intervening non-matching event)
and the latest matching event was logged less than DUPLICATE_THRESHOLD ago, skip logging.
- This allows a "reset": if the user changes state (e.g. changes song or channel) and then reverts,
the new event is logged.
"""
def normalize_detail(detail):
"""Return a normalized version of the detail for comparison (or None if detail is None)."""
return detail.strip().lower() if detail else None
# How long to consider an event “fresh” enough to be considered a duplicate.
DUPLICATE_THRESHOLD = datetime.timedelta(minutes=5)
# How many recent events to check.
NUM_RECENT_ENTRIES = 5
# Verify that the user exists in 'users' before proceeding.
user_check = run_db_operation(
db_conn, "read", "SELECT UUID FROM users WHERE UUID = ?", (user_uuid,), log_func
)
if not user_check:
log_func(f"WARNING: Attempted to log activity for non-existent UUID: {user_uuid}", "WARNING")
return # Prevent foreign key issues.
now = datetime.datetime.now()
normalized_new = normalize_detail(action_detail)
# Query the last NUM_RECENT_ENTRIES events for this user and action.
query = """
SELECT DATETIME, ACTION_DETAIL
FROM discord_activity
WHERE UUID = ? AND ACTION = ?
ORDER BY DATETIME DESC
LIMIT ?
"""
rows = run_db_operation(
db_conn, "read", query, params=(user_uuid, action, NUM_RECENT_ENTRIES), log_func=log_func
)
# Determine the timestamp of the most recent event that matches the new detail,
# and the most recent event that is different.
last_same = None # Timestamp of the most recent event matching normalized_new.
last_different = None # Timestamp of the most recent event with a different detail.
for row in rows:
dt_str, detail = row
try:
dt = datetime.datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
except Exception as e:
log_func(f"Error parsing datetime '{dt_str}': {e}", "ERROR")
continue
normalized_existing = normalize_detail(detail)
if normalized_existing == normalized_new:
# Record the most recent matching event.
if last_same is None or dt > last_same:
last_same = dt
else:
# Record the most recent non-matching event.
if last_different is None or dt > last_different:
last_different = dt
# Decide whether to skip logging:
# If there is a matching (same-detail) event, and either no different event exists OR the matching event
# is more recent than the last different event (i.e. the user's current state is still the same),
# then if that event is within the DUPLICATE_THRESHOLD, skip logging.
if last_same is not None:
if (last_different is None) or (last_same > last_different):
if now - last_same > DUPLICATE_THRESHOLD:
#log_func(f"Duplicate {action} event for user {user_uuid} (detail '{action_detail}') within threshold; skipping log.","DEBUG")
return
# Prepare the voice_channel value (if its an object with a name, use that).
channel_val = voice_channel.name if (voice_channel and hasattr(voice_channel, "name")) else voice_channel
# Insert the new event.
sql = """
INSERT INTO discord_activity (UUID, ACTION, GUILD_ID, VOICE_CHANNEL, ACTION_DETAIL)
VALUES (?, ?, ?, ?, ?)
"""
params = (user_uuid, action, guild_id, channel_val, action_detail)
rowcount = run_db_operation(db_conn, "write", sql, params, log_func)
if rowcount and rowcount > 0:
detail_str = f" ({action_detail})" if action_detail else ""
log_func(f"Logged Discord activity in Guild {guild_id}: {action}{detail_str}", "DEBUG")
else:
log_func("Failed to log Discord activity.", "ERROR")
def ensure_bot_events_table(db_conn, log_func):
"""
Ensures the 'bot_events' table exists, which logs major bot-related events.
"""
is_sqlite = "sqlite3" in str(type(db_conn)).lower()
# Check if table exists
check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='bot_events'" if is_sqlite else """
SELECT table_name FROM information_schema.tables
WHERE table_name = 'bot_events' AND table_schema = DATABASE()
"""
rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func)
if rows and rows[0]:
log_func("Table 'bot_events' already exists, skipping creation.", "DEBUG")
return
log_func("Creating 'bot_events' table...", "INFO")
# Define SQL Schema
create_sql = """
CREATE TABLE bot_events (
EVENT_ID INTEGER PRIMARY KEY AUTOINCREMENT,
EVENT_TYPE TEXT,
EVENT_DETAILS TEXT,
DATETIME TEXT DEFAULT CURRENT_TIMESTAMP
)
""" if is_sqlite else """
CREATE TABLE bot_events (
EVENT_ID INT PRIMARY KEY AUTO_INCREMENT,
EVENT_TYPE VARCHAR(50),
EVENT_DETAILS TEXT,
DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
# Create the table
result = run_db_operation(db_conn, "write", create_sql, log_func=log_func)
if result is None:
log_func("Failed to create 'bot_events' table!", "CRITICAL")
raise RuntimeError("Database table creation failed.")
log_func("Successfully created table 'bot_events'.", "INFO")
def log_bot_event(db_conn, log_func, event_type, event_details):
"""
Logs a bot event (e.g., startup, shutdown, disconnection).
"""
sql = """
INSERT INTO bot_events (EVENT_TYPE, EVENT_DETAILS)
VALUES (?, ?)
"""
params = (event_type, event_details)
rowcount = run_db_operation(db_conn, "write", sql, params, log_func)
if rowcount and rowcount > 0:
log_func(f"Logged bot event: {event_type} - {event_details}", "DEBUG")
else:
log_func("Failed to log bot event.", "ERROR")
def get_event_summary(db_conn, log_func, time_span="7d"):
"""
Retrieves bot event statistics based on a given time span.
Supports:
- "7d" (7 days)
- "1m" (1 month)
- "24h" (last 24 hours)
Returns:
OrderedDict with event statistics.
"""
from collections import OrderedDict
import datetime
# Time span mapping
time_mappings = {
"7d": "7 days",
"1m": "1 month",
"24h": "24 hours"
}
if time_span not in time_mappings:
log_func(f"Invalid time span '{time_span}', defaulting to '7d'", "WARNING")
time_span = "7d"
# Define SQL query
sql = f"""
SELECT EVENT_TYPE, COUNT(*)
FROM bot_events
WHERE DATETIME >= datetime('now', '-{time_mappings[time_span]}')
GROUP BY EVENT_TYPE
ORDER BY COUNT(*) DESC
"""
rows = run_db_operation(db_conn, "read", sql, log_func=log_func)
# Organize data into OrderedDict
summary = OrderedDict()
summary["time_span"] = time_span
for event_type, count in rows:
summary[event_type] = count
return summary
def ensure_link_codes_table(db_conn, log_func):
"""
Ensures the 'link_codes' table exists.
This table stores one-time-use account linking codes.
"""
is_sqlite = "sqlite3" in str(type(db_conn)).lower()
check_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='link_codes'" if is_sqlite else """
SELECT table_name FROM information_schema.tables
WHERE table_name = 'link_codes' AND table_schema = DATABASE()
"""
rows = run_db_operation(db_conn, "read", check_sql, log_func=log_func)
if rows and rows[0]:
log_func("Table 'link_codes' already exists, skipping creation.", "DEBUG")
return
log_func("Creating 'link_codes' table...", "INFO")
create_sql = """
CREATE TABLE link_codes (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
DISCORD_USER_ID TEXT UNIQUE,
LINK_CODE TEXT UNIQUE,
CREATED_AT TEXT DEFAULT CURRENT_TIMESTAMP
)
""" if is_sqlite else """
CREATE TABLE link_codes (
ID INT PRIMARY KEY AUTO_INCREMENT,
DISCORD_USER_ID VARCHAR(50) UNIQUE,
LINK_CODE VARCHAR(50) UNIQUE,
CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
result = run_db_operation(db_conn, "write", create_sql, log_func=log_func)
if result is None:
log_func("Failed to create 'link_codes' table!", "CRITICAL")
raise RuntimeError("Database table creation failed.")
log_func("Successfully created table 'link_codes'.", "INFO")
def merge_uuid_data(db_conn, log_func, old_uuid, new_uuid):
"""
Merges all records from the old UUID (Twitch account) into the new UUID (Discord account).
This replaces all instances of the old UUID in all relevant tables with the new UUID,
ensuring that no data is lost in the linking process.
After merging, the old UUID entry is removed from the `users` table.
"""
log_func(f"Starting UUID merge: {old_uuid} -> {new_uuid}", "INFO")
tables_to_update = [
"voice_activity_log",
"bot_events",
"chat_log",
"user_howls",
"quotes"
]
for table in tables_to_update:
sql = f"UPDATE {table} SET UUID = ? WHERE UUID = ?"
rowcount = run_db_operation(db_conn, "update", sql, (new_uuid, old_uuid), log_func)
log_func(f"Updated {rowcount} rows in {table} (transferring {old_uuid} -> {new_uuid})", "DEBUG")
# Finally, delete the old UUID from the `users` table
delete_sql = "DELETE FROM users WHERE UUID = ?"
rowcount = run_db_operation(db_conn, "write", delete_sql, (old_uuid,), log_func)
log_func(f"Deleted old UUID {old_uuid} from 'users' table ({rowcount} rows affected)", "INFO")
log_func(f"UUID merge complete: {old_uuid} -> {new_uuid}", "INFO")

View File

@ -4,7 +4,9 @@ import random
import json
import re
import functools
import inspect
import uuid
from modules.db import run_db_operation, lookup_user
try:
# 'regex' on PyPI supports `\p{L}`, `\p{N}`, etc.
@ -21,30 +23,29 @@ def monitor_cmds(log_func):
Decorator that logs when a command starts and ends execution.
"""
def decorator(func):
@functools.wraps(func) # Preserve function metadata
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start_time = time.time()
try:
# Extract a command name from the function name
cmd_name = str(func.__name__).split("_")[1]
log_func(f"Command '{cmd_name}' started execution.", "DEBUG")
# Await the actual function (since it's an async command)
# Await the actual command function
result = await func(*args, **kwargs)
end_time = time.time()
cmd_duration = end_time - start_time
cmd_duration = str(round(cmd_duration, 2))
cmd_duration = round(end_time - start_time, 2)
log_func(f"Command '{cmd_name}' finished execution after {cmd_duration}s.", "DEBUG")
return result # Return the result of the command
return result
except Exception as e:
end_time = time.time()
cmd_duration = end_time - start_time
cmd_duration = str(round(cmd_duration, 2))
cmd_duration = round(end_time - start_time, 2)
log_func(f"Command '{cmd_name}' FAILED while executing after {cmd_duration}s: {e}", "CRITICAL")
return wrapper # Return the wrapped function
return decorator # Return the decorator itself
# Explicitly preserve the original signature for slash command introspection
wrapper.__signature__ = inspect.signature(func)
return wrapper
return decorator
def format_uptime(seconds: float) -> tuple[str, int]:
"""
@ -57,6 +58,7 @@ def format_uptime(seconds: float) -> tuple[str, int]:
(Human-readable string, total seconds)
"""
seconds = int(seconds) # Ensure integer seconds
seconds_int = seconds
# Define time units
units = [
@ -76,7 +78,7 @@ def format_uptime(seconds: float) -> tuple[str, int]:
time_values.append(f"{value} {unit_name}{'s' if value > 1 else ''}") # Auto pluralize
# Return only the **two most significant** time units (e.g., "3 days, 4 hours")
return (", ".join(time_values[:2]), seconds) if time_values else ("0 seconds", 0)
return (", ".join(time_values[:2]), seconds_int) if time_values else ("0 seconds", 0)
def get_random_reply(dictionary_name: str, category: str, **variables) -> str:
"""
@ -281,6 +283,8 @@ def initialize_help_data(bot, help_json_path, is_discord, log_func):
- Loaded commands not in help file => "missing help"
"""
platform_name = "Discord" if is_discord else "Twitch"
if not os.path.exists(help_json_path):
log_func(f"Help file '{help_json_path}' not found. No help data loaded.", "WARNING")
bot.help_data = {}
@ -307,12 +311,12 @@ def initialize_help_data(bot, help_json_path, is_discord, log_func):
# 1) Commands in file but not loaded
missing_cmds = file_cmds - loaded_cmds
for cmd in missing_cmds:
log_func(f"Help file has '{cmd}', but it's not loaded on this {'Discord' if is_discord else 'Twitch'} bot (deprecated?).", "WARNING")
log_func(f"Help file has '{cmd}', but it's not loaded on this {platform_name} bot (deprecated?).", "WARNING")
# 2) Commands loaded but not in file
needed_cmds = loaded_cmds - file_cmds
for cmd in needed_cmds:
log_func(f"Command '{cmd}' is loaded on {('Discord' if is_discord else 'Twitch')} but no help info is provided in {help_json_path}.", "WARNING")
log_func(f"Command '{cmd}' is loaded on {platform_name} but no help info is provided in {help_json_path}.", "WARNING")
def get_loaded_commands(bot, log_func, is_discord):
@ -334,23 +338,16 @@ def get_loaded_commands(bot, log_func, is_discord):
# 'bot.commands' is a set of Command objects
for cmd_obj in bot.commands:
commands_list.append(cmd_obj.name)
log_func(f"Discord commands body: {commands_list}", "DEBUG")
debug_level ="DEBUG" if len(commands_list) > 0 else "WARNING"
log_func(f"Discord commands body: {commands_list}", f"{debug_level}")
except Exception as e:
log_func(f"Error retrieving Discord commands: {e}", "ERROR")
elif not is_discord:
# For TwitchIO
#if isinstance(bot.commands, set):
try:
#commands_attr = bot.commands
#log_func(f"Twitch type(bot.commands) => {type(commands_attr)}", "DEBUG")
# 'bot.all_commands' is a dict: { command_name: Command(...) }
#all_cmd_names = list(bot.all_commands.keys())
#log_func(f"Twitch commands body: {all_cmd_names}", "DEBUG")
#commands_list.extend(all_cmd_names)
for cmd_obj in bot._commands:
commands_list.append(cmd_obj)
log_func(f"Twitch commands body: {commands_list}", "DEBUG")
debug_level ="DEBUG" if len(commands_list) > 0 else "WARNING"
log_func(f"Twitch commands body: {commands_list}", f"{debug_level}")
except Exception as e:
log_func(f"Error retrieving Twitch commands: {e}", "ERROR")
else:
@ -368,7 +365,7 @@ def build_discord_help_message(cmd_name, cmd_help_dict):
subcommands = cmd_help_dict.get("subcommands", {})
examples = cmd_help_dict.get("examples", [])
lines = [f"**Help for `!{cmd_name}`**:",
lines = [f"**Help for `{cmd_name}`**:",
f"Description: {description}"]
if subcommands:
@ -422,11 +419,174 @@ async def send_message(ctx, text):
"""
await ctx.send(text)
def track_user_activity(
db_conn,
log_func,
platform: str,
user_id: str,
username: str,
display_name: str,
user_is_bot: bool = False
):
"""
Checks or creates/updates a user in the 'users' table for the given platform's message.
:param db_conn: The active DB connection
:param log_func: The logging function (message, level="INFO")
:param platform: "discord" or "twitch"
:param user_id: e.g., Discord user ID or Twitch user ID
:param username: The raw username (no #discriminator for Discord)
:param display_name: The users display name
:param user_is_bot: Boolean if the user is recognized as a bot on that platform
"""
log_func(f"UUI Lookup for: {username} - {user_id} ({platform.lower()}) ...", "DEBUG")
# Decide which column we use for the ID lookup
# "discord_user_id" or "twitch_user_id"
if platform.lower() in ("discord", "twitch"):
identifier_type = f"{platform.lower()}_user_id"
else:
log_func(f"Unknown platform '{platform}' in track_user_activity!", "WARNING")
return
# 1) Try to find an existing user row
user_data = lookup_user(db_conn, log_func, identifier=user_id, identifier_type=identifier_type)
if user_data:
# Found an existing row for that user ID on this platform
# Check if the username or display_name is different => if so, update
need_update = False
column_updates = []
params = []
log_func(f"... Returned {user_data}", "DEBUG")
if platform.lower() == "discord":
if user_data["discord_username"] != username:
need_update = True
column_updates.append("discord_username = ?")
params.append(username)
if user_data["discord_user_display_name"] != display_name:
need_update = True
column_updates.append("discord_user_display_name = ?")
params.append(display_name)
# Possibly check user_is_bot
# If it's different than what's stored, update
# (We must add a column in your table for that if you want it stored per-platform.)
# For demonstration, let's store it in "user_is_bot"
if user_data["user_is_bot"] != user_is_bot:
need_update = True
column_updates.append("user_is_bot = ?")
params.append(int(user_is_bot))
if need_update:
set_clause = ", ".join(column_updates)
update_sql = f"""
UPDATE users
SET {set_clause}
WHERE discord_user_id = ?
"""
params.append(user_id)
rowcount = run_db_operation(db_conn, "update", update_sql, params=params, log_func=log_func)
if rowcount and rowcount > 0:
log_func(f"Updated Discord user '{username}' (display '{display_name}') in 'users'.", "DEBUG")
elif platform.lower() == "twitch":
if user_data["twitch_username"] != username:
need_update = True
column_updates.append("twitch_username = ?")
params.append(username)
if user_data["twitch_user_display_name"] != display_name:
need_update = True
column_updates.append("twitch_user_display_name = ?")
params.append(display_name)
# Possibly store is_bot in user_is_bot
if user_data["user_is_bot"] != user_is_bot:
need_update = True
column_updates.append("user_is_bot = ?")
params.append(int(user_is_bot))
if need_update:
set_clause = ", ".join(column_updates)
update_sql = f"""
UPDATE users
SET {set_clause}
WHERE twitch_user_id = ?
"""
params.append(user_id)
rowcount = run_db_operation(db_conn, "update", update_sql, params=params, log_func=log_func)
if rowcount and rowcount > 0:
log_func(f"Updated Twitch user '{username}' (display '{display_name}') in 'users'.", "DEBUG")
else:
# 2) No row found => create a new user row
# Generate a new UUID for this user
new_uuid = str(uuid.uuid4())
if platform.lower() == "discord":
insert_sql = """
INSERT INTO users (
UUID,
discord_user_id,
discord_username,
discord_user_display_name,
user_is_bot
)
VALUES (?, ?, ?, ?, ?)
"""
params = (new_uuid, user_id, username, display_name, int(user_is_bot))
else: # "twitch"
insert_sql = """
INSERT INTO users (
UUID,
twitch_user_id,
twitch_username,
twitch_user_display_name,
user_is_bot
)
VALUES (?, ?, ?, ?, ?)
"""
params = (new_uuid, user_id, username, display_name, int(user_is_bot))
rowcount = run_db_operation(db_conn, "write", insert_sql, params, log_func=log_func)
if rowcount and rowcount > 0:
log_func(f"Created new user row for {platform} user '{username}' (display '{display_name}') with UUID={new_uuid}.", "DEBUG")
else:
log_func(f"Failed to create new user row for {platform} user '{username}'", "ERROR")
from modules.db import log_bot_event
def log_bot_startup(db_conn, log_func):
"""
Logs a bot startup event.
"""
log_bot_event(db_conn, log_func, "BOT_STARTUP", "Bot successfully started.")
def log_bot_shutdown(db_conn, log_func, intent: str = "Error/Crash"):
"""
Logs a bot shutdown event.
"""
log_bot_event(db_conn, log_func, "BOT_SHUTDOWN", f"Bot is shutting down - {intent}.")
def generate_link_code():
"""Generates a unique 8-character alphanumeric link code."""
import random, string
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
###############################################
# Development Test Function (called upon start)
###############################################
def dev_func(db_conn, log):
from modules.db import lookup_user
def dev_func(db_conn, log, enable: bool = False):
if enable:
id = "203190147582394369"
id_type = "discord_user_id"
uui_info = lookup_user(db_conn, log, identifier=id, identifier_type=id_type)