Big quote system overhaul

- Returned "quote" commands to textual commands type
- Rewritten quote system backend to ensure stability
- Added new quote subcommands:
  - `!quote search [keywords]` allow users to search for a quote using keywords, and returns the best match. If several equally good matches are found, returns one of them at random
  - `!quote info [quote_id]` allows users to see more info about a given quote. Grants more information on Discord
  - `!quote restore [quote_id]` allows users to restore a previously removed quote
  - `!quote last/latest/newest` allows users to get the newest quote
- Added new quote features to Discord helpfile
- Moved database init to globals.init_db_conn
- Associated unlinked usernames now default to that of the other platform, appended with "({platform} unlinked)". This should ensure consistensy despite users not linking accounts in the UAL system
- Added time_since(start, end, format) function to easily get formatted time differences, eg. for execution time reporting
- Added "wfstl" and "wfetl" helper function under utility. Useful for debugging when a function starts and ends manually if needed.
- Minor tweaks, corrections, bugfixes
kami_dev
Kami 2025-02-12 00:15:39 +01:00
parent 71505b4de1
commit 1b141c10fb
9 changed files with 673 additions and 139 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ Test ?/
permissions.json permissions.json
local_database.sqlite local_database.sqlite
logo.* logo.*
_SQL_PREFILL_QUERIES_

View File

@ -38,14 +38,7 @@ async def main():
# Log initial start # Log initial start
globals.log("--------------- BOT STARTUP ---------------") globals.log("--------------- BOT STARTUP ---------------")
# Before creating your DiscordBot/TwitchBot, initialize DB # Before creating your DiscordBot/TwitchBot, initialize DB
try: db_conn = globals.init_db_conn()
db_conn = db.init_db_connection(config_data)
if not db_conn:
# If we get None, it means FATAL. We might sys.exit(1) or handle it differently.
globals.log("Terminating bot due to no DB connection.", "FATAL")
sys.exit(1)
except Exception as e:
globals.log(f"Unable to initialize database!: {e}", "FATAL")
try: # Ensure FKs are enabled try: # Ensure FKs are enabled
db.checkenable_db_fk(db_conn) db.checkenable_db_fk(db_conn)

View File

@ -27,7 +27,7 @@ def handle_howl_command(ctx) -> str:
We rely on ctx to figure out the platform, the user, the arguments, etc. We rely on ctx to figure out the platform, the user, the arguments, etc.
Return a string that the caller will send. Return a string that the caller will send.
""" """
utility.wfstl()
# 1) Detect which platform # 1) Detect which platform
# We might do something like: # We might do something like:
platform, author_id, author_name, author_display_name, args = extract_ctx_info(ctx) platform, author_id, author_name, author_display_name, args = extract_ctx_info(ctx)
@ -41,10 +41,12 @@ def handle_howl_command(ctx) -> str:
target_name = args[1] target_name = args[1]
else: else:
target_name = author_name target_name = author_name
utility.wfetl()
return handle_howl_stats(ctx, platform, target_name) return handle_howl_stats(ctx, platform, target_name)
else: else:
# normal usage => random generation # normal usage => random generation
utility.wfetl()
return handle_howl_normal(ctx, platform, author_id, author_display_name) return handle_howl_normal(ctx, platform, author_id, author_display_name)
def extract_ctx_info(ctx): def extract_ctx_info(ctx):
@ -52,6 +54,7 @@ def extract_ctx_info(ctx):
Figures out if this is Discord or Twitch, Figures out if this is Discord or Twitch,
returns (platform_str, author_id, author_name, author_display_name, args). returns (platform_str, author_id, author_name, author_display_name, args).
""" """
utility.wfstl()
# Is it discord.py or twitchio? # Is it discord.py or twitchio?
if hasattr(ctx, "guild"): # typically means discord.py context if hasattr(ctx, "guild"): # typically means discord.py context
platform_str = "discord" platform_str = "discord"
@ -72,12 +75,14 @@ def extract_ctx_info(ctx):
parts = ctx.message.content.strip().split() parts = ctx.message.content.strip().split()
args = parts[1:] if len(parts) > 1 else [] args = parts[1:] if len(parts) > 1 else []
utility.wfetl()
return (platform_str, author_id, author_name, author_display_name, args) return (platform_str, author_id, author_name, author_display_name, args)
def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str: def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str:
""" """
Normal usage: random generation, store in DB. Normal usage: random generation, store in DB.
""" """
utility.wfstl()
db_conn = ctx.bot.db_conn db_conn = ctx.bot.db_conn
# random logic # random logic
@ -102,15 +107,18 @@ def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str:
else: else:
globals.log(f"Could not find user by ID={author_id} on {platform}. Not storing howl.", "WARNING") globals.log(f"Could not find user by ID={author_id} on {platform}. Not storing howl.", "WARNING")
utility.wfetl()
return reply return reply
def handle_howl_stats(ctx, platform, target_name) -> str: def handle_howl_stats(ctx, platform, target_name) -> str:
utility.wfstl()
db_conn = ctx.bot.db_conn db_conn = ctx.bot.db_conn
# Check if requesting global stats # Check if requesting global stats
if target_name in ("_COMMUNITY_", "all", "global", "community"): if target_name in ("_COMMUNITY_", "all", "global", "community"):
stats = db.get_global_howl_stats(db_conn) stats = db.get_global_howl_stats(db_conn)
if not stats: if not stats:
utility.wfetl()
return "No howls have been recorded yet!" return "No howls have been recorded yet!"
total_howls = stats["total_howls"] total_howls = stats["total_howls"]
@ -119,6 +127,7 @@ def handle_howl_stats(ctx, platform, target_name) -> str:
count_zero = stats["count_zero"] count_zero = stats["count_zero"]
count_hundred = stats["count_hundred"] count_hundred = stats["count_hundred"]
utility.wfetl()
return (f"**Community Howl Stats:**\n" return (f"**Community Howl Stats:**\n"
f"Total Howls: {total_howls}\n" f"Total Howls: {total_howls}\n"
f"Average Howl: {avg_howl:.1f}%\n" f"Average Howl: {avg_howl:.1f}%\n"
@ -128,16 +137,19 @@ def handle_howl_stats(ctx, platform, target_name) -> str:
# Otherwise, lookup a single user # Otherwise, lookup a single user
user_data = lookup_user_by_name(db_conn, platform, target_name) user_data = lookup_user_by_name(db_conn, platform, target_name)
if not user_data: if not user_data:
utility.wfetl()
return f"I don't know that user: {target_name}" return f"I don't know that user: {target_name}"
stats = db.get_howl_stats(db_conn, user_data["UUID"]) stats = db.get_howl_stats(db_conn, user_data["UUID"])
if not stats: if not stats:
utility.wfetl()
return f"{target_name} hasn't howled yet! (Try `!howl` to get started.)" return f"{target_name} hasn't howled yet! (Try `!howl` to get started.)"
c = stats["count"] c = stats["count"]
a = stats["average"] a = stats["average"]
z = stats["count_zero"] z = stats["count_zero"]
h = stats["count_hundred"] h = stats["count_hundred"]
utility.wfetl()
return (f"{target_name} has howled {c} times, averaging {a:.1f}% " return (f"{target_name} has howled {c} times, averaging {a:.1f}% "
f"(0% x{z}, 100% x{h})") f"(0% x{z}, 100% x{h})")
@ -146,21 +158,27 @@ def lookup_user_by_name(db_conn, platform, name_str):
""" """
Attempt to find a user by name on that platform, e.g. 'discord_username' or 'twitch_username'. Attempt to find a user by name on that platform, e.g. 'discord_username' or 'twitch_username'.
""" """
utility.wfstl()
# same logic as before # same logic as before
if platform == "discord": if platform == "discord":
ud = db.lookup_user(db_conn, name_str, "discord_user_display_name") ud = db.lookup_user(db_conn, name_str, "discord_user_display_name")
if ud: if ud:
utility.wfetl()
return ud return ud
ud = db.lookup_user(db_conn, name_str, "discord_username") ud = db.lookup_user(db_conn, name_str, "discord_username")
utility.wfetl()
return ud return ud
elif platform == "twitch": elif platform == "twitch":
ud = db.lookup_user(db_conn, name_str, "twitch_user_display_name") ud = db.lookup_user(db_conn, name_str, "twitch_user_display_name")
if ud: if ud:
utility.wfetl()
return ud return ud
ud = db.lookup_user(db_conn, name_str, "twitch_username") ud = db.lookup_user(db_conn, name_str, "twitch_username")
utility.wfetl()
return ud return ud
else: else:
globals.log(f"Unknown platform {platform} in lookup_user_by_name", "WARNING") globals.log(f"Unknown platform {platform} in lookup_user_by_name", "WARNING")
utility.wfetl()
return None return None
@ -168,6 +186,7 @@ def ping() -> str:
""" """
Returns a dynamic, randomized uptime response. Returns a dynamic, randomized uptime response.
""" """
utility.wfstl()
debug = False debug = False
# Use function to retrieve correct startup time and calculate uptime # Use function to retrieve correct startup time and calculate uptime
@ -187,6 +206,7 @@ def ping() -> str:
if debug: if debug:
print(f"Elapsed time: {elapsed}\nuptime_str: {uptime_str}\nuptime_s: {uptime_s}\nselected threshold: {selected_threshold}\nresponse: {response}") print(f"Elapsed time: {elapsed}\nuptime_str: {uptime_str}\nuptime_s: {uptime_s}\nselected threshold: {selected_threshold}\nresponse: {response}")
utility.wfetl()
return response return response
def greet(target_display_name: str, platform_name: str) -> str: def greet(target_display_name: str, platform_name: str) -> str:
@ -202,11 +222,13 @@ def greet(target_display_name: str, platform_name: str) -> str:
def create_quotes_table(db_conn): def create_quotes_table(db_conn):
""" """
Creates the 'quotes' table if it does not exist, with the columns: Creates the 'quotes' table if it does not exist, with the columns:
ID, QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED ID, QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED, QUOTE_REMOVED_DATETIME
Uses a slightly different CREATE statement depending on MariaDB vs SQLite. Uses a slightly different CREATE statement depending on MariaDB vs SQLite.
""" """
utility.wfstl()
if not db_conn: if not db_conn:
globals.log("No database connection available to create quotes table!", "FATAL") globals.log("No database connection available to create quotes table!", "FATAL")
utility.wfetl()
return return
# Detect if this is SQLite or MariaDB # Detect if this is SQLite or MariaDB
@ -221,7 +243,8 @@ def create_quotes_table(db_conn):
QUOTE_CHANNEL TEXT, QUOTE_CHANNEL TEXT,
QUOTE_DATETIME TEXT, QUOTE_DATETIME TEXT,
QUOTE_GAME TEXT, QUOTE_GAME TEXT,
QUOTE_REMOVED BOOLEAN DEFAULT 0 QUOTE_REMOVED BOOLEAN DEFAULT 0,
QUOTE_REMOVED_DATETIME TEXT
) )
""" """
else: else:
@ -235,11 +258,20 @@ def create_quotes_table(db_conn):
QUOTE_CHANNEL VARCHAR(100), QUOTE_CHANNEL VARCHAR(100),
QUOTE_DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, QUOTE_DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP,
QUOTE_GAME VARCHAR(200), QUOTE_GAME VARCHAR(200),
QUOTE_REMOVED BOOLEAN DEFAULT FALSE QUOTE_REMOVED BOOLEAN DEFAULT FALSE,
QUOTE_REMOVED_DATETIME DATETIME DEFAULT NULL
) )
""" """
db.run_db_operation(db_conn, "write", create_table_sql) db.run_db_operation(db_conn, "write", create_table_sql)
utility.wfetl()
def is_sqlite(db_conn):
"""
Helper function to determine if the database connection is SQLite.
"""
return 'sqlite3' in str(type(db_conn)).lower()
async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_game_for_channel=None): async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_game_for_channel=None):
@ -248,7 +280,7 @@ async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_
- `db_conn`: your active DB connection - `db_conn`: your active DB connection
- `is_discord`: True if this command is being called from Discord, False if from Twitch - `is_discord`: True if this command is being called from Discord, False if from Twitch
- `ctx`: the context object (discord.py ctx or twitchio context) - `ctx`: the context object (discord.py ctx or twitchio context)
- `args`: a list of arguments (e.g. ["add", "some quote text..."] or ["remove", "3"] or ["2"] etc.) - `args`: a list of arguments (e.g. ["add", "some quote text..."], ["remove", "3"], ["info", "3"], ["search", "foo", "bar"], or ["2"] etc.)
- `get_twitch_game_for_channel`: function(channel_name) -> str or None - `get_twitch_game_for_channel`: function(channel_name) -> str or None
Behavior: Behavior:
@ -256,14 +288,27 @@ async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_
-> Adds a new quote, stores channel=Discord or twitch channel name, game if twitch. -> Adds a new quote, stores channel=Discord or twitch channel name, game if twitch.
2) `!quote remove N` 2) `!quote remove N`
-> Mark quote #N as removed. -> Mark quote #N as removed.
3) `!quote N` 3) `!quote info N`
-> Returns stored info about quote #N (with a Discord embed if applicable).
4) `!quote search [keywords]`
-> Searches for the best matching non-removed quote based on the given keywords.
5) `!quote N`
-> Retrieve quote #N, if not removed. -> Retrieve quote #N, if not removed.
4) `!quote` (no args) 6) `!quote` (no args)
-> Retrieve a random (not-removed) quote. -> Retrieve a random (not-removed) quote.
7) `!quote last/latest/newest`
-> Retrieve the latest (most recent) non-removed quote.
""" """
utility.wfstl()
if callable(db_conn):
db_conn = db_conn()
# If no subcommand, treat as "random" # If no subcommand, treat as "random"
if len(args) == 0: if len(args) == 0:
return await retrieve_random_quote(db_conn, is_discord, ctx) try:
utility.wfetl()
return await retrieve_random_quote(db_conn)
except Exception as e:
globals.log(f"handle_quote_command() failed to retrieve a random quote: {e}", "ERROR", exec_info=True)
sub = args[0].lower() sub = args[0].lower()
@ -271,26 +316,83 @@ async def handle_quote_command(db_conn, is_discord: bool, ctx, args, get_twitch_
# everything after "add" is the quote text # everything after "add" is the quote text
quote_text = " ".join(args[1:]).strip() quote_text = " ".join(args[1:]).strip()
if not quote_text: if not quote_text:
utility.wfetl()
return "Please provide the quote text after 'add'." return "Please provide the quote text after 'add'."
try:
utility.wfetl()
return await add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_for_channel) return await add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_for_channel)
except Exception as e:
globals.log(f"handle_quote_command() failed to add a new quote: {e}", "ERROR", exec_info=True)
elif sub == "remove": elif sub == "remove":
if len(args) < 2: if len(args) < 2:
utility.wfetl()
return "Please specify which quote ID to remove." return "Please specify which quote ID to remove."
try:
utility.wfetl()
return await remove_quote(db_conn, is_discord, ctx, quote_id_str=args[1]) return await remove_quote(db_conn, is_discord, ctx, quote_id_str=args[1])
except Exception as e:
globals.log(f"handle_quote_command() failed to remove a quote: {e}", "ERROR", exec_info=True)
elif sub == "restore":
if len(args) < 2:
utility.wfetl()
return "Please specify which quote ID to restore."
try:
utility.wfetl()
return await restore_quote(db_conn, is_discord, ctx, quote_id_str=args[1])
except Exception as e:
globals.log(f"handle_quote_command() failed to restore a quote: {e}", "ERROR", exec_info=True)
elif sub == "info":
if len(args) < 2:
utility.wfetl()
return "Please specify which quote ID to get info for."
if not args[1].isdigit():
utility.wfetl()
return f"'{args[1]}' is not a valid quote ID."
quote_id = int(args[1])
try:
utility.wfetl()
return await retrieve_quote_info(db_conn, ctx, quote_id, is_discord)
except Exception as e:
globals.log(f"handle_quote_command() failed to retrieve quote info: {e}", "ERROR", exec_info=True)
elif sub == "search":
if len(args) < 2:
utility.wfetl()
return "Please provide keywords to search for."
keywords = args[1:]
try:
utility.wfetl()
return await search_quote(db_conn, keywords, is_discord, ctx)
except Exception as e:
globals.log(f"handle_quote_command() failed to process quote search: {e}", "ERROR", exec_info=True)
elif sub in ["last", "latest", "newest"]:
try:
utility.wfetl()
return await retrieve_latest_quote(db_conn)
except Exception as e:
globals.log(f"handle_quote_command() failed to retrieve latest quote: {e}", "ERROR", exec_info=True)
else: else:
# Possibly a quote ID # Possibly a quote ID
if sub.isdigit(): if sub.isdigit():
quote_id = int(sub) quote_id = int(sub)
try:
utility.wfetl()
return await retrieve_specific_quote(db_conn, ctx, quote_id, is_discord) return await retrieve_specific_quote(db_conn, ctx, quote_id, is_discord)
except Exception as e:
globals.log(f"handle_quote_command() failed to retrieve a specific quote: {e}", "ERROR", exec_info=True)
else: else:
# unrecognized subcommand => fallback to random # unrecognized subcommand => fallback to random
return await retrieve_random_quote(db_conn, is_discord, ctx) try:
utility.wfetl()
return await retrieve_random_quote(db_conn)
except Exception as e:
globals.log(f"handle_quote_command() failed to retrieve a random quote: {e}", "ERROR", exec_info=True)
async def add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_for_channel): async def add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_for_channel):
""" """
Inserts a new quote with UUID instead of username. Inserts a new quote with UUID instead of username.
""" """
utility.wfstl()
user_id = str(ctx.author.id) user_id = str(ctx.author.id)
platform = "discord" if is_discord else "twitch" platform = "discord" if is_discord else "twitch"
@ -298,6 +400,7 @@ async def add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_fo
user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id") user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id")
if not user_data: if not user_data:
globals.log(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not saved.", "ERROR") globals.log(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not saved.", "ERROR")
utility.wfetl()
return "Could not save quote. Your user data is missing from the system." return "Could not save quote. Your user data is missing from the system."
user_uuid = user_data["UUID"] user_uuid = user_data["UUID"]
@ -315,16 +418,22 @@ async def add_new_quote(db_conn, is_discord, ctx, quote_text, get_twitch_game_fo
result = db.run_db_operation(db_conn, "write", insert_sql, params) result = db.run_db_operation(db_conn, "write", insert_sql, params)
if result is not None: if result is not None:
return "Quote added successfully!" quote_id = get_max_quote_id(db_conn)
globals.log(f"New quote added: {quote_text} ({quote_id})")
utility.wfetl()
return f"Successfully added quote #{quote_id}"
else: else:
utility.wfetl()
return "Failed to add quote." return "Failed to add quote."
async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str): async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str):
""" """
Mark quote #ID as removed (QUOTE_REMOVED=1). Mark quote #ID as removed (QUOTE_REMOVED=1) and record removal datetime.
""" """
utility.wfstl()
if not quote_id_str.isdigit(): if not quote_id_str.isdigit():
utility.wfetl()
return f"'{quote_id_str}' is not a valid quote ID." return f"'{quote_id_str}' is not a valid quote ID."
user_id = str(ctx.author.id) user_id = str(ctx.author.id)
@ -334,6 +443,7 @@ async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str):
user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id") user_data = db.lookup_user(db_conn, identifier=user_id, identifier_type=f"{platform}_user_id")
if not user_data: if not user_data:
globals.log(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not removed.", "ERROR") globals.log(f"ERROR: Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not removed.", "ERROR")
utility.wfetl()
return "Could not remove quote. Your user data is missing from the system." return "Could not remove quote. Your user data is missing from the system."
user_uuid = user_data["UUID"] user_uuid = user_data["UUID"]
@ -341,11 +451,12 @@ async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str):
quote_id = int(quote_id_str) quote_id = int(quote_id_str)
remover_user = str(user_uuid) remover_user = str(user_uuid)
# Mark as removed # Mark as removed and record removal datetime
update_sql = """ update_sql = """
UPDATE quotes UPDATE quotes
SET QUOTE_REMOVED = 1, SET QUOTE_REMOVED = 1,
QUOTE_REMOVED_BY = ? QUOTE_REMOVED_BY = ?,
QUOTE_REMOVED_DATETIME = CURRENT_TIMESTAMP
WHERE ID = ? WHERE ID = ?
AND QUOTE_REMOVED = 0 AND QUOTE_REMOVED = 0
""" """
@ -353,10 +464,39 @@ async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str):
rowcount = db.run_db_operation(db_conn, "update", update_sql, params) rowcount = db.run_db_operation(db_conn, "update", update_sql, params)
if rowcount and rowcount > 0: if rowcount and rowcount > 0:
utility.wfetl()
return f"Removed quote #{quote_id}." return f"Removed quote #{quote_id}."
else: else:
utility.wfetl()
return "Could not remove that quote (maybe it's already removed or doesn't exist)." return "Could not remove that quote (maybe it's already removed or doesn't exist)."
async def restore_quote(db_conn, is_discord: bool, ctx, quote_id_str):
"""
Marks a previously removed quote as unremoved.
Updates the quote so that QUOTE_REMOVED is set to 0 and clears the
QUOTE_REMOVED_BY and QUOTE_REMOVED_DATETIME fields.
"""
if not quote_id_str.isdigit():
return f"'{quote_id_str}' is not a valid quote ID."
quote_id = int(quote_id_str)
# Attempt to restore the quote by clearing its removal flags
update_sql = """
UPDATE quotes
SET QUOTE_REMOVED = 0,
QUOTE_REMOVED_BY = NULL,
QUOTE_REMOVED_DATETIME = NULL
WHERE ID = ?
AND QUOTE_REMOVED = 1
"""
params = (quote_id,)
rowcount = db.run_db_operation(db_conn, "update", update_sql, params)
if rowcount and rowcount > 0:
return f"Quote #{quote_id} has been restored."
else:
return "Could not restore that quote (perhaps it is not marked as removed or does not exist)."
async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord): async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord):
""" """
@ -364,9 +504,11 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord):
If not found, or removed, inform user of the valid ID range (1 - {max_id}) If not found, or removed, inform user of the valid ID range (1 - {max_id})
If no quotes exist at all, say "No quotes are created yet." If no quotes exist at all, say "No quotes are created yet."
""" """
utility.wfstl()
# First, see if we have any quotes at all # First, see if we have any quotes at all
max_id = get_max_quote_id(db_conn) max_id = get_max_quote_id(db_conn)
if max_id < 1: if max_id < 1:
utility.wfetl()
return "No quotes are created yet." return "No quotes are created yet."
# Query for that specific quote # Query for that specific quote
@ -379,7 +521,8 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord):
QUOTE_DATETIME, QUOTE_DATETIME,
QUOTE_GAME, QUOTE_GAME,
QUOTE_REMOVED, QUOTE_REMOVED,
QUOTE_REMOVED_BY QUOTE_REMOVED_BY,
QUOTE_REMOVED_DATETIME
FROM quotes FROM quotes
WHERE ID = ? WHERE ID = ?
""" """
@ -387,6 +530,7 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord):
if not rows: if not rows:
# no match # no match
utility.wfetl()
return f"I couldn't find that quote (1-{max_id})." return f"I couldn't find that quote (1-{max_id})."
row = rows[0] row = rows[0]
@ -398,33 +542,43 @@ async def retrieve_specific_quote(db_conn, ctx, quote_id, is_discord):
quote_game = row[5] quote_game = row[5]
quote_removed = row[6] quote_removed = row[6]
quote_removed_by = row[7] if row[7] else "Unknown" quote_removed_by = row[7] if row[7] else "Unknown"
quote_removed_datetime = row[8] if row[8] else "Unknown"
platform = "discord" if is_discord else "twitch" platform = "discord" if is_discord else "twitch"
# Lookup UUID from users table # Lookup UUID from users table for the quoter
user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID") user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID")
if not user_data: if not user_data:
globals.log(f"ERROR: Could not find platform name for remover UUID {quote_removed_by} on UUI. Default to 'Unknown'", "ERROR") globals.log(f"ERROR: Could not find platform name for quotee UUID {quotee}. Default to 'Unknown'", "ERROR")
quote_removed_by = "Unknown" quotee_display = "Unknown"
else: else:
quote_removed_by = user_data[f"{platform}_user_display_name"] quotee_display = user_data[f"{platform}_user_display_name"]
if quote_removed == 1: if quote_removed == 1:
# It's removed # Lookup UUID for removed_by if removed
return f"Quote {quote_number}: [REMOVED by {quote_removed_by}]" removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID")
if not removed_user_data:
globals.log(f"ERROR: Could not find platform name for remover UUID {quote_removed_by}. Default to 'Unknown'", "ERROR")
quote_removed_by_display = "Unknown"
else: else:
# It's not removed quote_removed_by_display = removed_user_data[f"{platform}_user_display_name"]
return f"Quote {quote_number}: {quote_text}" utility.wfetl()
return f"Quote #{quote_number}: [REMOVED by {quote_removed_by_display} on {quote_removed_datetime}]"
else:
utility.wfetl()
return f"Quote #{quote_number}: {quote_text}"
async def retrieve_random_quote(db_conn, is_discord, ctx): async def retrieve_random_quote(db_conn):
""" """
Grab a random quote (QUOTE_REMOVED=0). Grab a random quote (QUOTE_REMOVED=0).
If no quotes exist or all removed, respond with "No quotes are created yet." If no quotes exist or all removed, respond with "No quotes are created yet."
""" """
utility.wfstl()
# First check if we have any quotes # First check if we have any quotes
max_id = get_max_quote_id(db_conn) max_id = get_max_quote_id(db_conn)
if max_id < 1: if max_id < 1:
utility.wfetl()
return "No quotes are created yet." return "No quotes are created yet."
# We have quotes, try selecting a random one from the not-removed set # We have quotes, try selecting a random one from the not-removed set
@ -448,36 +602,256 @@ async def retrieve_random_quote(db_conn, is_discord, ctx):
rows = db.run_db_operation(db_conn, "read", random_sql) rows = db.run_db_operation(db_conn, "read", random_sql)
if not rows: if not rows:
utility.wfetl()
return "No quotes are created yet." return "No quotes are created yet."
quote_number, quote_text = rows[0] quote_number, quote_text = rows[0]
await f"Quote {quote_number}: {quote_text}" utility.wfetl()
return f"Quote #{quote_number}: {quote_text}"
async def retrieve_latest_quote(db_conn):
"""
Retrieve the latest (most recent) non-removed quote based on QUOTE_DATETIME.
"""
utility.wfstl()
max_id = get_max_quote_id(db_conn)
if max_id < 1:
utility.wfetl()
return "No quotes are created yet."
latest_sql = """
SELECT ID, QUOTE_TEXT
FROM quotes
WHERE QUOTE_REMOVED = 0
ORDER BY QUOTE_DATETIME DESC
LIMIT 1
"""
rows = db.run_db_operation(db_conn, "read", latest_sql)
if not rows:
utility.wfetl()
return "No quotes are created yet."
quote_number, quote_text = rows[0]
utility.wfetl()
return f"Quote #{quote_number}: {quote_text}"
async def retrieve_quote_info(db_conn, ctx, quote_id, is_discord):
"""
Retrieve the stored information about a specific quote (excluding the quote text itself).
If called from Discord, returns a discord.Embed object with nicely formatted information.
If not found, returns an appropriate error message.
"""
utility.wfstl()
# First, check if any quotes exist
max_id = get_max_quote_id(db_conn)
if max_id < 1:
utility.wfetl()
return "No quotes are created yet."
# Query for the specific quote by ID
select_sql = """
SELECT
ID,
QUOTE_TEXT,
QUOTEE,
QUOTE_CHANNEL,
QUOTE_DATETIME,
QUOTE_GAME,
QUOTE_REMOVED,
QUOTE_REMOVED_BY,
QUOTE_REMOVED_DATETIME
FROM quotes
WHERE ID = ?
"""
rows = db.run_db_operation(db_conn, "read", select_sql, (quote_id,))
if not rows:
utility.wfetl()
return f"I couldn't find that quote (1-{max_id})."
row = rows[0]
quote_number = row[0]
quote_text = row[1]
quotee = row[2]
quote_channel = row[3]
quote_datetime = row[4]
quote_game = row[5]
quote_removed = row[6]
quote_removed_by = row[7] if row[7] else None
quote_removed_datetime = row[8] if row[8] else None
platform = "discord" if is_discord else "twitch"
# Lookup display name for the quoter
user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID")
if not user_data:
globals.log(f"ERROR: Could not find display name for quotee UUID {quotee}.", "ERROR")
quotee_display = "Unknown"
else:
quotee_display = user_data.get(f"{platform}_user_display_name", "Unknown")
info_lines = []
info_lines.append(f"Quote #{quote_number} was quoted by {quotee_display} on {quote_datetime}.")
if quote_channel:
info_lines.append(f"Channel: {quote_channel}")
if quote_game:
info_lines.append(f"Game: {quote_game}")
if quote_removed == 1:
# Lookup display name for the remover
if quote_removed_by:
removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID")
if not removed_user_data:
globals.log(f"ERROR: Could not find display name for remover UUID {quote_removed_by}.", "ERROR")
quote_removed_by_display = "Unknown"
else:
quote_removed_by_display = removed_user_data.get(f"{platform}_user_display_name", "Unknown")
else:
quote_removed_by_display = "Unknown"
removed_info = f"Removed by {quote_removed_by_display}"
if quote_removed_datetime:
removed_info += f" on {quote_removed_datetime}"
info_lines.append(removed_info)
info_text = "\n ".join(info_lines)
if is_discord:
# Create a Discord embed
try:
from discord import Embed, Color
except ImportError:
# If discord.py is not available, fallback to plain text
utility.wfetl()
return info_text
if quote_removed == 1:
embed_color = Color.red()
embed_title = f"Quote #{quote_number} Info [REMOVED]"
else:
embed_color = Color.blue()
embed_title = f"Quote #{quote_number} Info"
embed = Embed(title=embed_title, color=embed_color)
embed.add_field(name="Quote", value=quote_text, inline=False)
embed.add_field(name="Quoted by", value=quotee_display, inline=True)
embed.add_field(name="Quoted on", value=quote_datetime, inline=True)
if quote_channel:
embed.add_field(name="Channel", value=quote_channel, inline=True)
if quote_game:
embed.add_field(name="Game", value=quote_game, inline=True)
if quote_removed == 1:
embed.add_field(name="Removed Info", value=removed_info, inline=False)
utility.wfetl()
return embed
else:
utility.wfetl()
return info_text
async def search_quote(db_conn, keywords, is_discord, ctx):
"""
Searches for the best matching non-removed quote based on the given keywords.
The search compares keywords (case-insensitive) to words in the quote text, game name,
quotee's display name, and channel. For each keyword that matches any of these fields,
the quote receives +1 point, and for each keyword that does not match, it loses 1 point.
A whole word match counts more than a partial match.
The quote with the highest score is returned. In case of several equally good matches,
one is chosen at random.
"""
import re
utility.wfstl()
func_start = time.time()
sql = "SELECT ID, QUOTE_TEXT, QUOTE_GAME, QUOTEE, QUOTE_CHANNEL FROM quotes WHERE QUOTE_REMOVED = 0"
rows = db.run_db_operation(db_conn, "read", sql)
if not rows:
func_end = time.time()
func_elapsed = utility.time_since(func_start, func_end, "s")
dbg_lvl = "DEBUG" if func_elapsed[2] < 5 else "WARNING"
globals.log(f"search_quote took {func_elapsed[0]}, {func_elapsed[1]} to complete", dbg_lvl)
utility.wfetl()
return "No quotes are created yet."
best_score = None
best_quotes = []
# Normalize keywords to lowercase for case-insensitive matching.
lower_keywords = [kw.lower() for kw in keywords]
for row in rows:
quote_id = row[0]
quote_text = row[1] or ""
quote_game = row[2] or ""
quotee = row[3] or ""
quote_channel = row[4] or ""
# Lookup display name for quotee using UUID.
user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID")
if user_data:
quotee_display = user_data.get(f"{'discord' if is_discord else 'twitch'}_user_display_name", "")
else:
quotee_display = ""
# For each keyword, check each field.
# Award 2 points for a whole word match and 1 point for a partial match.
score_total = 0
for kw in lower_keywords:
keyword_score = 0
for field in (quote_text, quote_game, quotee_display, quote_channel):
field_str = field or ""
field_lower = field_str.lower()
# Check for whole word match using regex word boundaries.
if re.search(r'\b' + re.escape(kw) + r'\b', field_lower):
keyword_score = 2
break # Whole word match found, no need to check further fields.
elif kw in field_lower:
keyword_score = max(keyword_score, 1)
score_total += keyword_score
# Apply penalty: subtract the number of keywords.
final_score = score_total - len(lower_keywords)
if best_score is None or final_score > best_score:
best_score = final_score
best_quotes = [(quote_id, quote_text)]
elif final_score == best_score:
best_quotes.append((quote_id, quote_text))
if not best_quotes:
func_end = time.time()
func_elapsed = utility.time_since(func_start, func_end)
dbg_lvl = "DEBUG" if func_elapsed[2] < 5 else "WARNING"
globals.log(f"search_quote took {func_elapsed[0]}, {func_elapsed[1]} to complete", dbg_lvl)
utility.wfetl()
return "No matching quotes found."
chosen = random.choice(best_quotes)
func_end = time.time()
func_elapsed = utility.time_since(func_start, func_end, "s")
dbg_lvl = "DEBUG" if func_elapsed[2] < 5 else "WARNING"
globals.log(f"search_quote took {func_elapsed[0]}, {func_elapsed[1]} to complete", dbg_lvl)
utility.wfetl()
return f"Quote {chosen[0]}: {chosen[1]}"
def get_max_quote_id(db_conn): def get_max_quote_id(db_conn):
""" """
Return the highest ID in the quotes table, or 0 if empty. Return the highest ID in the quotes table, or 0 if empty.
""" """
utility.wfstl()
sql = "SELECT MAX(ID) FROM quotes" sql = "SELECT MAX(ID) FROM quotes"
rows = db.run_db_operation(db_conn, "read", sql) rows = db.run_db_operation(db_conn, "read", sql)
if rows and rows[0] and rows[0][0] is not None: if rows and rows[0] and rows[0][0] is not None:
utility.wfetl()
return rows[0][0] return rows[0][0]
utility.wfetl()
return 0 return 0
def is_sqlite(db_conn):
return 'sqlite3' in str(type(db_conn)).lower()
def get_author_name(ctx, is_discord): def get_author_name(ctx, is_discord):
""" """
Return the name/username of the command author. Return the name/username of the command author.
For Discord, it's ctx.author.display_name (or ctx.author.name). For Discord, it's ctx.author.display_name (or ctx.author.name).
For Twitch (twitchio), it's ctx.author.name. For Twitch (twitchio), it's ctx.author.name.
""" """
utility.wfstl()
if is_discord: if is_discord:
utility.wfetl()
return str(ctx.author.display_name) return str(ctx.author.display_name)
else: else:
utility.wfetl()
return str(ctx.author.name) return str(ctx.author.name)

View File

@ -9,7 +9,7 @@ from modules.permissions import has_permission
from modules.utility import handle_help_command from modules.utility import handle_help_command
import globals import globals
def setup(bot, db_conn=None): def setup(bot):
""" """
Attach commands to the Discord bot, store references to db/log. Attach commands to the Discord bot, store references to db/log.
""" """
@ -168,84 +168,53 @@ def setup(bot, db_conn=None):
# get_twitch_game_for_channel=None # None for Discord # get_twitch_game_for_channel=None # None for Discord
# ) # )
@bot.hybrid_group(name="quote", description="Interact with the quotes system", with_app_command=True) @bot.command(name="quote")
async def cmd_quote(ctx, *, id: Optional[int] = None): async def cmd_quote(ctx, *, arg_str: str = ""):
""" """
Handles base quote commands. Handles the !quote command with multiple subcommands.
- `!quote` -> Fetch a random quote
- `!quote <id>` -> Fetch a specific quote by ID Usage:
- `/quote` or `/quote <id>` -> Works for slash commands - `!quote`
-> Retrieves a random (non-removed) quote.
- `!quote <number>`
-> Retrieves the specific quote by ID.
- `!quote add <quote text>`
-> Adds a new quote and replies with its quote number.
- `!quote remove <number>`
-> Removes the specified quote.
- `!quote restore <number>`
-> Restores a previously removed quote.
- `!quote info <number>`
-> Displays stored information about the quote (as an embed on Discord).
- `!quote search [keywords]`
-> Searches for the best matching quote based on the provided keywords.
- `!quote last/latest/newest`
-> Retrieves the latest (most recent) non-removed quote.
""" """
if not bot.db_conn: if not globals.init_db_conn:
return await ctx.send("Database is unavailable, sorry.") await ctx.send("Database is unavailable, sorry.")
return
# If no query is provided, fetch a random quote.
if not id:
args = []
else:
args = id.split() # Split query into arguments
# Parse the arguments from the message text
args = arg_str.split() if arg_str else []
globals.log(f"'quote' command initiated with arguments: {args}", "DEBUG") globals.log(f"'quote' command initiated with arguments: {args}", "DEBUG")
result = await cc.handle_quote_command( result = await cc.handle_quote_command(
db_conn=bot.db_conn, db_conn=globals.init_db_conn,
is_discord=True, is_discord=True,
ctx=ctx, ctx=ctx,
args=args, args=args,
get_twitch_game_for_channel=None get_twitch_game_for_channel=None
) )
globals.log(f"'quote' result: {result}") globals.log(f"'quote' result: {result}", "DEBUG")
# If the result is a discord.Embed, send it as an embed; otherwise, send plain text.
if hasattr(result, "to_dict"):
await ctx.send(embed=result)
else:
await ctx.send(result) await ctx.send(result)
@cmd_quote.command(name="add", description="Add a quote")
async def cmd_quote_add(ctx, *, text: str):
"""
Usage:
- `!quote add <text>`
- `/quote add text:<your quote>` for slash commands.
"""
if not bot.db_conn:
return await ctx.send("Database is unavailable, sorry.")
# Ensure text isn't empty
if not text.strip():
return await ctx.send("You must provide a quote text.")
args = ["add", text] # Properly format arguments
result = await cc.handle_quote_command(
db_conn=bot.db_conn,
is_discord=True,
ctx=ctx,
args=args,
get_twitch_game_for_channel=None
)
await ctx.send(result)
@cmd_quote.command(name="remove", description="Remove a quote by number")
async def cmd_quote_remove(ctx, id: int):
"""
Usage:
- `!quote remove <id>`
- `/quote remove id:<quote number>`
"""
if not bot.db_conn:
return await ctx.send("Database is unavailable, sorry.")
args = ["remove", str(id)] # Properly pass the ID as an argument
result = await cc.handle_quote_command(
db_conn=bot.db_conn,
is_discord=True,
ctx=ctx,
args=args,
get_twitch_game_for_channel=None
)
await ctx.send(result)
###################### ######################
# The following log entry must be last in the file to verify commands loading as they should # The following log entry must be last in the file to verify commands loading as they should

View File

@ -14,29 +14,29 @@ def setup(bot, db_conn=None):
We also attach the db_conn and log so the commands can use them. We also attach the db_conn and log so the commands can use them.
""" """
@bot.command(name="greet") @bot.command(name="greet")
async def cmd_greet(ctx): async def cmd_greet(ctx: commands.Context):
result = cc.greet(ctx.author.display_name, "Twitch") result = cc.greet(ctx.author.display_name, "Twitch")
await ctx.send(result) await ctx.reply(result)
@bot.command(name="ping") @bot.command(name="ping")
async def cmd_ping(ctx): async def cmd_ping(ctx: commands.Context):
result = cc.ping() result = cc.ping()
await ctx.send(result) await ctx.reply(result)
@bot.command(name="howl") @bot.command(name="howl")
async def cmd_howl(ctx): async def cmd_howl(ctx: commands.Context):
response = cc.handle_howl_command(ctx) response = cc.handle_howl_command(ctx)
await ctx.send(response) await ctx.reply(response)
@bot.command(name="hi") @bot.command(name="hi")
async def cmd_hi(ctx): async def cmd_hi(ctx: commands.Context):
user_id = str(ctx.author.id) # Twitch user ID user_id = str(ctx.author.id) # Twitch user ID
user_roles = [role.lower() for role in ctx.author.badges.keys()] # "roles" from Twitch badges user_roles = [role.lower() for role in ctx.author.badges.keys()] # "roles" from Twitch badges
if not has_permission("hi", user_id, user_roles, "twitch"): if not has_permission("hi", user_id, user_roles, "twitch"):
return await ctx.send("You don't have permission to use this command.") return await ctx.send("You don't have permission to use this command.")
await ctx.send("Hello there!") await ctx.reply("Hello there!")
# @bot.command(name="acc_link") # @bot.command(name="acc_link")
# @monitor_cmds(bot.log) # @monitor_cmds(bot.log)
@ -73,26 +73,94 @@ def setup(bot, db_conn=None):
# await ctx.send(f"✅ Successfully linked Discord user **{discord_user_id}** with Twitch account **{twitch_username}**.") # await ctx.send(f"✅ Successfully linked Discord user **{discord_user_id}** with Twitch account **{twitch_username}**.")
# @bot.command(name="quote")
# async def cmd_quote(ctx: commands.Context):
# """
# Handles the !quote command with multiple subcommands.
# Usage:
# - !quote
# -> Retrieves a random (non-removed) quote.
# - !quote <number>
# -> Retrieves the specific quote by ID.
# - !quote add <quote text>
# -> Adds a new quote and replies with its quote number.
# - !quote remove <number>
# -> Removes the specified quote.
# - !quote info <number>
# -> Displays stored information about the quote (as an embed on Discord).
# - !quote search [keywords]
# -> Searches for the best matching quote based on the provided keywords.
# - !quote last/latest/newest
# -> Retrieves the latest (most recent) non-removed quote.
# """
# if not bot.db_conn:
# return await ctx.send("Database is unavailable, sorry.")
# parts = ctx.message.content.strip().split()
# args = parts[1:] if len(parts) > 1 else []
# def get_twitch_game_for_channel(chan_name):
# # Placeholder for your actual logic to fetch the current game
# return "SomeGame"
# result = await cc.handle_quote_command(
# db_conn=bot.db_conn,
# is_discord=False,
# ctx=ctx,
# args=args,
# get_twitch_game_for_channel=get_twitch_game_for_channel
# )
# await ctx.send(result)
@bot.command(name="quote") @bot.command(name="quote")
async def cmd_quote(ctx: commands.Context): async def cmd_quote(ctx: commands.Context):
if not bot.db_conn: """
return await ctx.send("Database is unavailable, sorry.") Handles the !quote command with multiple subcommands.
parts = ctx.message.content.strip().split() Usage:
args = parts[1:] if len(parts) > 1 else [] - !quote
-> Retrieves a random (non-removed) quote.
- !quote <number>
-> Retrieves the specific quote by ID.
- !quote add <quote text>
-> Adds a new quote and replies with its quote number.
- !quote remove <number>
-> Removes the specified quote.
- `!quote restore <number>`
-> Restores a previously removed quote.
- !quote info <number>
-> Displays stored information about the quote (as an embed on Discord).
- !quote search [keywords]
-> Searches for the best matching quote based on the provided keywords.
- !quote last/latest/newest
-> Retrieves the latest (most recent) non-removed quote.
"""
if not globals.init_db_conn:
await ctx.reply("Database is unavailable, sorry.")
return
# Parse the arguments from the message text
args = ctx.message.content.strip().split()
args = args[1:] if args else []
globals.log(f"'quote' command initiated with arguments: {args}", "DEBUG")
globals.log(f"'quote' command message content: {ctx.message.content}", "DEBUG")
def get_twitch_game_for_channel(chan_name): def get_twitch_game_for_channel(chan_name):
# Placeholder for your actual logic to fetch the current game # Placeholder for your actual logic to fetch the current game
return "SomeGame" return "SomeGame"
result = await cc.handle_quote_command( result = await cc.handle_quote_command(
db_conn=bot.db_conn, db_conn=globals.init_db_conn,
is_discord=False, is_discord=False,
ctx=ctx, ctx=ctx,
args=args, args=args,
get_twitch_game_for_channel=get_twitch_game_for_channel get_twitch_game_for_channel=get_twitch_game_for_channel
) )
await ctx.send(result)
globals.log(f"'quote' result: {result}", "DEBUG")
await ctx.reply(result)
@bot.command(name="help") @bot.command(name="help")
async def cmd_help(ctx): async def cmd_help(ctx):

View File

@ -9,8 +9,22 @@
] ]
}, },
"quote": { "quote": {
"description": "Manage quotes (add, remove, fetch).", "description": "Manage quotes (add, remove, restore, fetch, info).",
"subcommands": { "subcommands": {
"no subcommand": {
"desc": "Fetch a random quote."
},
"[quote_number]": {
"args": "[quote_number]",
"desc": "Fetch a specific quote by ID."
},
"search": {
"args": "[keywords]",
"desc": "Search for a quote with keywords."
},
"last/latest/newest": {
"desc": "Returns the newest quote."
},
"add": { "add": {
"args": "[quote_text]", "args": "[quote_text]",
"desc": "Adds a new quote." "desc": "Adds a new quote."
@ -19,18 +33,23 @@
"args": "[quote_number]", "args": "[quote_number]",
"desc": "Removes the specified quote by ID." "desc": "Removes the specified quote by ID."
}, },
"[quote_number]": { "restore": {
"desc": "Fetch a specific quote by ID." "args": "[quote_number]",
"desc": "Restores the specified quote by ID."
}, },
"no subcommand": { "info": {
"desc": "Fetch a random quote." "args": "[quote_number]",
"desc": "Retrieves info about the specified quote."
} }
}, },
"examples": [ "examples": [
"quote : Fetch a random quote",
"quote 3 : Fetch quote #3",
"quote search ookamikuntv : Search for quote containing 'ookamikuntv'",
"quote add This is my new quote : Add a new quote", "quote add This is my new quote : Add a new quote",
"quote remove 3 : Remove quote #3", "quote remove 3 : Remove quote #3",
"quote 5 : Fetch quote # 5", "quote restore 3 : Restores quote #3",
"quote : Fetch a random quote" "quote info 3 : Gets info about quote #3"
] ]
}, },
"ping": { "ping": {

View File

@ -23,6 +23,9 @@ def load_config_file():
print(f"Error parsing config.json: {e}") print(f"Error parsing config.json: {e}")
sys.exit(1) sys.exit(1)
# Load configuration file
config_data = load_config_file()
############################### ###############################
# Simple Logging System # Simple Logging System
############################### ###############################
@ -42,9 +45,6 @@ def log(message, level="INFO", exec_info=False):
See 'config.json' for disabling/enabling logging levels See 'config.json' for disabling/enabling logging levels
""" """
# Load configuration file
config_data = load_config_file()
# Initiate logfile # Initiate logfile
lfp = config_data["logging"]["logfile_path"] # Log File Path lfp = config_data["logging"]["logfile_path"] # Log File Path
clfp = f"cur_{lfp}" # Current Log File Path clfp = f"cur_{lfp}" # Current Log File Path
@ -99,9 +99,6 @@ def log(message, level="INFO", exec_info=False):
sys.exit(1) sys.exit(1)
def reset_curlogfile(): def reset_curlogfile():
# Load configuration file
config_data = load_config_file()
# Initiate logfile # Initiate logfile
lfp = config_data["logging"]["logfile_path"] # Log File Path lfp = config_data["logging"]["logfile_path"] # Log File Path
clfp = f"cur_{lfp}" # Current Log File Path clfp = f"cur_{lfp}" # Current Log File Path
@ -112,3 +109,16 @@ def reset_curlogfile():
except Exception as e: except Exception as e:
#log(f"Failed to clear current-run logfile: {e}") #log(f"Failed to clear current-run logfile: {e}")
pass pass
def init_db_conn():
try:
import modules.db
db_conn = modules.db.init_db_connection(config_data)
if not db_conn:
# If we get None, it means FATAL. We might sys.exit(1) or handle it differently.
log("Terminating bot due to no DB connection.", "FATAL")
sys.exit(1)
return db_conn
except Exception as e:
log(f"Unable to initialize database!: {e}", "FATAL")
return None

View File

@ -141,6 +141,16 @@ def run_db_operation(conn, operation, query, params=None):
conn.commit() conn.commit()
if globals.log: if globals.log:
globals.log(f"DB operation '{operation}' committed.", "DEBUG") globals.log(f"DB operation '{operation}' committed.", "DEBUG")
# If the query is an INSERT, return the last inserted row ID
if query.strip().lower().startswith("insert"):
try:
return cursor.lastrowid
except Exception as e:
if globals.log:
globals.log(f"Error retrieving lastrowid: {e}", "ERROR")
return cursor.rowcount
else:
return cursor.rowcount
# If it's read/lookup, fetch results # If it's read/lookup, fetch results
read_ops = ("read", "lookup", "select") read_ops = ("read", "lookup", "select")
@ -210,6 +220,7 @@ def ensure_quotes_table(db_conn):
QUOTE_GAME TEXT, QUOTE_GAME TEXT,
QUOTE_REMOVED BOOLEAN DEFAULT 0, QUOTE_REMOVED BOOLEAN DEFAULT 0,
QUOTE_REMOVED_BY TEXT, QUOTE_REMOVED_BY TEXT,
QUOTE_REMOVED_DATETIME TEXT DEFAULT NULL,
FOREIGN KEY (QUOTEE) REFERENCES users(UUID), FOREIGN KEY (QUOTEE) REFERENCES users(UUID),
FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID)
) )
@ -225,6 +236,7 @@ def ensure_quotes_table(db_conn):
QUOTE_GAME VARCHAR(200), QUOTE_GAME VARCHAR(200),
QUOTE_REMOVED BOOLEAN DEFAULT FALSE, QUOTE_REMOVED BOOLEAN DEFAULT FALSE,
QUOTE_REMOVED_BY VARCHAR(100), QUOTE_REMOVED_BY VARCHAR(100),
QUOTE_REMOVED_DATETIME DATETIME DEFAULT NULL,
FOREIGN KEY (QUOTEE) REFERENCES users(UUID) ON DELETE SET NULL FOREIGN KEY (QUOTEE) REFERENCES users(UUID) ON DELETE SET NULL
FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ON DELETE SET NULL FOREIGN KEY (QUOTE_REMOVED_BY) REFERENCES users(UUID) ON DELETE SET NULL
) )
@ -418,11 +430,11 @@ def lookup_user(db_conn, identifier: str, identifier_type: str, target_identifie
user_data = { user_data = {
"UUID": row[0], "UUID": row[0],
"discord_user_id": row[1], "discord_user_id": row[1],
"discord_username": row[2], "discord_username": row[2] if row[2] else f"{row[5]} (Discord unlinked)",
"discord_user_display_name": row[3], "discord_user_display_name": row[3] if row[3] else f"{row[6]} (Discord unlinked)",
"twitch_user_id": row[4], "twitch_user_id": row[4],
"twitch_username": row[5], "twitch_username": row[5] if row[5] else f"{row[2]} (Twitch unlinked)",
"twitch_user_display_name": row[6], "twitch_user_display_name": row[6] if row[6] else f"{row[3]} (Twitch unlinked)",
"datetime_linked": row[7], "datetime_linked": row[7],
"user_is_banned": row[8], "user_is_banned": row[8],
"user_is_bot": row[9], "user_is_bot": row[9],
@ -442,6 +454,17 @@ def lookup_user(db_conn, identifier: str, identifier_type: str, target_identifie
if target_identifier == "uuid": if target_identifier == "uuid":
target_identifier = "UUID" target_identifier = "UUID"
# If usernames are Null, default to that of the opposite platform
# if not user_data['discord_username']:
# user_data['discord_username'] = f"{user_data['twitch_username']} (Discord unlinked)"
# elif not user_data['twitch_username']:
# user_data['twitch_username'] = f"{user_data['discord_username']} (Twitch unlinked)"
# if not user_data['discord_user_display_name']:
# user_data['discord_user_display_name'] = f"{user_data['twitch_user_display_name']} (Discord unlinked)"
# elif not user_data['twitch_user_display_name']:
# user_data['twitch_user_display_name'] = f"{user_data['discord_user_display_name']} (Twitch unlinked)"
if target_identifier in user_data: if target_identifier in user_data:
return user_data[target_identifier] return user_data[target_identifier]
else: else:

View File

@ -679,6 +679,83 @@ def generate_link_code():
import random, string import random, string
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) return ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
def time_since(start, end=None, format=None):
"""
Returns the epoch time since the start value.
:param start: The epoch time to check against.
:param end: The epoch time to compare with.
Defaults to current time.
:param format: One of 's', 'm', 'h', or 'd' corresponding to seconds, minutes, hours, or days.
Defaults to "s" (seconds).
:return: A tuple (x, y, total_elapsed) where:
- For "s": x is whole seconds and y is the remaining milliseconds.
- For "m": x is whole minutes and y is the remaining seconds.
- For "h": x is whole hours and y is the remaining minutes.
- For "d": x is whole days and y is the remaining hours.
- total_elapsed is the complete elapsed time in seconds.
"""
# Ensure a start time is provided.
if start is None:
globals.log("time_since() lacks a start value!", "ERROR")
return None
# If no end time is provided, use the current time.
if end is None:
end = time.time()
# Default to seconds if format is not valid.
if format not in ["s", "m", "h", "d"]:
globals.log("time_since() has incorrect format string. Defaulting to 's'", "WARNING")
format = "s"
# Compute the total elapsed time in seconds.
since = end - start
# Break down the elapsed time according to the requested format.
if format == "s":
# Format as seconds: whole seconds and remainder in milliseconds.
x = int(since)
y = int((since - x) * 1000)
x = str(x) + "s"
y = str(y) + "ms"
elif format == "m":
# Format as minutes: whole minutes and remainder in seconds.
x = int(since // 60)
y = int(since % 60)
x = str(x) + "m"
y = str(y) + "s"
elif format == "h":
# Format as hours: whole hours and remainder in minutes.
x = int(since // 3600)
y = int((since % 3600) // 60)
x = str(x) + "h"
y = str(y) + "m"
elif format == "d":
# Format as days: whole days and remainder in hours.
x = int(since // 86400)
y = int((since % 86400) // 3600)
x = str(x) + "d"
y = str(y) + "h"
return (x, y, since)
def wfstl():
"""
Write Function Start To Log (debug)
Writes the calling function to log under the DEBUG category.
"""
caller_function_name = inspect.currentframe().f_back.f_code.co_name
globals.log(f"Function {caller_function_name} started processing")
def wfetl():
"""
Write Function End To Log (debug)
Writes the calling function to log under the DEBUG category.
"""
caller_function_name = inspect.currentframe().f_back.f_code.co_name
globals.log(f"Function {caller_function_name} finished processing")
############################################### ###############################################
# Development Test Function (called upon start) # Development Test Function (called upon start)