Compare commits

..

40 Commits

Author SHA1 Message Date
Kami d1faf7f214 Fixed Twitch bug
- Fixed an issue where Twitch users were not registered correctly due to recent DB changes
- Log-level of unidentified users changed from warning to info
- Minor changes to `!customvc`
  - Renamed `bitrate` -> `audio_bitrate`
  - Added `video_bitrate`
  - Changes some help text
  - Fixed certain subcommands which were broken after recent changes
  - Added `autoname` -> Automatically names the voice chat according to game being played by the owner
    - *Needs addiitonal work to implement all features*
2025-03-07 02:16:47 +01:00
Kami cce0f21ab0 Improved platform logging and Custom VC
- Platform logs associated to a message are now associated with platform-specific IDs
- Fixes for `!customvc`, specifically in relation to commands execution.
  - Optimized logic
  - Fixed limits for certain values like VC users limit
  - Better code integrity: now self-references as a Cog
- Lots of minor other tweaks, adjustments, and improvements
2025-03-06 17:53:54 +01:00
Kami 86ac83f34c Added basic custom VC functionality
- Supports a "VC Lobby" voice channel users can join to auto-create their own voice channel
  - That user is then the channel "owner".
  - Allows defining in-chat settings for each custom voice channel.
  - Empty channels auto-delete after 10s
  - Auto-deletes empty channels on startup
  - Auto-asigns the first user owner of the custom voice channel on startup if no data is retained
- Supports several commands:
  - `!customvc` -> Displays some help text
  - `!customvc name <new_name>` -> Renames the voice channel
  - `!customvc claim` -> Claim ownership of the voice channel if the owner has left
  - `!customvc lock` -> Locks the voice channel, preventing non-moderators from joining
  - `!customvc allow <some_user>` -> Allow a user into the locked voice channel
  - `!customvc deny <some_user>` -> Denies a non-mod user access to the voice channel
  - `!customvc unlock` -> Unlocks the voice channel. Denied users are still denied
  - `!customvc bitrate <kbps>` -> Set the bitrate for the voice channel (Should not be used unless needed)
  - `!customvc op <some_user>` -> Assing someone as co-owner of the voice channel
  - `!customvc settings` -> Display the current voice channel settings, including the owner
  - *It should be noted that moderators has the same access as channel owners for moderation purposes*
2025-03-02 01:52:42 +01:00
Kami 50617ef9ab Finalized database restructure, fixed Twitch auth issue
- Ironed out some issues with registering certain events into the database
- Fixed the old Twitch re-auth token issue. Now automatically renews it and initiates the bot with it.

These issues has been pestering the project a while now as they required some workarounds. Should work smoother from here on out! (I hope ...)
2025-03-01 23:14:10 +01:00
Kami d0313a6a92 Massive UUI system and database overhaul
- Dynamic accounts association
  - Accounts can now be associated dynamically, allowing multiple accounts on the same platform to be associated to the same UUID.
  - UUI system now supports any platform, eg. YouTube, TikTok, Kick, Twitter, etc.
  - More robust user lookups with enhanced fault tolerance and allowance for NULL data.
  - Optimized database structure with two tables for user association; one for UUID and basic info, another for platform-specific details.
- Enhanced logging functionality: logs now prefix the calling function.
- Enhanced user lookup debug messages, allowing easy query inspection and data validation.
- Other minor fixes
2025-03-01 01:54:51 +01:00
Kami 766c3ab690 Commands restructuring
- Reorganized commands into separate files for better fault tolerance and feature expandability
- Added basic dynamic bot status
  - Needs additional work. Currently very basic, planned features include fully dynamic mode with automatic alerts and such.
- Minor other tweaks
2025-02-23 17:00:45 +01:00
Kami 78e24a4641 Added Twitch channel game get function
- This function returns the game being played on the specified channel
- Implemented into quotes system: quotes automatically contain game name if quoted from a Twitch channel
- Defaults to None if channel is not live
- Discord quotes keep old logic: quotes never contain game information
2025-02-16 18:07:07 +01:00
Kami 17fbb20cdc Added Twitch multi-channel support
- OokamiPup v2 can now enter sevaral community channels
  - Individual channel settings. By default, all commands are disabled
  - Channel-specific settings allow enabling/disabling individual commands per channel
- Added a few more fun facts
- Minor tweaks and bugfixes
2025-02-16 15:30:17 +01:00
Kami 6da1744990 Added search feature to `!funfact`
- Users can now search for fun facts using `!funfact search [keywords]` without brackets. Returns best match.
- Also added some new facts
- Minor tweaks to force-sync of Discord slash commands
2025-02-14 11:42:20 +01:00
Kami 0f1077778f New `!funfact`/`!fun-fact` command
- This command returns a random fun fact from a list of over 500 verified fun, surprising, or good-to-know facts, with unit conversions between metric and imperial.
- Also started somewhat on the new Community Engagement Score (CES) system.
  - Currently not implemented to any working degree.
  - Will grant/deduct user scores depending on community engagement and actions overall.
2025-02-14 01:46:24 +01:00
Kami 01f002600c Bug tracking, minor tweaks
- Moved some constants to the globals.Constants class
  - config_data: returns the configuration dictionary
  - bot_start_time: returns the epoch bot start time
  - primary_discord_guild: returns a dict like `{"object": primary_guild_object, "id": primary_guild_int}`
  *These is initiated under globals.constants*
- Improved on docstrings. Google format now standard
- Reverted all commands to purely textual until further notice
  - `/help` is still additionally available as a slash command
2025-02-12 23:14:40 +01:00
Kami 1b141c10fb 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
2025-02-12 00:15:39 +01:00
Kami 71505b4de1 *ignore, just wanted to try out a crappy new logo for Discord push events* 2025-02-11 10:56:11 +01:00
Kami 66f3d03bc6 Program logging rework
- Logging functionality has been centralised to 'globals.log'

This should allow easy log integration into subsystems without the need of passing the function everywhere *phew*
2025-02-11 10:47:48 +01:00
Kami 699d8d493e Added latency to "ping" command
Ongoing bugfix for slash commands (#6)
2025-02-10 21:05:20 +01:00
Kami 623aeab9fb Fixed (?) a Twitch authentication crash
- The automatic token refresh function crashed after a certain time since last token refresh. This should fix it
2025-02-10 13:02:51 +01:00
Kami 3ad6504d69 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
2025-02-10 12:32:30 +01:00
Kami aed3d24e33 Started implementing Universal User Identification (UUI)
- added database table "users"
- table allows for assigning individual users a UUID for universal processing
- will support account linking in the future

- fixed a bug with reporting Discord commands in logs
2025-02-05 00:33:02 +01:00
Kami 8074fbbef4 Cleaned log code lines 2025-02-04 13:35:25 +01:00
Kami 403ee0aed5 Ensure FATAL logs always go through to console 2025-02-04 13:21:33 +01:00
Kami 1a97a0a78e Reworked logging to optionally log to file as well 2025-02-04 13:16:03 +01:00
Kami 63256b8984 added logo to .gitignore 2025-02-03 23:02:44 +01:00
Kami faea372a56 Cleaned up command execution debug logs for better readability 2025-02-03 23:01:40 +01:00
Kami 87d05d961a Fixed !help not processing certain help sections correctly if example details were missing. 2025-02-03 22:37:20 +01:00
Kami 5730840209 Improved !help
- Ensured it fetches the correct commands and help configuration depending on platform.
- Removed certain duplicate checks that intiated functions twice or repeated value asignments.
- Added the @monitor_cmd flag to commands, allowing easy command runtime diagnostics, including execution time.
2025-02-03 22:02:56 +01:00
Kami 28d22da0c1 + Added !help command
- Removed built-in Discord !help command

NOTE:
Basic implementation.
Help text is defined in:
  - dictionary/help_twitch.json
  - dictionary/help_discord.json
2025-02-03 14:14:30 +01:00
Kami 780ec2e540 - Added basic SQL functionality with SQLite fallback.
- Added basic "Quote" system/command using the new DB feature.
2025-02-03 12:17:55 +01:00
Kami af97b65c2f Added BSL-1.1 license 2025-02-02 16:58:53 +01:00
Kami afa45aa913 Added basic string sanitization 2025-02-02 14:34:30 +01:00
Kami a83e27c7ed - Added automatic reconnect after refreshing Twitch bot token.
- Added reattempt limit to prevent Twitch ratelimiting.
2025-02-02 12:39:53 +01:00
Kami 095514d95d Added howling dictionary with conditional selection 2025-02-02 00:59:53 +01:00
Kami c4e51abc5b Specified platform commands were initiated from 2025-02-02 00:32:38 +01:00
Kami dd05c26b5d Added command logging to console 2025-02-02 00:29:58 +01:00
Kami 1bc5d5c042 . 2025-02-02 00:18:44 +01:00
Kami 29e48907df improved uptime calculation with fewer lines 2025-02-02 00:14:55 +01:00
kami 913f63c43b Add permissions.json 2025-02-01 22:41:27 +00:00
Kami 5725439354 . 2025-02-01 23:40:28 +01:00
Kami c2676cf8c7 Updated .gitignore to exclude permissions.json 2025-02-01 23:39:13 +01:00
Kami 1b9a78b3d6 Added basic permissions system.
Needs to be vastly improved
2025-02-01 23:38:13 +01:00
Kami 9ef553ecb0 - Added dictionary functionality
- Looks up a file in the "dictionary" folder
  - Returns a random category string within that file
- Added proper start time fetching through globals.get_bot_start_time()
2025-02-01 22:54:10 +01:00
38 changed files with 6663 additions and 237 deletions

6
.gitignore vendored
View File

@ -4,4 +4,8 @@ config.json
error_log.txt error_log.txt
__pycache__ __pycache__
Test ?/ Test ?/
.venv .venv
permissions.json
local_database.sqlite
logo.*
_SQL_PREFILL_QUERIES_

View File

@ -1,32 +1,649 @@
# bot_discord.py # bot_discord.py
import discord import discord
from discord.ext import commands from discord import app_commands
from discord.ext import commands, tasks
import importlib import importlib
import cmd_discord import cmd_discord
import json
import os
import globals
import modules
import modules.utility
from modules.db import log_message, lookup_user, log_bot_event, log_discord_activity
primary_guild = globals.constants.primary_discord_guild()
class DiscordBot(commands.Bot): class DiscordBot(commands.Bot):
def __init__(self, config, log_func): def __init__(self):
super().__init__(command_prefix="!", intents=discord.Intents.all()) super().__init__(command_prefix="!", intents=discord.Intents.all())
self.config = config self.remove_command("help") # Remove built-in help function
self.log = log_func # Use the logging function from bots.py self.config = globals.constants.config_data
self.load_commands() self.log = globals.log # Use the logging function from bots.py
self.db_conn = None # We'll set this later
self.help_data = None # We'll set this later
def load_commands(self): globals.log("Discord bot initiated")
# 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):
""" """
Load all commands dynamically from cmd_discord.py. Store the DB connection in the bot so commands can use it.
""" """
self.db_conn = db_conn
async def setup_hook(self):
# This is an async-ready function you can override in discord.py 2.0.
for filename in os.listdir("cmd_discord"):
if filename.endswith(".py") and filename != "__init__.py":
cog_name = f"cmd_discord.{filename[:-3]}"
await self.load_extension(cog_name) # now we can await it
# Log which cogs got loaded
short_name = filename[:-3]
globals.log(f"Loaded Discord command cog '{short_name}'", "DEBUG")
globals.log("All Discord command cogs loaded successfully.", "INFO")
# Now that cogs are all loaded, run any help file initialization:
help_json_path = "dictionary/help_discord.json"
modules.utility.initialize_help_data(
bot=self,
help_json_path=help_json_path,
is_discord=True
)
@commands.command(name="reload")
@commands.is_owner()
async def reload(ctx, cog_name: str):
"""Reloads a specific cog without restarting the bot."""
try: try:
importlib.reload(cmd_discord) await ctx.bot.unload_extension(f"cmd_discord.{cog_name}")
cmd_discord.setup(self) await ctx.bot.load_extension(f"cmd_discord.{cog_name}")
self.log("Discord commands loaded successfully.", "INFO") await ctx.reply(f"✅ Reloaded `{cog_name}` successfully!")
globals.log(f"Successfully reloaded the command cog `{cog_name}`", "INFO")
except Exception as e: except Exception as e:
self.log(f"Error loading Discord commands: {e}", "ERROR") await ctx.reply(f"❌ Error reloading `{cog_name}`: {e}")
globals.log(f"Failed to reload the command cog `{cog_name}`", "ERROR")
async def on_message(self, message):
if message.guild:
guild_name = message.guild.name
channel_name = message.channel.name
else:
guild_name = "DM"
channel_name = "Direct Message"
globals.log(f"Message detected by '{message.author.name}' in '{guild_name}' - #'{channel_name}'", "DEBUG")
try:
is_bot = message.author.bot
user_id = str(message.author.id)
user_name = message.author.name
display_name = message.author.display_name
platform_str = f"discord-{guild_name}"
channel_str = channel_name
# Track user activity
modules.utility.track_user_activity(
db_conn=self.db_conn,
platform="discord",
user_id=user_id,
username=user_name,
display_name=display_name,
user_is_bot=is_bot
)
attachments = ", ".join(a.url for a in message.attachments) if message.attachments else ""
log_message(
db_conn=self.db_conn,
identifier=user_id,
identifier_type="discord_user_id",
message_content=message.content or "",
platform=platform_str,
channel=channel_str,
attachments=attachments,
platform_message_id=str(message.id) # Include Discord message ID
)
except Exception as e:
globals.log(f"... UUI lookup failed: {e}", "WARNING")
pass
try:
await self.process_commands(message)
globals.log(f"Command processing complete", "DEBUG")
except Exception as e:
globals.log(f"Command processing failed: {e}", "ERROR")
# async def on_reaction_add(self, reaction, user):
# if user.bot:
# return # Ignore bot reactions
# guild_name = reaction.message.guild.name if reaction.message.guild else "DM"
# channel_name = reaction.message.channel.name if hasattr(reaction.message.channel, "name") else "Direct Message"
# globals.log(f"Reaction added by '{user.name}' on message '{reaction.message.id}' in '{guild_name}' - #{channel_name}", "DEBUG")
# modules.utility.track_user_activity(
# db_conn=self.db_conn,
# platform="discord",
# user_id=str(user.id),
# username=user.name,
# display_name=user.display_name,
# user_is_bot=user.bot
# )
# platform_str = f"discord-{guild_name}"
# channel_str = channel_name
# log_message(
# db_conn=self.db_conn,
# identifier=str(user.id),
# identifier_type="discord_user_id",
# message_content=f"Reaction Added: {reaction.emoji} on Message ID: {reaction.message.id}",
# platform=platform_str,
# channel=channel_str
# )
# async def on_reaction_remove(self, reaction, user):
# if user.bot:
# return # Ignore bot reactions
# guild_name = reaction.message.guild.name if reaction.message.guild else "DM"
# channel_name = reaction.message.channel.name if hasattr(reaction.message.channel, "name") else "Direct Message"
# globals.log(f"Reaction removed by '{user.name}' on message '{reaction.message.id}' in '{guild_name}' - #{channel_name}", "DEBUG")
# modules.utility.track_user_activity(
# db_conn=self.db_conn,
# platform="discord",
# user_id=str(user.id),
# username=user.name,
# display_name=user.display_name,
# user_is_bot=user.bot
# )
# platform_str = f"discord-{guild_name}"
# channel_str = channel_name
# log_message(
# db_conn=self.db_conn,
# identifier=str(user.id),
# identifier_type="discord_user_id",
# message_content=f"Reaction Removed: {reaction.emoji} on Message ID: {reaction.message.id}",
# platform=platform_str,
# channel=channel_str
# )
# async def on_message_edit(self, before, after):
# if before.author.bot:
# return # Ignore bot edits
# guild_name = before.guild.name if before.guild else "DM"
# channel_name = before.channel.name if hasattr(before.channel, "name") else "Direct Message"
# globals.log(f"Message edited by '{before.author.name}' in '{guild_name}' - #{channel_name}", "DEBUG")
# globals.log(f"Before: {before.content}\nAfter: {after.content}", "DEBUG")
# modules.utility.track_user_activity(
# db_conn=self.db_conn,
# platform="discord",
# user_id=str(before.author.id),
# username=before.author.name,
# display_name=before.author.display_name,
# user_is_bot=before.author.bot
# )
# platform_str = f"discord-{guild_name}"
# channel_str = channel_name
# log_message(
# db_conn=self.db_conn,
# identifier=str(before.author.id),
# identifier_type="discord_user_id",
# message_content=f"Message Edited:\nBefore: {before.content}\nAfter: {after.content}",
# platform=platform_str,
# channel=channel_str
# )
# async def on_thread_create(self, thread):
# globals.log(f"Thread '{thread.name}' created in #{thread.parent.name}", "DEBUG")
# modules.utility.track_user_activity(
# db_conn=self.db_conn,
# platform="discord",
# user_id=str(thread.owner_id),
# username=thread.owner.name if thread.owner else "Unknown",
# display_name=thread.owner.display_name if thread.owner else "Unknown",
# user_is_bot=False
# )
# log_message(
# db_conn=self.db_conn,
# identifier=str(thread.owner_id),
# identifier_type="discord_user_id",
# message_content=f"Thread Created: {thread.name} in #{thread.parent.name}",
# platform=f"discord-{thread.guild.name}",
# channel=thread.parent.name
# )
# async def on_thread_update(self, before, after):
# globals.log(f"Thread updated: '{before.name}' -> '{after.name}'", "DEBUG")
# log_message(
# db_conn=self.db_conn,
# identifier=str(before.owner_id),
# identifier_type="discord_user_id",
# message_content=f"Thread Updated: '{before.name}' -> '{after.name}'",
# platform=f"discord-{before.guild.name}",
# channel=before.parent.name
# )
# async def on_thread_delete(self, thread):
# globals.log(f"Thread '{thread.name}' deleted", "DEBUG")
# log_message(
# db_conn=self.db_conn,
# identifier=str(thread.owner_id),
# identifier_type="discord_user_id",
# message_content=f"Thread Deleted: {thread.name}",
# platform=f"discord-{thread.guild.name}",
# channel=thread.parent.name
# )
def load_bot_settings(self):
"""Loads bot activity settings from JSON file."""
try:
with open("settings/discord_bot_settings.json", "r") as file:
return json.load(file)
except Exception as e:
self.log(f"Failed to load settings: {e}", "ERROR")
return {
"activity_mode": 0,
"static_activity": {"type": "Playing", "name": "with my commands!"},
"rotating_activities": [],
"dynamic_activities": {},
"rotation_interval": 600
}
async def on_command(self, ctx):
"""Logs every command execution at DEBUG level."""
_cmd_args = str(ctx.message.content).split(" ")[1:]
channel_name = "Direct Message" if "Direct Message with" in str(ctx.channel) else ctx.channel
globals.log(f"Command '{ctx.command}' (Discord) initiated by {ctx.author} in #{channel_name}", "DEBUG")
if len(_cmd_args) > 1: globals.log(f"!{ctx.command} arguments: {_cmd_args}", "DEBUG")
async def on_interaction(interaction: discord.Interaction):
# Only log application command (slash command) interactions.
if interaction.type == discord.InteractionType.application_command:
# Get the command name from the interaction data.
command_name = interaction.data.get("name")
# Get the options (arguments) if any.
options = interaction.data.get("options", [])
# Convert options to a list of values or key-value pairs.
option_values = [f'{opt.get("name")}: {opt.get("value")}' for opt in options]
# Determine the channel name (or DM).
if interaction.channel and hasattr(interaction.channel, "name"):
channel_name = interaction.channel.name
else:
channel_name = "Direct Message"
globals.log(
f"Command '{command_name}' (Discord) initiated by {interaction.user} in #{channel_name}",
"DEBUG"
)
if option_values:
globals.log(f"Command '{command_name}' arguments: {option_values}", "DEBUG")
async def on_ready(self): async def on_ready(self):
self.log(f"Discord bot is online as {self.user}", "INFO") """Runs when the bot successfully logs in."""
# Load activity settings
self.settings = self.load_bot_settings()
# Set initial bot activity
await self.update_activity()
# Sync Slash Commands
try:
# Sync slash commands globally
#await self.tree.sync()
#globals.log("Discord slash commands synced.")
num_guilds = len(self.config["discord_guilds"])
cmd_tree_result = (await self.tree.sync(guild=primary_guild["object"]))
command_names = [command.name for command in cmd_tree_result] if cmd_tree_result else None
if primary_guild["id"]:
try:
guild_info = await modules.utility.get_guild_info(self, primary_guild["id"])
primary_guild_name = guild_info["name"]
except Exception as e:
primary_guild_name = f"{primary_guild["id"]}"
globals.log(f"Guild lookup failed: {e}", "ERROR")
_log_message = f"{num_guilds} guilds (global)" if num_guilds > 1 else f"guild: {primary_guild_name}"
globals.log(f"Discord slash commands force synced to {_log_message}")
globals.log(f"Discord slash commands that got synced: {command_names}")
else:
globals.log("Discord commands synced globally.")
except Exception as e:
globals.log(f"Unable to sync Discord slash commands: {e}")
# Log successful bot startup
globals.log(f"Discord bot is online as {self.user}")
log_bot_event(self.db_conn, "DISCORD_RECONNECTED", "Discord bot logged in.")
async def on_disconnect(self):
globals.log("Discord bot has lost connection!", "WARNING")
log_bot_event(self.db_conn, "DISCORD_DISCONNECTED", "Discord bot lost connection.")
async def update_activity(self):
"""Sets the bot's activity based on settings."""
mode = self.settings.get("activity_mode", 0)
# Stop rotating activity loop if it's running
if self.change_rotating_activity.is_running():
self.change_rotating_activity.stop()
if mode == 0:
# Disable activity
await self.change_presence(activity=None)
self.log("Activity disabled", "DEBUG")
elif mode == 1:
# Static activity
activity_data = self.settings.get("static_activity", {})
if activity_data:
activity = self.get_activity(activity_data.get("type"), activity_data.get("name"))
await self.change_presence(activity=activity)
self.log(f"Static activity set: {activity_data['type']} {activity_data['name']}", "DEBUG")
else:
await self.change_presence(activity=None)
self.log("No static activity defined", "DEBUG")
elif mode == 2:
# Rotating activity
activities = self.settings.get("rotating_activities", [])
if activities:
self.change_rotating_activity.change_interval(seconds=self.settings.get("rotation_interval", 300))
self.change_rotating_activity.start()
self.log("Rotating activity mode enabled", "DEBUG")
else:
self.log("No rotating activities defined, falling back to static.", "INFO")
await self.update_activity_static()
elif mode == 3:
# Dynamic activity with fallback
if not await self.set_dynamic_activity():
self.log("Dynamic activity unavailable, falling back.", "INFO")
# Fallback to rotating or static
if self.settings.get("rotating_activities"):
self.change_rotating_activity.start()
self.log("Falling back to rotating activity.", "DEBUG")
else:
await self.update_activity_static()
else:
self.log("Invalid activity mode, defaulting to disabled.", "WARNING")
await self.change_presence(activity=None)
async def update_activity_static(self):
"""Fallback to static activity if available."""
activity_data = self.settings.get("static_activity", {})
if activity_data:
activity = self.get_activity(activity_data.get("type"), activity_data.get("name"))
await self.change_presence(activity=activity)
self.log(f"Static activity set: {activity_data['type']} {activity_data['name']}", "DEBUG")
else:
await self.change_presence(activity=None)
self.log("No static activity defined, activity disabled.", "DEBUG")
@tasks.loop(seconds=300) # Default to 5 minutes
async def change_rotating_activity(self):
"""Rotates activities every set interval."""
activities = self.settings.get("rotating_activities", [])
if not activities:
self.log("No rotating activities available, stopping rotation.", "INFO")
self.change_rotating_activity.stop()
return
# Rotate activity
activity_data = activities.pop(0)
activities.append(activity_data) # Move to the end of the list
activity = self.get_activity(activity_data.get("type"), activity_data.get("name"))
await self.change_presence(activity=activity)
self.log(f"Rotating activity: {activity_data['type']} {activity_data['name']}", "DEBUG")
async def set_dynamic_activity(self):
"""Sets a dynamic activity based on external conditions."""
twitch_live = await modules.utility.is_channel_live(self)
if twitch_live:
activity_data = self.settings["dynamic_activities"].get("twitch_live")
else:
activity_data = self.settings["dynamic_activities"].get("default_idle")
if activity_data:
activity = self.get_activity(activity_data.get("type"), activity_data.get("name"), activity_data.get("url"))
await self.change_presence(activity=activity)
self.log(f"Dynamic activity set: {activity_data['type']} {activity_data['name']}", "DEBUG")
return True # Dynamic activity was set
return False # No dynamic activity available
def get_activity(self, activity_type, name, url=None):
"""Returns a discord activity object based on type, including support for Custom Status."""
activity_map = {
"Playing": discord.Game(name=name),
"Streaming": discord.Streaming(name=name, url=url or "https://twitch.tv/OokamiKunTV"),
"Listening": discord.Activity(type=discord.ActivityType.listening, name=name),
"Watching": discord.Activity(type=discord.ActivityType.watching, name=name),
"Custom": discord.CustomActivity(name=name)
}
return activity_map.get(activity_type, discord.Game(name="around in Discord"))
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, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID")
if not user_uuid:
globals.log(f"User {member.name} ({discord_user_id}) not found in 'users'. Attempting to add...", "INFO")
modules.utility.track_user_activity(
db_conn=self.db_conn,
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, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID")
if not user_uuid:
globals.log(f"Failed to associate {member.name} ({discord_user_id}) with a UUID. Skipping activity log.", "WARNING")
return # Prevent logging with invalid UUID
if user_uuid:
globals.log(f"Successfully added {member.name} ({discord_user_id}) to the UUI database.", "INFO")
# Detect join and leave events
if before.channel is None and after.channel is not None:
modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, "JOIN", after.channel.name)
elif before.channel is not None and after.channel is None:
modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, "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, guild_id, discord_user_id, "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, guild_id, discord_user_id, 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, guild_id, discord_user_id, 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, guild_id, discord_user_id, 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, guild_id, discord_user_id, 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
if before.activities == after.activities and before.status == after.status:
# No real changes, skip
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,
identifier=discord_user_id,
identifier_type="discord_user_id",
target_identifier="UUID"
)
if not user_uuid:
globals.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,
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, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID")
if not user_uuid:
globals.log(f"ERROR: Failed to associate {after.name} ({discord_user_id}) with a UUID. Skipping activity log.", "ERROR")
return
if user_uuid:
globals.log(f"Successfully added {after.name} ({discord_user_id}) to the UUI database.", "INFO")
# 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, guild_id, discord_user_id, new_activity[0], None, new_activity[1])
if old_activity:
modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, 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): async def run(self, token):
try: try:
await super().start(token) await super().start(token)
except Exception as e: except Exception as e:
self.log(f"Discord bot error: {e}", "CRITICAL") globals.log(f"Discord bot error: {e}", "CRITICAL")

View File

@ -6,31 +6,109 @@ from twitchio.ext import commands
import importlib import importlib
import cmd_twitch import cmd_twitch
import globals
import modules
import modules.utility
from modules.db import log_message, lookup_user, log_bot_event
twitch_channels = globals.constants.config_data["twitch_channels"]
class TwitchBot(commands.Bot): class TwitchBot(commands.Bot):
def __init__(self, config, log_func): def __init__(self):
self.client_id = os.getenv("TWITCH_CLIENT_ID") self.client_id = os.getenv("TWITCH_CLIENT_ID")
self.client_secret = os.getenv("TWITCH_CLIENT_SECRET") self.client_secret = os.getenv("TWITCH_CLIENT_SECRET")
self.token = os.getenv("TWITCH_BOT_TOKEN") self.token = os.getenv("TWITCH_BOT_TOKEN")
self.refresh_token = os.getenv("TWITCH_REFRESH_TOKEN") self.refresh_token = os.getenv("TWITCH_REFRESH_TOKEN")
self.log = log_func # Use the logging function from bots.py self.log = globals.log # Use the logging function from bots.py
self.config = config self.config = globals.constants.config_data
self.db_conn = None # We'll set this later
self.help_data = None # We'll set this later
# 1) Initialize the parent Bot FIRST # 1) Initialize the parent Bot FIRST
super().__init__( super().__init__(
token=self.token, token=self.token,
prefix="!", prefix="!",
initial_channels=config["twitch_channels"] initial_channels=twitch_channels
) )
globals.log("Twitch bot initiated")
# 2) Then load commands # 2) Then load commands
self.load_commands() self.load_commands()
async def refresh_access_token(self): def set_db_connection(self, db_conn):
""" """
Refreshes the Twitch access token using the stored refresh token. Store the DB connection so that commands can use it.
Retries up to 2 times before logging a fatal error.
""" """
self.log("Attempting to refresh Twitch token...", "INFO") self.db_conn = db_conn
async def event_message(self, message):
"""
Called every time a Twitch message is received (chat message in a channel).
"""
if message.echo:
return
try:
author = message.author
if not author:
return
is_bot = False
user_id = str(author.id)
user_name = author.name
display_name = author.display_name or user_name
globals.log(f"Message detected, attempting UUI lookup on {user_name} ...", "DEBUG")
modules.utility.track_user_activity(
db_conn=self.db_conn,
platform="twitch",
user_id=user_id,
username=user_name,
display_name=display_name,
user_is_bot=is_bot
)
globals.log("... UUI lookup complete.", "DEBUG")
log_message(
db_conn=self.db_conn,
identifier=user_id,
identifier_type="twitch_user_id",
message_content=message.content or "",
platform="twitch",
channel=message.channel.name,
attachments="",
platform_message_id=str(message.id) # Include Twitch message ID
)
except Exception as e:
globals.log(f"... UUI lookup failed: {e}", "ERROR")
await self.handle_commands(message)
async def event_ready(self):
globals.log(f"Twitch bot is online as {self.nick}")
modules.utility.list_channels(self)
kami_status = "OokamiKunTV is currently LIVE" if await modules.utility.is_channel_live(self) else "OokamikunTV is currently not streaming"
globals.log(kami_status)
log_bot_event(self.db_conn, "TWITCH_RECONNECTED", "Twitch bot logged in.")
async def event_disconnected(self):
globals.log("Twitch bot has lost connection!", "WARNING")
log_bot_event(self.db_conn, "TWITCH_DISCONNECTED", "Twitch bot lost connection.")
async def refresh_access_token(self, automatic=False):
"""
Refresh the Twitch access token using the stored refresh token.
If 'automatic' is True, do NOT shut down the bot or require manual restart.
Return True if success, False if not.
"""
self.log("Attempting to refresh Twitch token...")
url = "https://id.twitch.tv/oauth2/token" url = "https://id.twitch.tv/oauth2/token"
params = { params = {
@ -40,31 +118,109 @@ class TwitchBot(commands.Bot):
"grant_type": "refresh_token" "grant_type": "refresh_token"
} }
for attempt in range(3): # Attempt up to 3 times try:
try: response = requests.post(url, params=params)
response = requests.post(url, params=params) data = response.json()
data = response.json() self.log(f"Twitch token response: {data}", "DEBUG")
if "access_token" in data: if "access_token" in data:
self.token = data["access_token"] self.token = data["access_token"]
self.refresh_token = data.get("refresh_token", self.refresh_token) self.refresh_token = data.get("refresh_token", self.refresh_token)
os.environ["TWITCH_BOT_TOKEN"] = self.token
os.environ["TWITCH_REFRESH_TOKEN"] = self.refresh_token
self.update_env_file()
os.environ["TWITCH_BOT_TOKEN"] = self.token # Validate newly refreshed token:
os.environ["TWITCH_REFRESH_TOKEN"] = self.refresh_token if not await self.validate_token():
self.update_env_file() self.log("New token is still invalid, re-auth required.", "CRITICAL")
if not automatic:
await self.prompt_manual_token()
return False
self.log("Twitch token refreshed successfully.", "INFO") self.log("Twitch token refreshed successfully.")
return # Success, exit function return True
else:
self.log(f"Twitch token refresh failed (Attempt {attempt+1}/3): {data}", "WARNING")
except Exception as e: elif "error" in data and data["error"] == "invalid_grant":
self.log(f"Twitch token refresh error (Attempt {attempt+1}/3): {e}", "ERROR") self.log("Refresh token is invalid or expired; manual re-auth required.", "CRITICAL")
if not automatic:
await self.prompt_manual_token()
return False
else:
self.log(f"Unexpected refresh response: {data}", "ERROR")
if not automatic:
await self.prompt_manual_token()
return False
await asyncio.sleep(10) # Wait before retrying except Exception as e:
self.log(f"Twitch token refresh error: {e}", "ERROR")
if not automatic:
await self.prompt_manual_token()
return False
async def shutdown_gracefully(self):
"""
Gracefully shuts down the bot, ensuring all resources are cleaned up.
"""
self.log("Closing Twitch bot gracefully...", "INFO")
try:
await self.close() # Closes TwitchIO bot properly
self.log("Twitch bot closed successfully.", "INFO")
except Exception as e:
self.log(f"Error during bot shutdown: {e}", "ERROR")
self.log("Bot has been stopped. Please restart it manually.", "FATAL")
async def validate_token(self):
"""
Validate the current Twitch token by making a test API request.
"""
url = "https://id.twitch.tv/oauth2/validate"
headers = {"Authorization": f"OAuth {self.token}"}
try:
response = requests.get(url, headers=headers)
self.log(f"Token validation response: {response.status_code}, {response.text}", "DEBUG")
return response.status_code == 200
except Exception as e:
self.log(f"Error during token validation: {e}", "ERROR")
return False
async def prompt_manual_token(self):
"""
Prompt the user in-terminal to manually enter a new Twitch access token.
"""
self.log("Prompting user for manual Twitch token input.", "WARNING")
new_token = input("Enter a new valid Twitch access token: ").strip()
if new_token:
self.token = new_token
os.environ["TWITCH_BOT_TOKEN"] = self.token
self.update_env_file()
self.log("New Twitch token entered manually. Please restart the bot.", "INFO")
else:
self.log("No valid token entered. Bot cannot continue.", "FATAL")
async def try_refresh_and_reconnect(self) -> bool:
"""
Attempts to refresh the token and reconnect the bot automatically.
Returns True if successful, False if refresh/manual re-auth is needed.
"""
try:
# Refresh the token in the same manner as refresh_access_token()
success = await self.refresh_access_token(automatic=True)
if not success:
return False
# If we got here, we have a valid new token.
# We can call self.start() again in the same run.
self.log("Re-initializing the Twitch connection with the new token...", "INFO")
self._http.token = self.token # Make sure TwitchIO sees the new token
await self.start()
return True
except Exception as e:
self.log(f"Auto-reconnect failed after token refresh: {e}", "ERROR")
return False
# If all attempts fail, log error
self.log("Twitch token refresh failed after 3 attempts.", "FATAL")
def update_env_file(self): def update_env_file(self):
""" """
@ -83,37 +239,72 @@ class TwitchBot(commands.Bot):
else: else:
file.write(line) file.write(line)
self.log("Updated .env file with new Twitch token.", "INFO") globals.log("Updated .env file with new Twitch token.")
except Exception as e: except Exception as e:
self.log(f"Failed to update .env file: {e}", "ERROR") globals.log(f"Failed to update .env file: {e}", "ERROR")
def load_commands(self): def load_commands(self):
""" """
Load all commands dynamically from cmd_twitch.py. Load all commands from cmd_twitch.py
""" """
try: try:
importlib.reload(cmd_twitch)
cmd_twitch.setup(self) cmd_twitch.setup(self)
self.log("Twitch commands loaded successfully.", "INFO") globals.log("Twitch commands loaded successfully.")
# Now load the help info from dictionary/help_twitch.json
help_json_path = "dictionary/help_twitch.json"
modules.utility.initialize_help_data(
bot=self,
help_json_path=help_json_path,
is_discord=False # Twitch
)
except Exception as e: except Exception as e:
self.log(f"Error loading Twitch commands: {e}", "ERROR") globals.log(f"Error loading Twitch commands: {e}", "ERROR")
async def run(self): async def run(self):
""" """
Run the Twitch bot, refreshing tokens if needed. Attempt to start the bot once. If token is invalid, refresh it,
then re-instantiate a fresh TwitchBot in the same Python process.
This avoids any manual restarts or external managers.
""" """
try: try:
self.log(f"Twitch bot connecting...", "INFO") # Normal attempt: just call self.start()
self.log(f"...Consider online if no further messages", "INFO")
await self.start() await self.start()
#while True:
# await self.refresh_access_token()
# await asyncio.sleep(10800) # Refresh every 3 hours
except Exception as e: except Exception as e:
self.log(f"Twitch bot failed to start: {e}", "CRITICAL") self.log(f"Twitch bot failed to start: {e}", "CRITICAL")
# Check if error is invalid token
if "Invalid or unauthorized Access Token passed." in str(e): if "Invalid or unauthorized Access Token passed." in str(e):
self.log("Attempting token refresh...", "WARNING")
refresh_success = await self.refresh_access_token()
if not refresh_success:
# If refresh truly failed => we can't proceed.
# Log a shutdown, do no external restart.
self.log("Refresh failed. Shutting down in same run. Token is invalid.", "SHUTDOWN")
return
# If refresh succeeded, we have a new valid token in .env.
# Now we must forcibly close THIS bot instance.
try: try:
await self.refresh_access_token() self.log("Closing old bot instance after refresh...", "DEBUG")
except Exception as e: await self.close()
self.log(f"Unable to refresh Twitch token! Twitch bot will be offline!", "CRITICAL") except Exception as close_err:
self.log(f"Ignored close() error: {close_err}", "DEBUG")
# Create a brand-new instance, referencing the updated token from .env
self.log("Creating a fresh TwitchBot instance with the new token...", "INFO")
from bot_twitch import TwitchBot # Re-import or define
new_bot = TwitchBot() # Re-run __init__, loads new token from environment
new_bot.set_db_connection(self.db_conn)
self.log("Starting the new TwitchBot in the same run...", "INFO")
await new_bot.run() # Now call *its* run method
return # Our job is done
else:
# Unknown error => you can either do a SHUTDOWN or ignore
self.log("Could not connect due to an unknown error. Shutting down in same run...", "SHUTDOWN")
return

119
bots.py
View File

@ -5,79 +5,104 @@ import asyncio
import sys import sys
import time import time
import traceback import traceback
import globals
from functools import partial
import twitchio.ext
from discord.ext import commands from discord.ext import commands
from dotenv import load_dotenv from dotenv import load_dotenv
from bot_discord import DiscordBot from bot_discord import DiscordBot
from bot_twitch import TwitchBot 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, ensure_chatlog_table, checkenable_db_fk
from modules import db, utility
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
# Clear previous current-run logfile
globals.reset_curlogfile()
# Load bot configuration # Load bot configuration
CONFIG_PATH = "config.json" config_data = globals.Constants.config_data
try:
with open(CONFIG_PATH, "r") as f:
config_data = json.load(f)
except FileNotFoundError:
print("Error: config.json not found.")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error parsing config.json: {e}")
sys.exit(1)
# Global settings
bot_start_time = time.time()
###############################
# Simple Logging System
###############################
def log(message, level="INFO"):
"""
A simple logging function with adjustable log levels.
Logs messages in a structured format.
Available levels:
DEBUG, INFO, WARNING, ERROR, CRITICAL, FATAL
See 'config.json' for disabling/enabling logging levels
"""
log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"]
if level not in log_levels:
level = "INFO" # Default to INFO if an invalid level is provided
if level in config_data["log_levels"]:
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
log_message = f"[{timestamp}] [{level}] {message}"
try:
print(log_message) # Print to terminal
except Exception:
pass # Prevent logging failures from crashing the bot
# Placeholder for future expansions (e.g., file logging, Discord alerts, etc.)
############################### ###############################
# Main Event Loop # Main Event Loop
############################### ###############################
async def main(): async def main():
global discord_bot, twitch_bot global discord_bot, twitch_bot, db_conn
log("Initializing bots...", "INFO") # Log initial start
globals.log("--------------- BOT STARTUP ---------------")
# Before creating your DiscordBot/TwitchBot, initialize DB
db_conn = globals.init_db_conn()
discord_bot = DiscordBot(config_data, log) try: # Ensure FKs are enabled
twitch_bot = TwitchBot(config_data, log) db.checkenable_db_fk(db_conn)
except Exception as e:
globals.log(f"Unable to ensure Foreign keys are enabled: {e}", "WARNING")
log("Starting Discord and Twitch bots...", "INFO") # auto-create the quotes table if it doesn't exist
tables = {
"Bot events table": partial(db.ensure_bot_events_table, db_conn),
"Quotes table": partial(db.ensure_quotes_table, db_conn),
"Users table": partial(db.ensure_users_table, db_conn),
"Platform_Mapping table": partial(db.ensure_platform_mapping_table, db_conn),
"Chatlog table": partial(db.ensure_chatlog_table, db_conn),
"Howls table": partial(db.ensure_userhowls_table, db_conn),
"Discord activity table": partial(db.ensure_discord_activity_table, db_conn),
"Account linking table": partial(db.ensure_link_codes_table, db_conn),
"Community events table": partial(db.ensure_community_events_table, db_conn)
}
try:
for table, func in tables.items():
func() # Call the function with db_conn and log already provided
globals.log(f"{table} ensured.", "DEBUG")
except Exception as e:
globals.log(f"Unable to ensure DB tables exist: {e}", "FATAL")
globals.log("Initializing bots...")
# Create both bots
discord_bot = DiscordBot()
twitch_bot = TwitchBot()
# Log startup
utility.log_bot_startup(db_conn)
# Provide DB connection to both bots
try:
discord_bot.set_db_connection(db_conn)
twitch_bot.set_db_connection(db_conn)
globals.log(f"Initialized database connection to both bots")
except Exception as e:
globals.log(f"Unable to initialize database connection to one or both bots: {e}", "FATAL")
globals.log("Starting Discord and Twitch bots...")
discord_task = asyncio.create_task(discord_bot.run(os.getenv("DISCORD_BOT_TOKEN"))) discord_task = asyncio.create_task(discord_bot.run(os.getenv("DISCORD_BOT_TOKEN")))
twitch_task = asyncio.create_task(twitch_bot.run()) twitch_task = asyncio.create_task(twitch_bot.run())
from modules.utility import dev_func
enable_dev_func = False
if enable_dev_func:
dev_func_result = dev_func(db_conn, enable_dev_func)
globals.log(f"dev_func output: {dev_func_result}")
await asyncio.gather(discord_task, twitch_task) await asyncio.gather(discord_task, twitch_task)
#await asyncio.gather(discord_task)
if __name__ == "__main__": if __name__ == "__main__":
try: try:
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt:
utility.log_bot_shutdown(db_conn, intent="User Shutdown")
except Exception as e: except Exception as e:
error_trace = traceback.format_exc() error_trace = traceback.format_exc()
log(f"Fatal Error: {e}\n{error_trace}", "FATAL") globals.log(f"Fatal Error: {e}\n{error_trace}", "FATAL")
utility.log_bot_shutdown(db_conn)

View File

@ -1,67 +1,934 @@
# cmd_common/common_commands.py # cmd_common/common_commands.py
import random import random
import time import time
from modules import utility
import globals
import json
import re
def generate_howl_message(username: str) -> str: from modules import db
#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:
""" """
Return a random howl message (0-100). A single function that handles !howl logic for both Discord and Twitch.
- If 0%: a fail message. We rely on ctx to figure out the platform, the user, the arguments, etc.
- If 100%: a perfect success. Return a string that the caller will send.
- Otherwise: normal message with the random %.
""" """
howl_percentage = random.randint(0, 100) utility.wfstl()
# 1) Detect which platform
if howl_percentage == 0: # We might do something like:
return f"Uh oh, {username} failed with a miserable 0% howl ..." platform, author_id, author_name, author_display_name, args = extract_ctx_info(ctx)
elif howl_percentage == 100:
return f"Wow, {username} performed a perfect 100% howl!" # 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
utility.wfetl()
return handle_howl_stats(ctx, platform, target_name)
else: else:
return f"{username} howled at {howl_percentage}%!" # normal usage => random generation
utility.wfetl()
return handle_howl_normal(ctx, platform, author_id, author_display_name)
def ping(): def extract_ctx_info(ctx):
""" """
Returns a string, confirming the bot is online. Figures out if this is Discord or Twitch,
returns (platform_str, author_id, author_name, author_display_name, args).
""" """
from modules.utility import format_uptime utility.wfstl()
from bots import bot_start_time # where you stored the start timestamp # 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 []
current_time = time.time() utility.wfetl()
elapsed = current_time - bot_start_time return (platform_str, author_id, author_name, author_display_name, args)
uptime_str, uptime_s = format_uptime(elapsed)
# Define thresholds in ascending order
# (threshold_in_seconds, message)
# The message is a short "desperation" or "awake" text.
time_ranges = [
(3600, f"I've been awake for {uptime_str}. I just woke up, feeling great!"), # < 1 hour
(10800, f"I've been awake for {uptime_str}. I'm still fairly fresh!"), # 3 hours
(21600, f"I've been awake for {uptime_str}. I'm starting to get a bit weary..."), # 6 hours
(43200, f"I've been awake for {uptime_str}. 12 hours?! Might be time for coffee."), # 12 hours
(86400, f"I've been awake for {uptime_str}. A whole day without sleep... I'm okay?"), # 1 day
(172800, f"I've been awake for {uptime_str}. Two days... I'd love a nap."), # 2 days
(259200, f"I've been awake for {uptime_str}. Three days. Is sleep optional now?"), # 3 days
(345600, f"I've been awake for {uptime_str}. Four days... I'm running on fumes."), # 4 days
(432000, f"I've been awake for {uptime_str}. Five days. Please send more coffee."), # 5 days
(518400, f"I've been awake for {uptime_str}. Six days. I've forgotten what dreams are."), # 6 days
(604800, f"I've been awake for {uptime_str}. One week. I'm turning into a zombie."), # 7 days
(1209600, f"I've been awake for {uptime_str}. Two weeks. Are you sure I can't rest?"), # 14 days
(2592000, f"I've been awake for {uptime_str}. A month! The nightmares never end."), # 30 days
(7776000, f"I've been awake for {uptime_str}. Three months. I'm mostly coffee now."), # 90 days
(15552000,f"I've been awake for {uptime_str}. Six months. This is insane..."), # 180 days
(23328000,f"I've been awake for {uptime_str}. Nine months. I might be unstoppable."), # 270 days
(31536000,f"I've been awake for {uptime_str}. A year?! I'm a legend of insomnia..."), # 365 days
]
# We'll iterate from smallest to largest threshold
for threshold, msg in time_ranges:
if uptime_s < threshold:
return msg
# If none matched, it means uptime_s >= 31536000 (1 year+)
return f"I've been awake for {uptime_str}. Over a year awake... I'm beyond mortal limits!"
def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str:
"""
Normal usage: random generation, store in DB.
"""
utility.wfstl()
db_conn = ctx.bot.db_conn
# Random logic for howl percentage
howl_val = random.randint(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
)
# Consistent UUID lookup
user_data = db.lookup_user(db_conn, identifier=author_id, identifier_type=f"{platform}_user_id")
if user_data:
user_uuid = user_data["UUID"]
db.insert_howl(db_conn, user_uuid, howl_val)
else:
globals.log(f"Could not find user by ID={author_id} on {platform}. Not storing howl.", "WARNING")
utility.wfetl()
return reply
def handle_howl_stats(ctx, platform, target_name) -> str:
"""
Handles !howl stats subcommand for both community and individual users.
"""
utility.wfstl()
db_conn = ctx.bot.db_conn
# Check if requesting global stats
if target_name in ("_COMMUNITY_", "all", "global", "community"):
stats = db.get_global_howl_stats(db_conn)
if not stats:
utility.wfetl()
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"]
utility.wfetl()
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 = db.lookup_user(db_conn, identifier=target_name, identifier_type=f"{platform}_username")
if not user_data:
utility.wfetl()
return f"I don't know that user: {target_name}"
user_uuid = user_data["UUID"]
stats = db.get_howl_stats(db_conn, user_uuid)
if not stats:
utility.wfetl()
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"]
utility.wfetl()
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, platform, name_str):
"""
Consistent UUID resolution for usernames across platforms.
"""
utility.wfstl()
if platform == "discord":
ud = db.lookup_user(db_conn, name_str, "discord_user_display_name")
if ud:
utility.wfetl()
return ud
ud = db.lookup_user(db_conn, name_str, "discord_username")
utility.wfetl()
return ud
elif platform == "twitch":
ud = db.lookup_user(db_conn, name_str, "twitch_user_display_name")
if ud:
utility.wfetl()
return ud
ud = db.lookup_user(db_conn, name_str, "twitch_username")
utility.wfetl()
return ud
else:
globals.log(f"Unknown platform '{platform}' in lookup_user_by_name", "WARNING")
utility.wfetl()
return None
def ping() -> str:
"""
Returns a dynamic, randomized uptime response.
"""
utility.wfstl()
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 = [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=600)
# Get a random response from the dictionary
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}")
utility.wfetl()
return response
def greet(target_display_name: str, platform_name: str) -> str: def greet(target_display_name: str, platform_name: str) -> str:
""" """
Returns a greeting string for the given user displayname on a given platform. Returns a greeting string for the given user displayname on a given platform.
""" """
return f"Hello {target_display_name}, welcome to {platform_name}!" return f"Hello {target_display_name}, welcome to {platform_name}!"
######################
# Quotes
######################
def create_quotes_table(db_conn):
"""
Creates the 'quotes' table if it does not exist, with the columns:
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.
"""
utility.wfstl()
if not db_conn:
globals.log("No database connection available to create quotes table!", "FATAL")
utility.wfetl()
return
# Detect if this is SQLite or MariaDB
db_name = str(type(db_conn)).lower()
if 'sqlite3' in db_name:
# SQLite
create_table_sql = """
CREATE TABLE IF NOT EXISTS quotes (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
QUOTE_TEXT TEXT,
QUOTEE TEXT,
QUOTE_CHANNEL TEXT,
QUOTE_DATETIME TEXT,
QUOTE_GAME TEXT,
QUOTE_REMOVED BOOLEAN DEFAULT 0,
QUOTE_REMOVED_DATETIME TEXT
)
"""
else:
# Assume MariaDB
# Adjust column types as appropriate for your setup
create_table_sql = """
CREATE TABLE IF NOT EXISTS quotes (
ID INT PRIMARY KEY AUTO_INCREMENT,
QUOTE_TEXT TEXT,
QUOTEE VARCHAR(100),
QUOTE_CHANNEL VARCHAR(100),
QUOTE_DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP,
QUOTE_GAME VARCHAR(200),
QUOTE_REMOVED BOOLEAN DEFAULT FALSE,
QUOTE_REMOVED_DATETIME DATETIME DEFAULT NULL
)
"""
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, game_name=None):
"""
Core logic for !quote command, shared by both Discord and Twitch.
- `db_conn`: your active DB connection
- `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)
- `args`: a list of arguments (e.g. ["add", "some quote text..."], ["remove", "3"], ["info", "3"], ["search", "foo", "bar"], or ["2"] etc.)
- `game_name`: function(channel_name) -> str or None
Behavior:
1) `!quote add some text here`
-> Adds a new quote, stores channel=Discord or twitch channel name, game if twitch.
2) `!quote remove N`
-> Mark quote #N as removed.
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.
6) `!quote` (no args)
-> 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 len(args) == 0:
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()
if sub == "add":
# everything after "add" is the quote text
quote_text = " ".join(args[1:]).strip()
if not quote_text:
utility.wfetl()
return "Please provide the quote text after 'add'."
try:
utility.wfetl()
return await add_new_quote(db_conn, is_discord, ctx, quote_text, game_name)
except Exception as e:
globals.log(f"handle_quote_command() failed to add a new quote: {e}", "ERROR", exec_info=True)
elif sub == "remove":
if len(args) < 2:
utility.wfetl()
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])
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:
# Possibly a quote ID
if sub.isdigit():
quote_id = int(sub)
try:
utility.wfetl()
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:
# unrecognized subcommand => fallback to random
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, game_name: str = None):
"""
Inserts a new quote with UUID instead of username.
"""
utility.wfstl()
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, identifier=user_id, identifier_type=f"{platform}_user_id")
if not user_data:
globals.log(f"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."
user_uuid = user_data["UUID"]
channel_name = "Discord" if is_discord else ctx.channel.name
if is_discord or not game_name:
game_name = None
# Insert quote using UUID for QUOTEE
insert_sql = """
INSERT INTO quotes (QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, 0)
"""
params = (quote_text, user_uuid, channel_name, game_name)
result = db.run_db_operation(db_conn, "write", insert_sql, params)
if result is not None:
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:
utility.wfetl()
return "Failed to add quote."
async def remove_quote(db_conn, is_discord: bool, ctx, quote_id_str):
"""
Mark quote #ID as removed (QUOTE_REMOVED=1) and record removal datetime.
"""
utility.wfstl()
if not quote_id_str.isdigit():
utility.wfetl()
return 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, identifier=user_id, identifier_type=f"{platform}_user_id")
if not user_data:
globals.log(f"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."
user_uuid = user_data["UUID"]
quote_id = int(quote_id_str)
remover_user = str(user_uuid)
# Mark as removed and record removal datetime
update_sql = """
UPDATE quotes
SET QUOTE_REMOVED = 1,
QUOTE_REMOVED_BY = ?,
QUOTE_REMOVED_DATETIME = CURRENT_TIMESTAMP
WHERE ID = ?
AND QUOTE_REMOVED = 0
"""
params = (remover_user, quote_id)
rowcount = db.run_db_operation(db_conn, "update", update_sql, params)
if rowcount and rowcount > 0:
utility.wfetl()
return f"Removed quote #{quote_id}."
else:
utility.wfetl()
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):
"""
Retrieve a specific quote by ID, if not removed.
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."
"""
utility.wfstl()
# First, see if we have any quotes at all
max_id = get_max_quote_id(db_conn)
if max_id < 1:
utility.wfetl()
return "No quotes are created yet."
# Query for that specific quote
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:
# no match
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 "Unknown"
quote_removed_datetime = row[8] if row[8] else "Unknown"
platform = "discord" if is_discord else "twitch"
# Lookup UUID from users table for the quoter
user_data = db.lookup_user(db_conn, identifier=quotee, identifier_type="UUID")
if not user_data:
globals.log(f"Could not find platform name for quotee UUID {quotee}. Default to 'Unknown'", "INFO")
quotee_display = "Unknown"
else:
quotee_display = user_data[f"{platform}_user_display_name"]
if quote_removed == 1:
# Lookup UUID for removed_by if removed
removed_user_data = db.lookup_user(db_conn, identifier=quote_removed_by, identifier_type="UUID")
if not removed_user_data:
globals.log(f"Could not find platform name for remover UUID {quote_removed_by}. Default to 'Unknown'", "INFO")
quote_removed_by_display = "Unknown"
else:
quote_removed_by_display = removed_user_data[f"{platform}_user_display_name"]
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):
"""
Grab a random quote (QUOTE_REMOVED=0).
If no quotes exist or all removed, respond with "No quotes are created yet."
"""
utility.wfstl()
# First check if we have any quotes
max_id = get_max_quote_id(db_conn)
if max_id < 1:
utility.wfetl()
return "No quotes are created yet."
# We have quotes, try selecting a random one from the not-removed set
if is_sqlite(db_conn):
random_sql = """
SELECT ID, QUOTE_TEXT
FROM quotes
WHERE QUOTE_REMOVED = 0
ORDER BY RANDOM()
LIMIT 1
"""
else:
# MariaDB uses RAND()
random_sql = """
SELECT ID, QUOTE_TEXT
FROM quotes
WHERE QUOTE_REMOVED = 0
ORDER BY RAND()
LIMIT 1
"""
rows = db.run_db_operation(db_conn, "read", random_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_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"Could not find display name for quotee UUID {quotee}.", "INFO")
quotee_display = "Unknown"
else:
# Use display name or fallback to platform username
quotee_display = user_data.get(f"platform_display_name", user_data.get("platform_username", "Unknown"))
info_lines = []
info_lines.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"Could not find display name for remover UUID {quote_removed_by}.", "INFO")
quote_removed_by_display = "Unknown"
else:
# Use display name or fallback to platform username
quote_removed_by_display = removed_user_data.get(f"platform_display_name", removed_user_data.get("platform_username", "Unknown"))
else:
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:
# Use display name or fallback to platform username
quotee_display = user_data.get("platform_display_name", user_data.get("platform_username", "Unknown"))
else:
globals.log(f"Could not find display name for quotee UUID {quotee}.", "INFO")
quotee_display = "Unknown"
# 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):
"""
Return the highest ID in the quotes table, or 0 if empty.
"""
utility.wfstl()
sql = "SELECT MAX(ID) FROM quotes"
rows = db.run_db_operation(db_conn, "read", sql)
if rows and rows[0] and rows[0][0] is not None:
utility.wfetl()
return rows[0][0]
utility.wfetl()
return 0
def get_author_name(ctx, is_discord):
"""
Return the name/username of the command author.
For Discord, it's ctx.author.display_name (or ctx.author.name).
For Twitch (twitchio), it's ctx.author.name.
"""
utility.wfstl()
if is_discord:
utility.wfetl()
return str(ctx.author.display_name)
else:
utility.wfetl()
return str(ctx.author.name)
def get_channel_name(ctx):
"""
Return the channel name for Twitch. For example, ctx.channel.name in twitchio.
"""
# In twitchio, ctx.channel has .name
return str(ctx.channel.name)
async def send_message(ctx, text):
"""
Minimal helper to send a message to either Discord or Twitch.
For discord.py: await ctx.send(text)
For twitchio: await ctx.send(text)
"""
await ctx.send(text)
# Common backend function to get a random fun fact
def get_fun_fact(keywords=None):
"""
If keywords is None or empty, returns a random fun fact.
Otherwise, searches for the best matching fun fact in dictionary/funfacts.json.
For each fun fact:
- Awards 2 points for each keyword found as a whole word.
- Awards 1 point for each keyword found as a partial match.
- Subtracts 1 point for each keyword provided.
In the event of a tie, one fun fact is chosen at random.
"""
with open('dictionary/funfacts.json', 'r') as f:
facts = json.load(f)
# If no keywords provided, return a random fact.
if not keywords:
return random.choice(facts)
if len(keywords) < 2:
return "If you want to search, please append the command with `search [keywords]` without brackets."
keywords = keywords[1:]
lower_keywords = [kw.lower() for kw in keywords]
best_score = None
best_facts = []
for fact in facts:
score_total = 0
fact_lower = fact.lower()
# For each keyword, check for whole word and partial matches.
for kw in lower_keywords:
if re.search(r'\b' + re.escape(kw) + r'\b', fact_lower):
score_total += 2
elif kw in fact_lower:
score_total += 1
# Apply penalty for each keyword.
final_score = score_total - len(lower_keywords)
if best_score is None or final_score > best_score:
best_score = final_score
best_facts = [fact]
elif final_score == best_score:
best_facts.append(fact)
if not best_facts:
return "No matching fun facts found."
return random.choice(best_facts)

View File

@ -1,34 +0,0 @@
# cmd_discord.py
from discord.ext import commands
def setup(bot):
@bot.command()
async def greet(ctx):
from cmd_common.common_commands import greet
result = greet(ctx.author.display_name, "Discord")
await ctx.send(result)
@bot.command()
async def ping(ctx):
from cmd_common.common_commands import ping
result = ping()
await ctx.send(result)
@bot.command()
async def howl(ctx):
"""Calls the shared !howl logic."""
from cmd_common.common_commands import generate_howl_message
result = generate_howl_message(ctx.author.display_name)
await ctx.send(result)
@bot.command()
async def 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}")

19
cmd_discord/__init__.py Normal file
View File

@ -0,0 +1,19 @@
# cmd_discord/__init__.py
import os
import importlib
# def setup(bot):
# """
# Dynamically load all commands from the cmd_discord folder.
# """
# # Get a list of all command files (excluding __init__.py)
# command_files = [
# f.replace('.py', '') for f in os.listdir(os.path.dirname(__file__))
# if f.endswith('.py') and f != '__init__.py'
# ]
# # Import and set up each command module
# for command in command_files:
# module = importlib.import_module(f".{command}", package='cmd_discord')
# if hasattr(module, 'setup'):
# module.setup(bot)

595
cmd_discord/customvc.py Normal file
View File

@ -0,0 +1,595 @@
# cmd_discord/customvc.py
#
# TODO
# - Fix "allow" and "deny" subcommands not working (modifications not applied)
# - Fix "lock" subcommand not working (modifications not applied)
# - Add "video_bitrate" to subcommands list
# - Rename "bitrate" to "audio_bitrate"
# - Add automatic channel naming
# - Dynamic mode: displays the game currently (at any time) played by the owner, respecting conservative ratelimits
# - Static mode: names the channel according to pre-defined rules (used on creation if owner is not playing)
# - Add "autoname" to subcommands list
# - Sets the channel name to Dynamic Mode (see above)
# - Modify "settings" to display new information
import discord
from discord.ext import commands
import datetime
import globals
from modules.permissions import has_custom_vc_permission
class CustomVCCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.settings_data = globals.load_settings_file("discord_guilds_config.json")
guild_id = "896713616089309184" # Adjust based on your settings structure
self.LOBBY_VC_ID = self.settings_data[guild_id]["customvc_settings"]["lobby_vc_id"]
self.CUSTOM_VC_CATEGORY_ID = self.settings_data[guild_id]["customvc_settings"]["customvc_category_id"]
self.USER_COOLDOWN_MINUTES = self.settings_data[guild_id]["customvc_settings"]["vc_creation_user_cooldown"]
self.GLOBAL_CHANNELS_PER_MINUTE = self.settings_data[guild_id]["customvc_settings"]["vc_creation_global_per_min"]
self.DELETE_DELAY_SECONDS = self.settings_data[guild_id]["customvc_settings"]["empty_vc_autodelete_delay"]
self.MAX_CUSTOM_CHANNELS = self.settings_data[guild_id]["customvc_settings"]["customvc_max_limit"]
self.CUSTOM_VC_INFO = {}
self.USER_LAST_CREATED = {}
self.GLOBAL_CREATIONS = []
self.CHANNEL_COUNTER = 0
self.PENDING_DELETIONS = {}
@commands.Cog.listener()
async def on_ready(self):
"""Handles checking existing voice channels on bot startup."""
await self.scan_existing_custom_vcs()
@commands.Cog.listener()
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
"""Handles user movement between voice channels."""
await self.on_voice_update(member, before, after)
@commands.group(name="customvc", invoke_without_command=True)
async def customvc(self, ctx):
"""
Base !customvc command -> show help if no subcommand used.
"""
if ctx.invoked_subcommand is None:
msg = (
"**Custom VC Help**\n"
"Join the lobby to create a new voice channel automatically.\n"
"Subcommands:\n"
"- **name** <new_name> - rename\n"
" - `!customvc name my_custom_vc`\n"
"- **autoname** - enables automatic renaming based on game presence\n"
" - `!customvc autoname`\n"
"- **claim** - claim ownership\n"
" - `!customvc claim`\n"
"- **lock** - lock the channel\n"
" - `!customvc lock`\n"
"- **allow** <user> - allow user\n"
" - `!customvc allow some_user`\n"
"- **deny** <user> - deny user\n"
" - `!customvc deny some_user`\n"
"- **unlock** - unlock channel\n"
" - `!customvc unlock`\n"
"- **users** <count> - set user limit\n"
" - `!customvc users 2`\n"
"- **audio_bitrate** <kbps> - set audio bitrate\n"
" - `!customvc audio_bitrate 64`\n"
"- **video_bitrate** <kbps> - set video bitrate\n"
" - `!customvc video_bitrate 2500`\n"
"- ~~**status** <status_str> - set a custom status~~\n"
"- **op** <user> - co-owner\n"
" - `!customvc op some_user`\n"
"- **settings** - show channel settings\n"
" - `!customvc settings`\n"
)
await ctx.reply(msg)
else:
try:
await self.bot.invoke(ctx) # This will ensure subcommands get processed.
globals.log(f"{ctx.author.name} executed Custom VC subcommand '{ctx.invoked_subcommand}' in {ctx.channel.name}", "DEBUG")
except Exception as e:
globals.log(f"'customvc {ctx.invoked_subcommand}' failed to execute: {e}", "ERROR")
# Subcommands:
@customvc.command(name="name")
async def rename_channel(self, ctx, *, new_name: str):
"""Renames a custom VC."""
await self.rename_channel_logic(ctx, new_name)
@customvc.command(name="claim")
async def claim_channel(self, ctx):
"""Claims ownership of a VC if the owner is absent."""
await self.claim_channel_logic(ctx)
@customvc.command(name="lock")
async def lock_channel(self, ctx):
"""Locks a custom VC."""
await self.lock_channel_logic(ctx)
@customvc.command(name="allow")
async def allow_user(self, ctx, *, user: str):
"""Allows a user to join a VC."""
await self.allow_user_logic(ctx, user)
@customvc.command(name="deny")
async def deny_user(self, ctx, *, user: str):
"""Denies a user from joining a VC."""
await self.deny_user_logic(ctx, user)
@customvc.command(name="unlock")
async def unlock_channel(self, ctx):
"""Unlocks a custom VC."""
await self.unlock_channel_logic(ctx)
@customvc.command(name="settings")
async def show_settings(self, ctx):
"""Shows the settings of the current VC."""
await self.show_settings_logic(ctx)
@customvc.command(name="users")
async def set_users_limit(self, ctx, *, limit: int):
"""Assign a VC users limit"""
await self.set_user_limit_logic(ctx, limit)
@customvc.command(name="op")
async def op_user(self, ctx, *, user: str):
"""Make another user co-owner"""
await self.op_user_logic(ctx, user)
#
# Main voice update logic
#
async def on_voice_update(self, member, before, after):
if before.channel != after.channel:
# 1) If user just joined the lobby
if after.channel and after.channel.id == self.LOBBY_VC_ID:
if not await self.can_create_vc(member):
# Rate-limit => forcibly disconnect
await member.move_to(None)
# Also mention them in the ephemeral chat of LOBBY_VC_ID if we want (if possible)
lobby_chan = after.channel
if lobby_chan and hasattr(lobby_chan, "send"):
# We'll try to mention them
await lobby_chan.send(f"{member.mention}, youve exceeded custom VC creation limits. Try again later!")
return
# 2) Create a new custom VC
self.CHANNEL_COUNTER += 1
vc_name = f"VC {self.CHANNEL_COUNTER}"
if self.CHANNEL_COUNTER > self.MAX_CUSTOM_CHANNELS:
vc_name = f"Overflow {self.CHANNEL_COUNTER}"
category = member.guild.get_channel(self.CUSTOM_VC_CATEGORY_ID)
if not category or not isinstance(category, discord.CategoryChannel):
globals.log("Could not find a valid custom VC category.", "INFO")
return
new_vc = await category.create_voice_channel(name=vc_name)
await member.move_to(new_vc)
# Record memory
self.CUSTOM_VC_INFO[new_vc.id] = {
"owner_id": member.id,
"locked": False,
"allowed_ids": set(),
"denied_ids": set(),
"user_limit": None,
"bitrate": None,
}
now = datetime.datetime.utcnow()
self.USER_LAST_CREATED[member.id] = now
self.GLOBAL_CREATIONS.append(now)
# prune old
cutoff = now - datetime.timedelta(seconds=60)
self.GLOBAL_CREATIONS[:] = [t for t in self.GLOBAL_CREATIONS if t > cutoff]
# Announce in the new VC's ephemeral chat
if hasattr(new_vc, "send"):
await new_vc.send(
f"{member.name}, your custom voice channel is ready! "
"Type `!customvc` here for help with subcommands."
)
await self.update_channel_name(member, after.channel)
# 3) If user left a custom VC -> maybe its now empty
if before.channel and before.channel.id in self.CUSTOM_VC_INFO:
old_vc = before.channel
if len(old_vc.members) == 0:
self.schedule_deletion(old_vc)
# If user joined a custom VC that was pending deletion, cancel it
if after.channel and after.channel.id in self.CUSTOM_VC_INFO:
if after.channel.id in self.PENDING_DELETIONS:
self.PENDING_DELETIONS[after.channel.id].cancel()
del self.PENDING_DELETIONS[after.channel.id]
def schedule_deletion(self, vc: discord.VoiceChannel):
"""
Schedules this custom VC for deletion in 10s if it stays empty.
"""
import asyncio
async def delete_task():
await asyncio.sleep(self.DELETE_DELAY_SECONDS)
if len(vc.members) == 0:
self.CUSTOM_VC_INFO.pop(vc.id, None)
try:
await vc.delete()
except:
pass
self.PENDING_DELETIONS.pop(vc.id, None)
loop = vc.guild._state.loop
task = loop.create_task(delete_task())
self.PENDING_DELETIONS[vc.id] = task
async def can_create_vc(self, member: discord.Member) -> bool:
"""
Return False if user or global rate-limits are hit:
- user can only create 1 channel every 5 minutes
- globally only 2 channels per minute
"""
now = datetime.datetime.utcnow()
last_time = self.USER_LAST_CREATED.get(member.id)
if last_time:
diff = (now - last_time).total_seconds()
if diff < (self.USER_COOLDOWN_MINUTES * 60):
return False
# global limit
cutoff = now - datetime.timedelta(seconds=60)
recent = [t for t in self.GLOBAL_CREATIONS if t > cutoff]
if len(recent) >= self.GLOBAL_CHANNELS_PER_MINUTE:
return False
return True
async def scan_existing_custom_vcs(self):
"""
On startup: check the custom VC category for leftover channels:
- If channel.id == LOBBY_VC_ID -> skip (don't delete the main lobby).
- If empty, delete immediately.
- If non-empty, first occupant is new owner. Mention them in ephemeral chat if possible.
"""
for g in self.bot.guilds:
cat = g.get_channel(self.CUSTOM_VC_CATEGORY_ID)
if not cat or not isinstance(cat, discord.CategoryChannel):
continue
for ch in cat.voice_channels:
# skip the LOBBY
if ch.id == self.LOBBY_VC_ID:
continue
if len(ch.members) == 0:
# safe to delete
try:
await ch.delete()
except:
pass
globals.log(f"Deleted empty leftover channel: {ch.name}", "INFO")
else:
# pick first occupant
first = ch.members[0]
self.CHANNEL_COUNTER += 1
self.CUSTOM_VC_INFO[ch.id] = {
"owner_id": first.id,
"locked": False,
"allowed_ids": set(),
"denied_ids": set(),
"user_limit": None,
"bitrate": None,
}
globals.log(f"Assigned {first.display_name} as owner of leftover VC: {ch.name}", "INFO")
if hasattr(ch, "send"):
try:
await ch.send(
f"{first.mention}, you're now the owner of leftover channel **{ch.name}** after a restart. "
"Type `!customvc` here to see available commands."
)
except:
pass
#
# The subcommand logic below, referencing CUSTOM_VC_INFO
#
def get_custom_vc_for(self, ctx: commands.Context) -> discord.VoiceChannel:
"""
Return the custom voice channel the command user is currently in (or None).
We'll look at ctx.author.voice.
"""
if not isinstance(ctx.author, discord.Member):
return None
vs = ctx.author.voice
if not vs or not vs.channel:
return None
vc = vs.channel
if vc.id not in self.CUSTOM_VC_INFO:
return None
return vc
def is_initiator_or_mod(self, ctx: commands.Context, vc_id: int) -> bool:
info = self.CUSTOM_VC_INFO.get(vc_id)
if not info:
return False
return has_custom_vc_permission(ctx.author, info["owner_id"])
async def rename_channel_logic(self, ctx: commands.Context, new_name: str):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("You are not in a custom voice channel.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to rename.")
await vc.edit(name=new_name, reason=f'{ctx.author.name} renamed the channel.')
await ctx.send(f"Renamed channel to **{new_name}**.")
@customvc.command(name="autoname")
async def enable_autoname(self, ctx: commands.Context):
"""Enables automatic channel naming based on the owner's game."""
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to enable auto-naming.")
info = self.CUSTOM_VC_INFO[vc.id]
info["autoname"] = True
await self.update_channel_name(ctx.author, vc)
await ctx.send("Auto-naming enabled! Your channel will update based on the game you're playing.")
async def claim_channel_logic(self, ctx: commands.Context):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
info = self.CUSTOM_VC_INFO.get(vc.id)
if not info:
return await ctx.send("No memory for this channel?")
old = info["owner_id"]
# if author is owner
if old == ctx.author:
return await ctx.send("You're already the owner of this Custom VC!")
# if old owner is still inside
if any(m.id == old for m in vc.members):
return await ctx.send(f"The current owner, {old.name}, is still here!.")
info["owner_id"] = ctx.author.id
await ctx.send("You are now the owner of this channel!")
async def lock_channel_logic(self, ctx: commands.Context):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("You're not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to lock.")
info = self.CUSTOM_VC_INFO[vc.id]
info["locked"] = True
# Use set_permissions() instead of modifying overwrites directly
await vc.set_permissions(ctx.guild.default_role, connect=False, reason=f"{ctx.author.name} locked the Custom VC")
await ctx.send("Channel locked. Only allowed users can join.")
async def allow_user_logic(self, ctx: commands.Context, user: str):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to allow a user.")
mem = await self.resolve_member(ctx, user)
if not mem:
return await ctx.send(f"Could not find user: {user}.")
info = self.CUSTOM_VC_INFO[vc.id]
info["allowed_ids"].add(mem.id)
# Set explicit permissions instead of overwriting
await vc.set_permissions(mem, connect=True, reason=f"{ctx.author.name} allowed {mem.display_name} into their Custom VC")
await ctx.send(f"Allowed **{mem.display_name}** to join.")
async def deny_user_logic(self, ctx: commands.Context, user: str):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to deny user.")
mem = await self.resolve_member(ctx, user)
if not mem:
return await ctx.send(f"Could not find user: {user}.")
info = self.CUSTOM_VC_INFO[vc.id]
if has_custom_vc_permission(mem, info["owner_id"]):
return await ctx.send("Cannot deny a moderator or the owner.")
info["denied_ids"].add(mem.id)
# Explicitly deny permission
await vc.set_permissions(mem, connect=False, reason=f"{ctx.author.name} denied {mem.display_name} from their Custom VC")
await ctx.send(f"Denied **{mem.display_name}** from connecting.")
async def unlock_channel_logic(self, ctx: commands.Context):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to unlock.")
# Fetch category and its permissions
category = vc.category
if not category:
return await ctx.send("Error: Could not determine category permissions.")
# Copy category permissions
overwrites = category.overwrites.copy()
# Update stored channel info
info = self.CUSTOM_VC_INFO[vc.id]
info["locked"] = False
await vc.edit(overwrites=overwrites, reason=f'{ctx.author.name} unlocked their Custom VC')
await ctx.send("Unlocked the channel. Permissions now match the category default.")
async def set_user_limit_logic(self, ctx: commands.Context, limit: int):
MIN = 2
MAX = 99
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if limit < MIN:
return await ctx.send(f"Minimum limit is {MIN}.")
if limit > MAX:
return await ctx.send(f"Maximum limit is {MAX}.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to set user limit.")
info = self.CUSTOM_VC_INFO[vc.id]
info["user_limit"] = limit
await vc.edit(user_limit=limit, reason=f'{ctx.author.name} changed users limit to {limit} in their Custom VC')
await ctx.send(f"User limit set to {limit}.")
@customvc.command(name="audio_bitrate")
async def set_audio_bitrate(self, ctx: commands.Context, kbps: int):
"""Sets the audio bitrate for a VC."""
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to set audio bitrate.")
if kbps < 8 or kbps > 128:
return await ctx.send(f"Invalid audio bitrate! Must be between 8 and 128 kbps.")
await vc.edit(bitrate=kbps * 1000, reason=f"{ctx.author.name} changed audio bitrate")
await ctx.send(f"Audio bitrate set to {kbps} kbps.")
@customvc.command(name="video_bitrate")
async def set_video_bitrate(self, ctx: commands.Context, kbps: int):
"""Sets the video bitrate for a VC."""
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to set video bitrate.")
if kbps < 100 or kbps > 8000:
return await ctx.send(f"Invalid video bitrate! Must be between 100 and 8000 kbps.")
await vc.edit(video_quality_mode=discord.VideoQualityMode.full, bitrate=kbps * 1000, reason=f"{ctx.author.name} changed video bitrate")
await ctx.send(f"Video bitrate set to {kbps} kbps.")
async def set_status_logic(self, ctx: commands.Context, status_str: str):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to set channel status.")
info = self.CUSTOM_VC_INFO[vc.id]
info["status"] = status_str
await ctx.send(f"Channel status set to: **{status_str}** (placeholder).")
async def update_channel_name(self, member: discord.Member, vc: discord.VoiceChannel):
"""Dynamically renames VC based on the user's game or predefined rules."""
if not vc or not member:
return
game_name = None
if member.activities:
for activity in member.activities:
if isinstance(activity, discord.Game):
game_name = activity.name
break
if game_name:
new_name = f"{game_name}"
else:
new_name = f"{member.display_name}'s Channel"
await vc.edit(name=new_name, reason="Automatic VC Naming")
async def op_user_logic(self, ctx: commands.Context, user: str):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to op someone.")
mem = await self.resolve_member(ctx, user)
if not mem:
return await ctx.send(f"Could not find user: {user}.")
info = self.CUSTOM_VC_INFO[vc.id]
if "ops" not in info:
info["ops"] = set()
info["ops"].add(mem.id)
await ctx.send(f"Granted co-ownership to {mem.display_name}.")
async def show_settings_logic(self, ctx: commands.Context):
vc = self.get_custom_vc_for(ctx)
if not vc:
return await ctx.send("Not in a custom VC.")
info = self.CUSTOM_VC_INFO.get(vc.id)
if not info:
return await ctx.send("No data found for this channel?")
locked_str = "Yes" if info["locked"] else "No"
user_lim = info["user_limit"] if info["user_limit"] else "Default"
bitrate_str = info["bitrate"] if info["bitrate"] else "Default (64 kbps)"
status_str = info.get("status", "None")
owner_id = info["owner_id"]
owner = ctx.guild.get_member(owner_id)
owner_str = owner.display_name if owner else f"<ID {owner_id}>"
allowed_str = ", ".join(map(str, info["allowed_ids"])) if info["allowed_ids"] else "None specified"
denied_str = ", ".join(map(str, info["denied_ids"])) if info["denied_ids"] else "None specified"
msg = (
f"**Channel Settings**\n"
f"Owner: {owner_str}\n"
f"Locked: {locked_str}\n"
f"User Limit: {user_lim}\n"
f"Bitrate: {bitrate_str}\n"
f"Status: {status_str}\n"
f"Allowed: {allowed_str}\n"
f"Denied: {denied_str}\n"
)
await ctx.send(msg)
async def resolve_member(ctx: commands.Context, user_str: str) -> discord.Member:
"""
Attempt to parse user mention/ID/partial name.
"""
user_str = user_str.strip()
if user_str.startswith("<@") and user_str.endswith(">"):
uid = user_str.strip("<@!>")
if uid.isdigit():
return ctx.guild.get_member(int(uid))
elif user_str.isdigit():
return ctx.guild.get_member(int(user_str))
lower = user_str.lower()
for m in ctx.guild.members:
if m.name.lower() == lower or m.display_name.lower() == lower:
return m
return None
async def setup(bot):
await bot.add_cog(CustomVCCog(bot))

23
cmd_discord/funfact.py Normal file
View File

@ -0,0 +1,23 @@
# cmd_discord/funfact.py
import discord
from discord.ext import commands
import cmd_common.common_commands as cc
class FunfactCog(commands.Cog):
"""Cog for the '!funfact' command."""
def __init__(self, bot):
self.bot = bot
@commands.command(name='funfact', aliases=['fun-fact'])
async def funfact_command(self, ctx, *keywords):
"""
Fetches a fun fact based on provided keywords and replies to the user.
"""
fact = cc.get_fun_fact(list(keywords))
await ctx.reply(fact)
# Required for loading the cog dynamically
async def setup(bot):
await bot.add_cog(FunfactCog(bot))

30
cmd_discord/help.py Normal file
View File

@ -0,0 +1,30 @@
# cmd_discord/help.py
import discord
from discord.ext import commands
from discord import app_commands
from typing import Optional
from modules.utility import handle_help_command
import globals
class HelpCog(commands.Cog):
"""Handles the !help and /help commands."""
def __init__(self, bot):
self.bot = bot
self.primary_guild = globals.constants.primary_discord_guild()
@commands.command(name="help")
async def cmd_help_text(self, ctx, *, command: str = ""):
"""Handles text-based help command."""
result = await handle_help_command(ctx, command, self.bot, is_discord=True)
await ctx.reply(result)
@app_commands.command(name="help", description="Get information about commands")
async def cmd_help_slash(self, interaction: discord.Interaction, command: Optional[str] = ""):
"""Handles slash command for help."""
result = await handle_help_command(interaction, command, self.bot, is_discord=True)
await interaction.response.send_message(result)
async def setup(bot):
await bot.add_cog(HelpCog(bot))

20
cmd_discord/howl.py Normal file
View File

@ -0,0 +1,20 @@
# cmd_discord/howl.py
import discord
from discord.ext import commands
import cmd_common.common_commands as cc
class HowlCog(commands.Cog):
"""Cog for the '!howl' command."""
def __init__(self, bot):
self.bot = bot
@commands.command(name="howl")
async def cmd_howl_text(self, ctx):
"""Handles the '!howl' command."""
result = cc.handle_howl_command(ctx)
await ctx.reply(result)
async def setup(bot):
await bot.add_cog(HowlCog(bot))

22
cmd_discord/ping.py Normal file
View File

@ -0,0 +1,22 @@
# cmd_discord/ping.py
import discord
from discord.ext import commands
import cmd_common.common_commands as cc
class PingCog(commands.Cog):
"""Handles the '!ping' command."""
def __init__(self, bot):
self.bot = bot
@commands.command(name="ping")
async def cmd_ping_text(self, ctx):
"""Checks bot's uptime and latency."""
result = cc.ping()
latency = round(self.bot.latency * 1000)
result += f" (*latency: {latency}ms*)"
await ctx.reply(result)
async def setup(bot):
await bot.add_cog(PingCog(bot))

39
cmd_discord/quote.py Normal file
View File

@ -0,0 +1,39 @@
# cmd_discord/quote.py
import discord
from discord.ext import commands
import globals
import cmd_common.common_commands as cc
class QuoteCog(commands.Cog):
"""Handles the '!quote' command."""
def __init__(self, bot):
self.bot = bot
@commands.command(name="quote")
async def cmd_quote_text(self, ctx, *, arg_str: str = ""):
"""Handles various quote-related subcommands."""
if not globals.init_db_conn:
await ctx.reply("Database is unavailable, sorry.")
return
args = arg_str.split() if arg_str else []
globals.log(f"'quote' command initiated with arguments: {args}", "DEBUG")
result = await cc.handle_quote_command(
db_conn=globals.init_db_conn,
is_discord=True,
ctx=ctx,
args=args,
game_name=None
)
globals.log(f"'quote' result: {result}", "DEBUG")
if hasattr(result, "to_dict"):
await ctx.reply(embed=result)
else:
await ctx.reply(result)
async def setup(bot):
await bot.add_cog(QuoteCog(bot))

View File

@ -1,21 +0,0 @@
# cmd_twitch.py
from twitchio.ext import commands
def setup(bot):
@bot.command(name="greet")
async def greet(ctx):
from cmd_common.common_commands import greet
result = greet(ctx.author.display_name, "Twitch")
await ctx.send(result)
@bot.command(name="ping")
async def ping(ctx):
from cmd_common.common_commands import ping
result = ping()
await ctx.send(result)
@bot.command(name="howl")
async def howl_command(ctx):
from cmd_common.common_commands import generate_howl_message
result = generate_howl_message(ctx.author.display_name)
await ctx.send(result)

19
cmd_twitch/__init__.py Normal file
View File

@ -0,0 +1,19 @@
# cmd_twitch/__init__.py
import os
import importlib
def setup(bot):
"""
Dynamically load all commands from the cmd_twitch folder.
"""
# Get a list of all command files (excluding __init__.py)
command_files = [
f.replace('.py', '') for f in os.listdir(os.path.dirname(__file__))
if f.endswith('.py') and f != '__init__.py'
]
# Import and set up each command module
for command in command_files:
module = importlib.import_module(f".{command}", package='cmd_twitch')
if hasattr(module, 'setup'):
module.setup(bot)

19
cmd_twitch/funfact.py Normal file
View File

@ -0,0 +1,19 @@
# cmd_twitch/funfact.py
from twitchio.ext import commands
from cmd_common import common_commands as cc
def setup(bot):
"""
Registers the '!funfact' command for Twitch.
"""
@bot.command(name='funfact', aliases=['fun-fact'])
async def cmd_funfact(ctx: commands.Context, *keywords: str):
"""
Handle the '!funfact' command.
Usage:
- !funfact <keywords>
-> Displays a fun fact related to the given keywords.
"""
fact = cc.get_fun_fact(list(keywords))
await ctx.reply(fact)

19
cmd_twitch/getgame.py Normal file
View File

@ -0,0 +1,19 @@
# cmd_twitch/getgame.py
from twitchio.ext import commands
from modules.utility import get_current_twitch_game
def setup(bot):
"""
Registers the '!getgame' command for Twitch.
"""
@bot.command(name='getgame')
async def cmd_getgame(ctx: commands.Context):
"""
Retrieves the current game being played on Twitch.
Usage:
- !getgame -> Shows the current game for the channel.
"""
channel_name = ctx.channel.name
game_name = await get_current_twitch_game(bot, channel_name)
await ctx.reply(game_name)

21
cmd_twitch/help.py Normal file
View File

@ -0,0 +1,21 @@
# cmd_twitch/help.py
from twitchio.ext import commands
from modules.utility import handle_help_command, is_channel_live
def setup(bot):
"""
Registers the '!help' command for Twitch.
"""
@bot.command(name="help")
async def cmd_help(ctx):
"""
Displays help information for available commands.
Usage:
- !help -> Lists all commands.
- !help <command> -> Detailed help for a specific command.
"""
if not await is_channel_live(bot):
parts = ctx.message.content.strip().split()
cmd_name = parts[1] if len(parts) > 1 else None
await handle_help_command(ctx, cmd_name, bot, is_discord=False)

21
cmd_twitch/howl.py Normal file
View File

@ -0,0 +1,21 @@
# cmd_twitch/howl.py
from twitchio.ext import commands
from cmd_common import common_commands as cc
from modules.utility import is_channel_live
def setup(bot):
"""
Registers the '!howl' command for Twitch.
"""
@bot.command(name="howl")
async def cmd_howl(ctx: commands.Context):
"""
Handle the '!howl' command.
Usage:
- !howl -> Attempts a howl.
- !howl stat <user> -> Looks up howling stats for a user.
"""
if not await is_channel_live(bot):
response = cc.handle_howl_command(ctx)
await ctx.reply(response)

18
cmd_twitch/ping.py Normal file
View File

@ -0,0 +1,18 @@
# cmd_twitch/ping.py
from twitchio.ext import commands
from cmd_common import common_commands as cc
def setup(bot):
"""
Registers the '!ping' command for Twitch.
"""
@bot.command(name="ping")
async def cmd_ping(ctx: commands.Context):
"""
Checks the bot's uptime and latency.
Usage:
- !ping -> Returns the bot's uptime and latency.
"""
result = cc.ping()
await ctx.reply(result)

46
cmd_twitch/quote.py Normal file
View File

@ -0,0 +1,46 @@
# cmd_twitch/quote.py
from twitchio.ext import commands
from cmd_common import common_commands as cc
from modules.utility import is_channel_live
import globals
def setup(bot):
"""
Registers the '!quote' command for Twitch.
"""
@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 restore <number>
-> Restores a previously removed quote.
- !quote info <number>
-> Displays stored information about the quote.
- !quote search [keywords]
-> Searches for the best matching quote.
- !quote last/latest/newest
-> Retrieves the latest (most recent) non-removed quote.
"""
if not await is_channel_live(bot):
if not globals.init_db_conn:
await ctx.reply("Database is unavailable, sorry.")
return
args = ctx.message.content.strip().split()[1:]
result = await cc.handle_quote_command(
db_conn=globals.init_db_conn,
is_discord=False,
ctx=ctx,
args=args
)
await ctx.reply(result)

View File

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

537
dictionary/funfacts.json Normal file
View File

@ -0,0 +1,537 @@
[
"Bananas are berries, but strawberries are not.",
"Honey never spoils; archaeologists have found edible honey in ancient Egyptian tombs.",
"A day on Venus is longer than a year on Venus.",
"Octopuses have three hearts and blue blood.",
"There are more trees on Earth than stars in the Milky Way.",
"Some metals, like gallium, melt in your hand.",
"Wombat poop is cube-shaped.",
"The Eiffel Tower can be 15 cm taller during the summer.",
"A cloud can weigh more than 500.000kg (1 million pounds).",
"There's a species of jellyfish that is immortal.",
"Cows have best friends and get stressed when separated.",
"The heart of a blue whale is as big as a car.",
"A group of flamingos is called a 'flamboyance'.",
"The inventor of the frisbee was turned into a frisbee after he died.",
"A single strand of spaghetti is called a 'spaghetto'.",
"You can hear a blue whale's heartbeat from more than 3.2 km (2 miles) away.",
"Rats laugh when tickled.",
"Peanuts aren't nuts; they're legumes.",
"An adult human has fewer bones than a baby.",
"There's a basketball court on the top floor of the U.S. Supreme Court building.",
"A snail can sleep for three years.",
"There are more fake flamingos in the world than real ones.",
"Mosquitoes are attracted to people who just ate bananas.",
"Some turtles can breathe through their butts.",
"Cheetahs can't roar; they can only meow.",
"The inventor, Percy Spencer, of the microwave only received $2 for his discovery.",
"The majority of your brain is fat.",
"Hot water can freeze faster than cold water under certain conditions.",
"The unicorn is the national animal of Scotland.",
"Butterflies taste with their feet.",
"The first oranges weren't orange; they were green.",
"A bolt of lightning contains enough energy to toast 100,000 slices of bread.",
"A cloud can travel at speeds up to 97 km/h (60 mph).",
"Some cats are allergic to humans.",
"There are over 200 corpses of climbers on Mount Everest.",
"The oldest 'your mom' joke was discovered on a 3,500-year-old Babylonian tablet.",
"An apple, potato, and onion all taste the same if you eat them with your nose plugged.",
"A small child could swim through the veins of a blue whale.",
"A company in Taiwan makes dinnerware out of wheat, so you can eat your plate.",
"The human stomach gets a new lining every three to four days.",
"Some lipsticks contain fish scales.",
"There's a town in Norway called 'Hell' that freezes over every winter.",
"Almonds are a member of the peach family.",
"The longest place name in the world is 85 letters long.",
"The scientific name for brain freeze is 'sphenopalatine ganglioneuralgia'.",
"There's a museum in Sweden dedicated entirely to failures.",
"The first computer virus was created in 1983 as an experiment.",
"The shortest war in history was between Britain and Zanzibar on August 27, 1896, lasting just 38 to 45 minutes.",
"The inventor of the Pringles can, Fred Baur, is now buried in one.",
"A bolt of lightning can heat the air to five times hotter than the sun's surface.",
"Koala fingerprints are so similar to human fingerprints that they have been mistaken at crime scenes.",
"Sloths can hold their breath longer than dolphins.",
"The longest place name in the United States is 'Chargoggagoggmanchauggagoggchaubunagungamaugg'. It's commonly known as 'Webster Lake' in Webster, Massachusetts.",
"There's a species of fungus that turns ants into 'zombies'.",
"The human nose can detect over 1 trillion different scents.",
"A group of porcupines is called a 'prickle'.",
"Sea otters hold hands while sleeping so they don't drift apart.",
"The world's oldest toy is a stick.",
"The inventor of the modern zipper, Whitcomb Judson, initially failed to catch on with his invention.",
"A snail can have up to 25,000 teeth.",
"Polar bear skin is actually black.",
"The longest recorded flight of a chicken is 13 seconds.",
"The Twitter bird has a name: Larry.",
"The original London Bridge is now in Arizona.",
"A group of crows is called a 'murder'.",
"The average person walks the equivalent of five times around the world in their lifetime.",
"A 'jiffy' is an actual unit of time equal to 1/100th of a second.",
"The heart of a shrimp is located in its head.",
"In Switzerland, it is illegal to own just one guinea pig.",
"The mantis shrimp has the world's fastest punch, traveling at about 80 km/h (50 mph).",
"Bubble wrap was originally invented as wallpaper.",
"The first alarm clock could only ring at 4 a.m.",
"A hummingbird's heart can beat up to 1,260 times per minute.",
"There are more possible iterations of a game of chess than atoms in the known universe.",
"Some frogs can freeze without dying.",
"The plastic tips on shoelaces are called 'aglets'.",
"The word 'nerd' was first coined by Dr. Seuss in 'If I Ran the Zoo'.",
"A single strand of human hair can hold up to 100 grams.",
"A crocodile can't stick its tongue out.",
"The world's smallest reptile was discovered in 2021 and is just over 1.3 cm (0.5 inches) long.",
"Most of the dust in your home is made from dead skin cells.",
"Bananas glow blue under black lights.",
"A lightning strike can produce ozone, which is why the air smells fresh after a storm.",
"A day on Mercury lasts longer than its year.",
"There are more plastic flamingos in the world than real ones.",
"The world's deepest postbox is in Susami Bay, Japan, and is 10 meters underwater.",
"The longest wedding veil was longer than 63 football/soccer fields.",
"Humans share 50% of their DNA with bananas.",
"Olympic gold medals are mostly made of silver.",
"The tongue of a blue whale can weigh as much as an elephant.",
"Koalas sleep up to 22 hours a day.",
"The world's first website is still online.",
"The inventor of the lightbulb, Thomas Edison, once said he was afraid of the dark.",
"A cloud can hold up to a billion kilograms of water.",
"The record for the longest hiccuping spree is 68 years.",
"It takes glass over a million years to decompose.",
"The first VCR, created in 1956, was roughly the size of a piano.",
"Some turtles can live over 150 years.",
"The world's largest snowflake on record was 38cm (15 inches) wide.",
"An ostrich's eye is bigger than its brain.",
"The smell of freshly cut grass is actually a plant distress call.",
"Bees sometimes sting other bees.",
"The world's largest pizza was made in Rome and measured over 1,261 square meters.",
"The oldest piece of chewing gum is over 9,000 years old.",
"The word 'set' has the highest number of definitions in the English language.",
"Some frogs can freeze solid and then thaw out and hop away without harm.",
"The average human body has enough iron to make a small nail.",
"The planet Saturn could float in water because it is mostly made of gas.",
"A bee can fly around 10 km/h (6 mph).",
"The world's smallest mammal is the bumblebee bat.",
"Cows produce around 189,000 liters (200,000 quarts) of milk in their lifetime.",
"A piece of paper can typically be folded only 7 times.",
"The original name for a butterfly was 'flutterby'.",
"The longest word that can be typed using only the top row of a QWERTY keyboard is 'typewriter'.",
"An octopus can taste with its arms.",
"The inventor of the telephone, Alexander Graham Bell, never called his mother-in-law.",
"A giraffe can clean its ears with its 53 cm (21 inch) long tongue.",
"The lifespan of a single taste bud is about 10 days.",
"Humans can distinguish over a trillion different colors.",
"A single bolt of lightning contains enough energy to power a 100-watt light bulb for over 3 months.",
"The human nose can remember 50,000 different scents.",
"The world's fastest growing plant is the giant kelp.",
"Some cats have fewer toes on their back paws.",
"The world's oldest known wild bird is over 70 years old.",
"The unicorn is mentioned in the Bible as a symbol of strength.",
"The average person produces about 21.000 liters (22,000 quarts) of saliva in a lifetime.",
"The shortest commercial flight in the world lasts just 57 seconds.",
"There is a species of spider that mimics ants in appearance and behavior.",
"The world's largest grand piano was built by a 15-year-old in New Zealand.",
"In Japan, square watermelons are grown for easier stacking.",
"The first email was sent by Ray Tomlinson in 1971.",
"Lightning can strike the same place twice, and sometimes it does.",
"A day on Mercury lasts approximately 59 Earth days.",
"A snail can regenerate its eye stalks if they are damaged.",
"The longest continuous flight of a commercial airliner is over 18 hours.",
"Ants never sleep and they don't even have lungs.",
"The electric eel isn't really an eel, it's a knifefish.",
"The world's smallest book measures just 0.74 mm by 0.75 mm.",
"Some species of bamboo can grow over 89cm (35 inches) in a single day.",
"The giant squid has the largest eyes in the animal kingdom, measuring up to 25cm (10 inches).",
"Jellyfish are 95% water.",
"There are more bacteria in your mouth than there are people on Earth.",
"The world's largest desert isn't the Sahara, it's Antarctica.",
"Cleopatra lived closer in time to the Moon landing than to the construction of the Great Pyramid.",
"The tallest mountain in our solar system is Olympus Mons on Mars, nearly three times the height of Mount Everest.",
"Some fish can change their gender during their lifetime.",
"The combined weight of all ants on Earth is roughly equal to the combined weight of humans.",
"There is a museum dedicated solely to bad art in Massachusetts.",
"The original name of Google was 'Backrub'.",
"Wearing headphones for just an hour could increase the bacteria in your ear by 700 times.",
"Peacock feathers aren't actually colored, they're reflective structures that create iridescence.",
"The human brain uses 20% of the body's energy, even though it makes up only about 2% of the body's weight.",
"A group of ferrets is called a 'business'.",
"The first video ever uploaded on YouTube was 'Me at the zoo.'",
"If you shuffle a deck of cards, chances are that exact order has never been seen before in the history of the universe.",
"Walt Disney was cryogenically frozen, according to urban legend, though he was actually cremated.",
"The human heart creates enough pressure to squirt blood up to 30 feet.",
"Some plants can 'talk' to each other through underground fungal networks.",
"The longest English word without a vowel is 'rhythms'.",
"You can start a fire with ice by shaping it into a lens.",
"There are more stars in the universe than grains of sand on all the beaches on Earth.",
"Polar bears have black skin underneath their fur.",
"A hummingbird can fly backwards.",
"Kangaroos cannot walk backwards.",
"Sea cucumbers fight off predators by ejecting their internal organs.",
"A lightning bolt can travel at speeds up to 220,000 kilometers per hour.",
"The record for the longest time without sleep is 11 days.",
"A snail's trail is made of 98% water.",
"The world's largest flower, Rafflesia arnoldii, can reach up to 3 feet in diameter.",
"The oldest known animal was a quahog clam that lived for 507 years.",
"Caterpillars have more muscles than humans do.",
"The microwave was invented when a researcher's chocolate bar melted in his pocket near a radar tube.",
"There's a species of bat that emits sounds resembling a lion's roar.",
"The sound of a whip cracking is actually a mini sonic boom.",
"Some lizards can detach their tails to escape predators, and then grow them back.",
"Vending machines are more lethal than sharks, causing more deaths per year.",
"An adult human has about 100,000 hairs on their head.",
"The total length of blood vessels in the human body could circle the Earth 2.5 times.",
"Turtles have been around for over 200 million years, surviving the dinosaurs.",
"In ancient Rome, urine was commonly used as a cleaning agent for clothes.",
"One piece of space junk falls back to Earth every day.",
"The average person spends about 6 months of their lifetime waiting for red lights to turn green.",
"Cows have panoramic vision.",
"The plastic in credit cards can take up to 1,000 years to decompose.",
"In 2006, a man in Germany tried to trade a napkin for a car.",
"A lightning bolt can contain up to one billion volts of electricity.",
"The modern flush toilet was popularized by Thomas Crapper.",
"An average human produces enough saliva in a lifetime to fill two swimming pools.",
"The term 'piano' comes from the Italian 'pianoforte', meaning 'soft-loud'.",
"The Moon experiences moonquakes, similar to earthquakes on Earth.",
"A lightning bolt can be seen from over 150km (100 miles) away.",
"The world's largest hot dog was 203 feet long.",
"A cat has 32 muscles in each ear.",
"A hummingbird weighs less than a penny.",
"The average person will spend six months of their life in the bathroom.",
"The Bible is the most shoplifted book in the world.",
"Rabbits cannot vomit.",
"Sharks have existed longer than trees.",
"A hummingbird's egg weighs less than a dime.",
"It rains diamonds on Jupiter and Saturn.",
"The inventor of the chocolate chip cookie, Ruth Wakefield, sold the recipe for a dollar.",
"A lightning bolt can reach temperatures up to 30,000 Kelvin.",
"The average American eats around 18 acres of pizza in their lifetime.",
"The first product to have a barcode was Wrigley's gum.",
"In space, astronauts cannot cry because there's no gravity for tears to flow.",
"The Eiffel Tower was originally intended to be dismantled after 20 years.",
"An adult elephant's trunk contains over 40,000 muscles.",
"A day on Mars is approximately 24 hours and 39 minutes long.",
"The scent of rain, known as petrichor, is partly caused by bacteria called actinomycetes.",
"More people are allergic to cow's milk than to any other food.",
"Some species of ants form supercolonies spanning almost 6km (around 3.700 miles).",
"The cheetah, the fastest land animal, can accelerate from 0 to 60 mph in just 3 seconds.",
"Dolphins have been known to use tools, like sponges, to protect their snouts while foraging.",
"Despite popular belief, the Great Wall of China isn't visible from space with the naked eye.",
"In ancient Greece, throwing an apple at someone was considered a declaration of love.",
"There are more public libraries in the U.S. than there are McDonald's restaurants.",
"Bananas are naturally radioactive due to their high potassium content.",
"Shakespeare invented over 1,700 words that are now common in English.",
"A leap year occurs every 4 years, except in years divisible by 100 but not by 400.",
"In space there is no up or down; astronauts orient themselves relative to their spacecraft.",
"The world's largest recorded earthquake was a magnitude 9.5 in Chile in 1960.",
"The first known contraceptive was crocodile dung, used by ancient Egyptians.",
"M&M's stands for 'Mars & Murrie', the founders' last names.",
"The human body has enough carbon to fill 9,000 pencils.",
"The world's oldest pair of pants is over 3,000 years old.",
"Some birds, like the European robin, use Earth's magnetic field to navigate.",
"A newborn giraffe can stand within an hour of being born.",
"The shortest street in the world is Ebenezer Place in Scotland, measuring just 2.06 meters.",
"The Great Pyramid of Giza was the tallest man-made structure for over 3,800 years.",
"A single teaspoon of honey represents the life work of 12 bees.",
"The world's first roller coaster was built in Russia in the 17th century.",
"In Switzerland, it's illegal to mow your lawn on a Sunday.",
"The largest living structure on Earth is the Great Barrier Reef, visible from space.",
"The human brain can store up to 2.5 petabytes of information.",
"There's a fish called the 'Sarcastic Fringehead' known for its aggressive, gaping display.",
"A polar bear's fur is actually transparent, not white.",
"Some adult dragonflies live for as little as 24 hours.",
"There's a spot in the Pacific Ocean called Point Nemo, the furthest from any land.",
"Butterflies undergo a 4-stage life cycle: egg, larva, pupa, and adult.",
"A 'moment' in medieval times was defined as 90 seconds.",
"Sharks can detect a drop of blood in roughly 95 liters (25 gallons) of water.",
"A human sneeze can travel at speeds up to 161 km/h (100 mph).",
"Dolphins sleep with one eye open.",
"In Finland, speeding fines are calculated based on the offender's income.",
"Giraffes need only 5 to 30 minutes of sleep in a 24-hour period.",
"Some plants can survive for decades without sunlight by entering dormancy.",
"A full NASA spacesuit costs about $12 million.",
"Elephants are one of the few animals that can't jump.",
"The Sun makes up 99.86% of the mass in our solar system.",
"Starfish can regenerate lost arms, and sometimes a whole new starfish can grow from a single arm.",
"Bats are the only mammals capable of sustained flight.",
"The Great Wall of China spans around 20,921 km (over 13,000 miles).",
"The Greenland shark can live for over 400 years.",
"The color orange was named after the fruit, not the other way around.",
"A group of ravens is called an 'unkindness'.",
"In 1969, two computers at MIT were connected in a network that helped form the early Internet.",
"Some insects can survive in the vacuum of space for short periods.",
"Walt Disney's first Mickey Mouse cartoon was 'Steamboat Willie.'",
"A single lightning bolt can contain enough energy to power a city for a day.",
"The most expensive pizza in the world costs over $12,000 and takes 72 hours to prepare.",
"Venus rotates in the opposite direction to most planets in our solar system.",
"Cacti have evolved to store water in their tissues, allowing them to survive in deserts.",
"The first written recipe dates back over 4,000 years in ancient Mesopotamia.",
"The human body contains enough fat to produce over seven bars of soap.",
"The term 'robot' comes from the Czech word 'robota', meaning forced labor.",
"A lightning bolt can carry up to 100 million joules of energy.",
"The smallest country in the world is Vatican City.",
"In 1980, a Las Vegas hospital suspended workers for betting on when patients would die.",
"You can't hum while holding your nose.",
"Mice can sing, but their songs are too high-pitched for human ears.",
"There's an island in the Bahamas inhabited by swimming pigs.",
"Some metals, like sodium and potassium, react explosively with water.",
"The Guinness World Record for the most T-shirts worn at once is 257.",
"In ancient China, paper money was first used over 1,000 years ago.",
"There are more cells in the human body than there are stars in the Milky Way.",
"The coldest temperature ever recorded on Earth was -89.2°C (-128.6°F) in Antarctica.",
"Honeybees communicate by dancing in a figure-eight pattern.",
"The world's largest rubber band ball weighs 4,097 kg (over 9,000 lbs).",
"A single sneeze can produce up to 40,000 droplets.",
"In 2007, an Australian man built a car entirely out of Lego.",
"A group of flamingos is sometimes also called a 'stand'.",
"Some snakes can reproduce through parthenogenesis.",
"The first bicycle was invented in 1817.",
"Lightning strikes the Earth about 100 times per second.",
"The modern Olympic Games were revived in 1896.",
"There is a rare phenomenon known as 'ball lightning' that remains mysterious.",
"The speed of sound is about 343 meters per second in air.",
"In ancient Egypt, cats were revered and considered sacred.",
"A group of frogs is called an 'army'.",
"The first person convicted of speeding was clocked at around 13 km/h (8 mph).",
"Venus is the only planet that rotates clockwise.",
"The largest living animal is the blue whale.",
"A group of lions is called a 'pride'.",
"Some plants emit a faint glow in the dark, known as bioluminescence.",
"The average person blinks about 1520 times per minute.",
"The original Xbox was released in 2001.",
"The first documented parachute jump was in 1797.",
"Some turtles breathe through their cloacas.",
"The Olympic torch relay tradition began at the 1936 Berlin Games.",
"There is a species of lizard that can squirt blood from its eyes as a defense mechanism.",
"Some sea snails have blue blood.",
"The oldest known written language is Sumerian.",
"In ancient Rome, purple clothing was reserved for royalty.",
"The Earth's magnetic field is shifting at about 15 km (9 miles) per year.",
"Some whale species can communicate over 250km (over 150 miles) on the surface. This increases to over 6.000km (over 3.700 miles) at certain depths.",
"In 1977, a radio signal from space, dubbed the 'Wow! signal', baffled scientists.",
"The average person walks about 112,650 km (70,000 miles) in their lifetime.",
"The human brain can generate about 20 watts of electrical power while awake.",
"America's first roller coaster was built in Coney Island in 1884.",
"There's a coral reef in the Red Sea that glows at night due to bioluminescent plankton.",
"The average human sheds about 600,000 particles of skin every hour.",
"The longest-running TV show is 'Guiding Light', which aired for 72 years.",
"Goldfish have a memory span of several months, not just three seconds.",
"Bats aren't blind; they rely on a combination of echolocation, vision, and smell to navigate.",
"Humans use virtually every part of their brain, the idea that we only use 10% is a myth.",
"Napoleon's height was average for his era; he wasn't unusually short.",
"Cracking your knuckles does not cause arthritis.",
"Vaccines do not cause autism.",
"Dogs see colors, primarily blues and yellows, even though their color range is more limited than humans'.",
"Hair and fingernails don't actually continue growing after death; skin dehydration makes them appear longer.",
"Sugar does not cause hyperactivity in children.",
"You don't swallow spiders in your sleep, this is a baseless myth.",
"Chameleons change color primarily for communication and regulating body temperature, not just for camouflage.",
"Swallowed gum passes through the digestive system in days, not years.",
"The rule of 8 glasses of water a day' is a myth, hydration needs vary based on many factors.",
"Lightning doesn't always strike the highest point; its path depends on complex atmospheric conditions.",
"Microwaves heat food by exciting water molecules, they do not make food radioactive or use harmful radiation.",
"Shaving hair does not cause it to grow back thicker or darker.",
"Humans have a keener sense of smell than commonly believed and can detect a vast array of odors.",
"Catching a cold isn't caused by being cold, the illness is due to viral infections.",
"Humans and dinosaurs never coexisted; dinosaurs went extinct about 65 million years before humans appeared.",
"Pure water is a poor conductor of electricity, the impurities (ions) in water make it conductive.",
"Humans have more than the traditional five senses; we also sense balance, temperature, pain, and more.",
"Ostriches do not bury their heads in the sand, the myth likely arose from their defensive behavior of lying low.",
"Duck quacks do echo; the idea that they don't is simply false.",
"Drinking alcohol doesn't actually warm you up, it causes blood vessels to dilate, leading to increased heat loss.",
"Mice have excellent memories and can be trained to remember complex tasks.",
"The full moon does not cause erratic human behavior, this popular belief isn't supported by solid scientific evidence.",
"Some sharks can actively pump water over their gills to breathe rather than needing constant motion.",
"Flight recorders (often called black boxes') are painted bright orange to make them easier to locate after an accident.",
"A penny dropped from a great height is unlikely to kill a person because air resistance limits its terminal velocity.",
"Cats don't always land on their feet, while agile, falls can still result in injury.",
"The human brain continues to develop and change throughout life; neuroplasticity persists well beyond early adulthood.",
"Excess sugar alone doesn't directly cause diabetes, lifestyle factors and overall diet play major roles.",
"The five-second rule' for dropped food is not scientifically valid; bacteria can contaminate food almost instantly.",
"Octopuses have a decentralized nervous system, with a central brain and mini-brains in each arm, allowing for complex, independent arm control.",
"Computers don't 'think' like humans, they perform rapid, specific calculations without any consciousness.",
"The idea that computer viruses come solely from rogue programmers is oversimplified; most malware exploits vulnerabilities in software.",
"Bananas naturally contain a small amount of the radioactive isotope potassium-40, proving that not all radioactivity is dangerous.",
"Microwave ovens heat food by exciting water molecules; they do not make food radioactive or alter its molecular structure in harmful ways.",
"Moore's Law, which predicted the doubling of transistors on a chip roughly every two years, is approaching physical limits as chip features reach the nanoscale.",
"Modern agriculture now relies heavily on technology and data analytics, contradicting the notion that farming is purely manual work.",
"Plant biotechnology, including gene editing like CRISPR, has revolutionized crop improvement, challenging the belief that genetics in agriculture is unchangeable.",
"While sci-fi often depicts robots as highly intelligent, most artificial intelligence today is specialized and lacks the general reasoning capabilities of humans.",
"Cloud computing isn't an abstract 'cloud' floating in space, it's backed by physical servers housed in data centers around the globe.",
"Nuclear reactors use controlled nuclear fission to safely generate energy, dispelling the myth that all nuclear processes are inherently catastrophic.",
"Everyday background radiation, from cosmic rays to radon, is a natural part of our environment and necessary for certain biological processes.",
"The internet is not a single, monolithic entity but a vast, decentralized network of interconnected systems and protocols.",
"Cryptocurrency networks like Bitcoin consume a lot of energy, yet the debate over their value and sustainability continues as technology evolves.",
"Machine learning algorithms require massive datasets and careful tuning, they don't 'learn' autonomously like the human brain.",
"Early computing devices, such as mechanical calculators and analog computers, predate modern digital computers by centuries.",
"Satellites and space probes are engineered for the vacuum and radiation of space, contradicting the idea that all technology is Earth-bound.",
"The electromagnetic spectrum includes many forms of energy, radio waves, microwaves, X-rays, and gamma rays, that are often misunderstood or unseen.",
"Cell phone signals are non-ionizing radiation, which means they don't carry enough energy to damage DNA, countering common fears about cancer.",
"Despite their complexity, even advanced computer chips operate using relatively simple physical principles governed by quantum mechanics.",
"The Green Revolution of the mid-20th century, which introduced high-yield hybrid seeds and chemical fertilizers, transformed agriculture and challenged traditional farming methods.",
"Controlled exposure to radiation in medical imaging (like X-rays and CT scans) is a powerful diagnostic tool, debunking the myth that all radiation is harmful.",
"Technological advances historically have disrupted certain jobs but ultimately create new industries and opportunities.",
"Scientific studies indicate that both organic and conventional produce offer similar nutritional benefits, challenging the assumption that organic always means healthier.",
"Everyday items like smoke detectors and luminous watch dials safely use small amounts of radioactive material, demonstrating that radioactivity isn't uniformly dangerous.",
"Quantum computing, based on superposition and entanglement, operates in ways that defy classical logic, contradicting everyday notions of how computers work.",
"Artificial intelligence systems are not infallible; they can reflect biases present in their training data and make errors, challenging the idea of AI as a perfect solution.",
"Solar panels can generate electricity on cloudy days too, contrary to the common belief that they only work in bright sunlight.",
"Rapid advancements in technology have dramatically reduced the cost of computing power over the decades, contradicting the notion that high-tech gadgets always have to be expensive.",
"The internet was originally designed as a resilient, decentralized network meant to withstand partial outages, rather than as a centralized, profit-driven platform.",
"Lightning can strike in clear weather, not just during a storm.",
"Plants communicate with each other by releasing volatile organic compounds that warn of pest attacks.",
"About 95% of the universe is made up of dark matter and dark energy, substances we cannot directly see.",
"Water expands when it freezes, which is why ice floats instead of sinking.",
"Every computer programming language is a human creation designed to instruct machines, not a form of natural thought.",
"If uncoiled, the DNA in your body could stretch from the sun to Pluto and back multiple times.",
"Earth's magnetic field shields us from harmful solar radiation.",
"It's not just the heat but the humidity that makes tropical summers feel oppressively sticky.",
"Astronauts can temporarily gain up to 5cm (2 inches) in height in space because their spinal discs expand without gravity's compression.",
"Crystals can grow faster in microgravity, which often produces unique structures not seen on Earth.",
"The pH of human blood is tightly regulated at about 7.4, regardless of what we eat.",
"A supernova explosion can briefly outshine an entire galaxy, defying our everyday sense of scale.",
"Much of the Internet's physical infrastructure still relies on decades-old copper wiring alongside modern fiber optics.",
"Quantum entanglement lets particles influence each other instantly, challenging our common-sense ideas of cause and effect.",
"Soil microbes play a huge role in sequestering carbon, which is vital for regulating Earth's climate.",
"Some bacteria thrive in extreme environments such as boiling acid or deep-sea vents, contrary to what we might expect about life.",
"Space isn't completely silent, electromagnetic vibrations can be translated into sound for scientific analysis.",
"Some studies suggest the human brain exhibits bursts of high activity during sleep, contradicting the idea that it shuts off' at night.",
"Properly preserved digital storage media can last for centuries, challenging the notion that all digital data is short-lived.",
"Recycling electronic waste is far more complex than recycling paper or glass due to the variety of materials involved.",
"Engineers are developing advanced shielding techniques to protect spacecraft from high-radiation environments.",
"The rapid rise of renewable energy is driven as much by falling costs as by environmental concern.",
"Many modern computer algorithms draw inspiration from biological processes, such as neural networks that mimic brain function.",
"Today's organic farms frequently use cutting-edge technologies, drones, sensors, and data analytics, to monitor crops.",
"Even though cloud storage seems abstract, it relies on physical data centers distributed around the globe.",
"Radio telescopes, not just optical ones, provide critical insights into the composition of distant celestial objects.",
"Even the emptiest vacuum of space contains sparse particles and residual energy.",
"Rather than isolating us, technology often fosters global communities and unprecedented connectivity.",
"Advances in genetic engineering have improved crop nutrition and resistance, defying the idea that agricultural traits are fixed.",
"Certain bacteria can break down toxic waste into harmless substances, offering innovative solutions for environmental cleanup.",
"Solar flares can unexpectedly disrupt Earth's communications and power grids.",
"Many major computer security breaches occur because of simple human error, not just sophisticated hacking.",
"Modern agriculture uses precision farming techniques that tailor inputs like water and fertilizer to exact field conditions.",
"Radioactive isotopes are harnessed in medicine to both diagnose and treat various diseases.",
"A perfect vacuum' is theoretical, there's always a residual presence of particles or energy even in space.",
"Breakthroughs in battery technology are making electric vehicles more practical than ever before.",
"Even on cloudy days, solar panels continue to produce electricity, albeit at a reduced efficiency.",
"Artificial intelligence systems depend on enormous datasets and careful tuning; they don't learn as humans do.",
"GPS satellites must correct for the effects of Einstein's relativity in order to provide accurate positioning.",
"The pace of technological change often outstrips our societal ability to adapt, challenging our expectations of progress.",
"Nanotechnology exploits the unique behaviors of materials at the nanoscale, defying everyday physics.",
"Superconductivity allows certain materials to conduct electricity with zero resistance when cooled sufficiently.",
"Modern cryptography relies on complex mathematical problems once deemed unsolvable.",
"The Internet of Things is revolutionizing agriculture by connecting sensors, weather stations, and equipment in real time.",
"Many conventional agricultural chemicals are being replaced by bio-based alternatives as environmental concerns grow.",
"Nuclear reactors operate under strict controls, proving that controlled fission can be a safe energy source.",
"Everyday background radiation, from cosmic rays to radon, is a natural part of our environment.",
"Cryptocurrency mining's heavy energy use has sparked debate over its sustainability and long-term viability.",
"Early computers, like the Z3 built by Konrad Zuse, show that digital technology has deep historical roots.",
"Satellites and space probes are meticulously engineered to withstand extreme conditions in space.",
"The electromagnetic spectrum is vast and includes radio waves, microwaves, X-rays, and gamma rays, many of which are invisible to us.",
"Cell phones emit non-ionizing radiation, which lacks the energy to damage DNA, a fact that contradicts common fears.",
"Even the most advanced microprocessors operate on fundamental quantum mechanics principles discovered over a century ago.",
"Modern studies reveal that both organic and conventional produce offer similar nutritional benefits.",
"Everyday items like smoke detectors contain minuscule amounts of radioactive material, safely used for practical purposes.",
"Quantum computers use superposition and entanglement to tackle problems beyond the reach of classical machines.",
"The term 'bug' in computing originated when an actual moth caused a malfunction in an early computer.",
"Semiconductor technology has transformed electronics, enabling devices that are far more powerful and compact than ever before.",
"Sand is the source of the silicon used in computer chips, turning a ubiquitous material into the backbone of modern technology.",
"Researchers have demonstrated that digital information can be encoded into DNA, potentially revolutionizing data storage.",
"Robots are increasingly collaborating with humans on production lines, combining machine precision with human creativity.",
"Genetically modified organisms undergo rigorous testing and regulation, countering many public misconceptions.",
"The first programmable computer, the Z3, was developed in 1941, predating many modern computing systems.",
"While the speed of light is constant in a vacuum, it slows when passing through materials like water or glass.",
"Deep-sea hydrothermal vents support entire ecosystems powered by chemosynthesis rather than sunlight.",
"Adaptive optics in modern telescopes correct for atmospheric distortion, yielding crystal-clear images of space.",
"Artificial neural networks mimic, but do not replicate, the complex processing of the human brain.",
"Many smartphones contain rare-earth elements that are critical to their high performance.",
"Data in computers is stored magnetically, optically, or electronically, each method with its own strengths.",
"Crop rotation, an ancient agricultural practice, is still proven to improve soil health today.",
"Ionizing radiation is naturally occurring and has been a constant factor throughout Earth's history.",
"Smart power grids now adjust energy distribution in real time to meet changing demands.",
"Fiber-optic technology has dramatically increased the speed and volume of global communications.",
"Space weather forecasting helps predict solar storms that could impact satellites and Earth-bound systems.",
"Industries are rapidly adopting 3D printing, which can reduce waste and streamline manufacturing processes.",
"Algorithms continue to evolve, enabling computers to analyze vast amounts of data more efficiently than ever.",
"The human brain's plasticity means that learning new skills can forge new neural connections even in later life.",
"Acoustic levitation uses powerful sound waves to suspend small objects in mid-air, a feat once thought impossible.",
"Satellite imagery is indispensable for tracking deforestation, urban sprawl, and other environmental changes.",
"Precision irrigation systems in agriculture conserve water by delivering exactly the right amount to each plant.",
"Bioinformatics, a merger of biology and computer science, is key to decoding vast genetic datasets.",
"Innovations in medical imaging, like MRI and PET scans, have revolutionized early disease detection.",
"Photolithography is a cornerstone of modern electronics, enabling the manufacture of ever-smaller microchips.",
"Blockchain technology is emerging as a tool for secure record-keeping beyond its role in cryptocurrencies.",
"Genetic improvements in crops have significantly reduced the need for chemical pesticides in modern farming.",
"Fusion energy research aims to harness a virtually limitless and clean energy source, despite current challenges.",
"Advancements in robotics now allow machines to perform incredibly delicate tasks, from surgery to micro-assembly.",
"Quantum cryptography promises theoretically unbreakable encryption using the principles of quantum mechanics.",
"There are materials that change color when exposed to different temperatures, pressures, or light conditions.",
"The behavior of electrons in semiconductors, arranged in distinct energy bands, is the foundation of modern electronics.",
"Sputnik's launch in 1957 marked the beginning of the space age and spurred decades of exploration.",
"Modern radar systems use radio waves to detect and track objects, essential for aviation and meteorology.",
"Precision agriculture leverages satellite data to optimize fertilizer use and water application.",
"Bioluminescence in organisms like certain plankton creates natural light shows in the ocean.",
"Moore's Law, though reaching its physical limits, has historically driven exponential growth in processing power.",
"Agricultural drones now routinely assess crop health and even apply targeted treatments over large fields.",
"Global data generation now reaches exabytes (1 000 000 000 GB) daily, underscoring the exponential growth of digital information.",
"Spectroscopy lets astronomers decipher the chemical makeup of stars by analyzing their light.",
"Ion propulsion systems accelerate ions to generate thrust, offering an alternative to conventional rockets.",
"Wearable technologies continuously monitor health metrics, providing insights once available only in clinics.",
"Space telescopes such as Hubble bypass atmospheric interference to deliver stunning views of the cosmos.",
"Vertical farming in urban settings grows crops in stacked layers, challenging traditional agricultural layouts.",
"Recent strides in AI have produced systems capable of generating human-like text, art, and even music.",
"Acoustics isn't just about sound in air, sound can travel through water, solids, and even plasma.",
"Cloud computing offers scalable, on-demand processing power that redefines the limits of traditional hardware.",
"Bioremediation employs microorganisms to degrade or transform environmental pollutants into non-toxic substances.",
"Deep learning breakthroughs have revolutionized image and speech recognition technologies.",
"Modern vehicles integrate sophisticated computer systems that manage everything from navigation to safety features.",
"The era of Big Data has enabled industries to analyze complex datasets, transforming business practices worldwide.",
"Nuclear magnetic resonance underpins MRI technology, allowing us to see inside the human body without surgery.",
"Graphene, an emerging material, boasts extraordinary strength and conductivity at just one atom thick.",
"Robotic process automation is increasingly handling repetitive tasks across sectors like finance and healthcare.",
"Self-driving cars combine sensor data, machine learning, and real-time processing to navigate complex environments.",
"High-energy particle accelerators probe the fundamental components of matter, expanding our understanding of physics.",
"Soil sensors in precision farming continuously measure moisture, pH, and nutrients to optimize plant growth.",
"Modern computers routinely use multicore processors to perform multiple tasks in parallel.",
"Breakthroughs in solar cell technology have led to panels that are both more efficient and less expensive.",
"3D bioprinting is emerging as a way to create living tissues for research and, eventually, organ transplants.",
"No-till farming practices help preserve soil structure and reduce erosion compared to conventional plowing.",
"Wireless charging uses electromagnetic fields to transfer energy, offering a cable-free way to power devices.",
"Extremophiles thriving in boiling acid or near-freezing conditions highlight the resilience of life.",
"The speed of data transfer in fiber-optic cables is determined by the refractive index of the glass.",
"Many modern agricultural operations now integrate both satellite and drone technologies for real-time monitoring.",
"Biotechnology has paved the way for pest-resistant crops that reduce the need for chemical interventions.",
"Superconductivity is not just a laboratory curiosity, it's applied in technologies like MRI machines and maglev trains.",
"Emerging quantum sensors can detect extremely subtle changes in magnetic and gravitational fields.",
"The evolution of microprocessors has been driven by relentless transistor miniaturization over the decades.",
"Agricultural research increasingly supports the idea that crop diversity can build resilience against climate change.",
"Nanomaterials exhibit properties that differ dramatically from their bulk counterparts, opening new technological frontiers.",
"The historical miniaturization of transistors has been a key driver in the rapid evolution of computing hardware.",
"Advances in image processing now enable computers to interpret and analyze visual data with remarkable precision.",
"Some nuclear power plants use naturally sourced water for cooling, challenging the notion that all require artificial systems.",
"Webster Lake, aka 'Chargoggagoggmanchauggagoggchaubunagungamaugg', can be translated to 'You fish on your side, I'll fish on my side, and no one shall fish in the middle.'",
"Cows, along with other ruminants like sheep, deer and giraffes, have 4 'stomachs', or compartments within the stomach, called the rumen, the reticulum, the omasum and the abomasum.",
"It is proposed that cheese have existed for around 10.000 years, with the earliest archaelogical record being almost 8.000 years old.",
"Wolves have been observed exhibiting mourning behaviors, lingering near the remains of fallen pack members in a manner that suggests they experience grief.",
"Recent research reveals that the unique patterns in wolf howls serve as a vocal fingerprint, conveying detailed information about an individual's identity and emotional state.",
"Contrary to the classic 'alpha' myth, many wolf packs function as family units where the parents lead the group and even subordinate wolves may reproduce.",
"Wolves display remarkable flexibility in their hunting strategies, adapting their techniques based on the prey available and the terrain, demonstrating problem-solving abilities rarely attributed to wild carnivores.",
"In certain wolf populations, individuals have been seen sharing food with non-pack members, challenging the long-held view of wolves as strictly territorial and competitive.",
"Studies show that a wolf's sense of smell is so acute that it can detect prey from several miles away and even follow scent trails that are days old.",
"Cows can recognize and remember the faces of up to 50 individuals, both other cows and humans, indicating a surprisingly complex memory capacity.",
"Beyond their docile reputation, cows have been found to experience a range of emotions, displaying signs of depression when isolated and apparent joy when in the company of their close companions.",
"Research has demonstrated that cows can solve simple puzzles for a reward, challenging the stereotype that they are only passive grazers.",
"Cows communicate through a rich array of vocalizations and subtle body language, suggesting they convey complex emotional states that scientists are only beginning to decipher.",
"Cheese may have been discovered accidentally when milk was stored in containers lined with rennet-rich animal stomachs, causing it to curdle.",
"There are over 1,800 distinct varieties of cheese worldwide, far more than the few types most people are familiar with.",
"Aged cheeses contain virtually no lactose, which explains why many lactose-intolerant individuals can enjoy them without discomfort.",
"Cheese rolling, an annual event in Gloucestershire, England, is not just a quirky tradition but an ancient competitive sport with a history spanning centuries.",
"The characteristic holes in Swiss cheese, known as 'eyes,' are formed by gas bubbles released by bacteria during the fermentation process.",
"Pule cheese, made from the milk of Balkan donkeys, is one of the world's most expensive cheeses, costing over a thousand euros per kilo due to its rarity and labor-intensive production.",
"Cheese may have addictive qualities: during digestion, casomorphins are released from milk proteins, interacting with brain receptors in a way similar to opiates.",
"Some artisanal cheeses are aged in natural caves, where unique microclimates contribute to complex flavors that are difficult to replicate in modern facilities.",
"While Cheddar cheese is now a global staple, it originally came from the small English village of Cheddar, and its production methods have evolved dramatically over time.",
"Modern cheese production often uses vegetarian rennet, derived from microbial or plant sources, challenging the common belief that all cheese is made with animal-derived enzymes.",
"Cows are related to whales, as they both evolved from land-dwelling, even-toed ungulates, hence why a baby whale is called a 'calf'.",
"McNamee was the first to reach 999 of an item, sticks, and was awarded the sticker 'McNamee's Stick' for his efforts.",
"Jenni is 159cm (5.2 feet) tall, while Kami is 186cm (6.1 feet), making Kami almost 20% taller, or the length of a sheet of A4 paper.",
"If Jenni were to bury Kami's dead body in the back of the garden, she wouldn't be able to get out of the hole without a ladder.",
"Aibophobia is the suggested name for a fear of words that can be reversed without changing the word. Aibophobia itself can be reversed."
]

View File

@ -0,0 +1,164 @@
{
"commands": {
"help": {
"description": "Show information about available commands.",
"subcommands": {},
"examples": [
"help",
"help quote"
]
},
"quote": {
"description": "Manage quotes (add, remove, restore, fetch, info).",
"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": {
"args": "[quote_text]",
"desc": "Adds a new quote."
},
"remove": {
"args": "[quote_number]",
"desc": "Removes the specified quote by ID."
},
"restore": {
"args": "[quote_number]",
"desc": "Restores the specified quote by ID."
},
"info": {
"args": "[quote_number]",
"desc": "Retrieves info about the specified quote."
}
},
"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 remove 3 : Remove quote #3",
"quote restore 3 : Restores quote #3",
"quote info 3 : Gets info about quote #3"
]
},
"ping": {
"description": "Check my uptime.",
"subcommands": {
"stat": {}
},
"examples": [
"ping"
]
},
"customvc": {
"description": "Manage custom voice channels.",
"subcommands": {
"name": {
"args": "<new_name>",
"desc": "Rename your custom voice channel."
},
"claim": {
"desc": "Claim ownership of the channel if the owner has left."
},
"lock": {
"desc": "Lock your voice channel to prevent others from joining."
},
"allow": {
"args": "<user>",
"desc": "Allow a specific user to join your locked channel."
},
"deny": {
"args": "<user>",
"desc": "Deny a specific user from joining your channel."
},
"unlock": {
"desc": "Unlock your voice channel."
},
"users": {
"args": "<count>",
"desc": "Set a user limit for your voice channel."
},
"bitrate": {
"args": "<kbps>",
"desc": "Set the bitrate of your voice channel (8-128 kbps)."
},
"status": {
"args": "<status_str>",
"desc": "Set a custom status for your voice channel. (TODO)"
},
"op": {
"args": "<user>",
"desc": "Grant co-ownership of your channel to another user."
},
"settings": {
"desc": "Show current settings of your custom voice channel."
}
},
"examples": [
"customvc : Show help for custom VC commands",
"customvc name My New VC : Rename the VC",
"customvc claim : Claim ownership if the owner left",
"customvc lock : Lock the channel",
"customvc allow @User : Allow a user to join",
"customvc deny @User : Deny a user from joining",
"customvc unlock : Unlock the channel",
"customvc users 5 : Set user limit to 5",
"customvc bitrate 64 : Set bitrate to 64 kbps",
"customvc status AFK Zone : Set custom status",
"customvc op @User : Grant co-ownership",
"customvc settings : View VC settings"
]
},
"howl": {
"description": "Attempt a howl, measured 0-100%.\n(*Adventure Command*)",
"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 : 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"
]
},
"greet": {
"description": "Make me greet you to Discord!",
"subcommands": {},
"examples": [
"greet"
]
},
"reload": {
"description": "Reload Discord commands dynamically. TODO.",
"subcommands": {},
"examples": [
"reload"
]
}
}
}

View File

@ -0,0 +1,40 @@
{
"commands": {
"help": {
"description": "Show info about available commands in Twitch chat.",
"subcommands": {},
"examples": ["!help", "!help quote"]
},
"quote": {
"description": "Manage quotes (add, remove, fetch).",
"subcommands": {
"add": {
"args": "[quote_text]",
"desc": "Adds a new quote."
},
"remove": {
"args": "[quote_number]",
"desc": "Removes the specified quote by ID."
},
"[quote_number]": {
"desc": "Fetch a specific quote by ID."
},
"no subcommand": {
"desc": "Fetch a random quote."
}
},
"examples": [
"!quote add This is my new quote",
"!quote remove 3",
"!quote 5",
"!quote"
]
},
"ping": {
"description": "Check bots latency or respond with a pun.",
"subcommands": {},
"examples": ["!ping"]
}
}
}

View File

@ -0,0 +1,79 @@
{
"0": [
"Uh oh, {username} failed with a miserable {howl_percentage}% howl ...",
"Yikes, {username}'s howl didn't even register. A complete failure!",
"{username} tried to howl, but it came out as a pathetic whisper...",
"Not a single sound from {username}... {howl_percentage}% howl detected!",
"{username} choked on their howl and ended up coughing instead!"
],
"10": [
"{username} let out a weak {howl_percentage}% howl... barely noticeable.",
"{username} howled at {howl_percentage}%, but it sounded more like a sigh.",
"That {howl_percentage}% howl was more of a breath than a call, {username}...",
"{username} tried to howl at {howl_percentage}%, but the wind drowned it out.",
"A faint {howl_percentage}% howl from {username}. A whisper in the night!"
],
"20": [
"{username} howled at {howl_percentage}%! Not great, but not silent!",
"A shaky {howl_percentage}% howl from {username}. At least it's audible!",
"That {howl_percentage}% howl had some spirit, {username}!",
"{username} let out a {howl_percentage}% howl. Could use more power!",
"At {howl_percentage}%, {username}'s howl barely stirred the trees."
],
"30": [
"{username} howled at {howl_percentage}%! A decent attempt!",
"A soft but clear {howl_percentage}% howl from {username}.",
"{username} gave a {howl_percentage}% howl! The wolves tilt their heads.",
"That {howl_percentage}% howl had some potential, {username}!",
"{username}'s {howl_percentage}% howl echoed weakly, but it carried."
],
"40": [
"{username} howled at {howl_percentage}%! Getting a bit of force behind it!",
"A sturdy {howl_percentage}% howl from {username}. The pack listens.",
"{username}'s {howl_percentage}% howl carried through the trees.",
"That {howl_percentage}% howl had some presence! Not bad, {username}.",
"A respectable {howl_percentage}% howl from {username}! Almost strong."
],
"50": [
"A solid {howl_percentage}% howl from {username}! Right in the middle of the scale!",
"{username} let out a {howl_percentage}% howl. Steady and clear.",
"{username}'s {howl_percentage}% howl echoed nicely into the night.",
"That {howl_percentage}% howl had a good ring to it, {username}!",
"{username} howled at {howl_percentage}%, solid but not overwhelming."
],
"60": [
"{username} howled at {howl_percentage}%! A strong and steady call!",
"That {howl_percentage}% howl had some real strength behind it, {username}!",
"A deep {howl_percentage}% howl from {username}, heard from afar!",
"{username} howled at {howl_percentage}%, and the night air carried it far!",
"That {howl_percentage}% howl had a great tone, {username}!"
],
"70": [
"{username} howled at {howl_percentage}%! A bold, commanding call!",
"That {howl_percentage}% howl from {username} had serious power!",
"{username}'s howl at {howl_percentage}% sent a chill through the air!",
"A howling force at {howl_percentage}%! {username} is getting loud!",
"{username} let out a {howl_percentage}% howl! The pack takes notice."
],
"80": [
"{username} howled at {howl_percentage}%! That was a proper call!",
"A mighty {howl_percentage}% howl from {username}! The wolves approve!",
"At {howl_percentage}%, {username}'s howl rang through the valley!",
"{username} let out an {howl_percentage}% howl! Strong and proud!",
"That {howl_percentage}% howl had real weight to it, {username}!"
],
"90": [
"{username} howled at {howl_percentage}%! Almost a perfect call!",
"A thunderous {howl_percentage}% howl from {username}! Almost legendary!",
"That {howl_percentage}% howl from {username} shook the trees!",
"{username} let out a {howl_percentage}% howl! Nearly flawless!",
"That {howl_percentage}% howl was near perfection, {username}!"
],
"100": [
"Wow, {username} performed a perfect {howl_percentage}% howl!",
"A magnificent howl! {username} reached a legendary {howl_percentage}%!",
"{username}'s howl echoed through the mountains at a flawless {howl_percentage}%!",
"The wolves bow before {username}, whose {howl_percentage}% howl was unmatched!",
"Perfect pitch, perfect strength—{username} unleashed a {howl_percentage}% howl!"
]
}

View File

@ -0,0 +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 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}. 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}. 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}. 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 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 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 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 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 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 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}. 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 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 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 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 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}. 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."
]
}

294
globals.py Normal file
View File

@ -0,0 +1,294 @@
import time
import json
import sys
import traceback
import discord
import inspect
import os
# Store the start time globally.
_bot_start_time = time.time()
def get_bot_start_time():
"""
Retrieve the bot's start time.
This function returns the Unix timestamp (in seconds) when the bot was started.
The timestamp is stored in the global variable `_bot_start_time`, which is set
when the module is first imported.
Returns:
float: The Unix timestamp representing the bot's start time.
"""
return _bot_start_time
def load_config_file():
"""
Load the configuration file.
This function attempts to read the JSON configuration from 'config.json'
in the current directory and return its contents as a dictionary. If the
file is not found or if the file contains invalid JSON, an error message
is printed and the program terminates with a non-zero exit code.
Returns:
dict: The configuration data loaded from 'config.json'.
Raises:
SystemExit: If 'config.json' is missing or cannot be parsed.
"""
CONFIG_PATH = "config.json"
try:
with open(CONFIG_PATH, "r") as f:
config_data = json.load(f)
return config_data
except FileNotFoundError:
print("Error: config.json not found.")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error parsing config.json: {e}")
sys.exit(1)
# Load configuration file
config_data = load_config_file()
def load_settings_file(file: str):
"""
Load a settings file from the settings directory.
Args:
file (str): The name of the settings file, with or without the .json extension.
Returns:
dict: The configuration data loaded from the specified settings file.
"""
SETTINGS_PATH = "settings"
# Ensure the file has a .json extension
if not file.endswith(".json"):
file += ".json"
file_path = os.path.join(SETTINGS_PATH, file)
if not os.path.exists(file_path):
log(f"Unable to read the settings file {file}!", "FATAL")
try:
with open(file_path, "r", encoding="utf-8") as f:
config_data = json.load(f)
return config_data
except json.JSONDecodeError as e:
log(f"Error parsing {file}: {e}", "FATAL")
###############################
# Simple Logging System
###############################
def log(message: str, level="INFO", exec_info=False, linebreaks=False):
"""
Log a message with the specified log level.
Capable of logging individual levels to the terminal and/or logfile separately.
Can also append traceback information if needed, and is capable of preserving/removing
linebreaks from log messages as needed. Also prepends the calling function name.
Args:
message (str): The message to log.
level (str, optional): Log level of the message. Defaults to "INFO".
exec_info (bool, optional): If True, append traceback information. Defaults to False.
linebreaks (bool, optional): If True, preserve line breaks in the log. Defaults to False.
Available levels:
DEBUG - Information useful for debugging.
INFO - Informational messages.
WARNING - Something happened that may lead to issues.
ERROR - A non-critical error has occurred.
CRITICAL - A critical, but non-fatal, error occurred.
FATAL - Fatal error; program exits after logging this.
RESTART - Graceful restart.
SHUTDOWN - Graceful exit.
See:
config.json for further configuration options under "logging".
Example:
log("An error occured during processing", "ERROR", exec_info=True, linebreaks=False)
"""
# Hard-coded options/settings (can be expanded as needed)
default_level = "INFO" # Fallback log level
allowed_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL", "RESTART", "SHUTDOWN"}
# Ensure valid level
if level not in allowed_levels:
level = default_level
# Capture the calling function's name using inspect
try:
caller_frame = inspect.stack()[1]
caller_name = caller_frame.function
except Exception:
caller_name = "Unknown"
# Optionally remove linebreaks if not desired
if not linebreaks:
message = message.replace("\n", " ")
# Get current timestamp and uptime
elapsed = time.time() - get_bot_start_time() # Assuming this function is defined elsewhere
from modules import utility
uptime_str, _ = utility.format_uptime(elapsed)
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
# Prepend dynamic details including the caller name
log_message = f"[{timestamp} - {uptime_str}] [{level}] [Func: {caller_name}] {message}"
# Append traceback if required or for error levels
if exec_info or level in {"ERROR", "CRITICAL", "FATAL"}:
log_message += f"\n{traceback.format_exc()}"
# Read logging settings from the configuration
lfp = config_data["logging"]["logfile_path"] # Log File Path
clfp = f"cur_{lfp}" # Current Log File Path
if not (config_data["logging"]["terminal"]["log_to_terminal"] or
config_data["logging"]["file"]["log_to_file"]):
print("!!! WARNING !!! CONSOLE AND LOGFILE OUTPUT DISABLED !!!\n"
"!!! NO LOGS WILL BE PROVIDED !!!")
# Check if this log level is enabled (or if it's a FATAL message which always prints)
if level in config_data["logging"]["log_levels"] or level == "FATAL":
# Terminal output
if config_data["logging"]["terminal"]["log_to_terminal"] or level == "FATAL":
config_level_format = f"log_{level.lower()}"
if config_data["logging"]["terminal"].get(config_level_format, False) or level == "FATAL":
print(log_message)
# File output
if config_data["logging"]["file"]["log_to_file"] or level == "FATAL":
config_level_format = f"log_{level.lower()}"
if config_data["logging"]["file"].get(config_level_format, False) or level == "FATAL":
try:
with open(lfp, "a", encoding="utf-8") as logfile:
logfile.write(f"{log_message}\n")
logfile.flush()
with open(clfp, "a", encoding="utf-8") as c_logfile:
c_logfile.write(f"{log_message}\n")
c_logfile.flush()
except Exception as e:
print(f"[WARNING] Failed to write to logfile: {e}")
# Handle fatal errors with shutdown
if level == "FATAL":
print("!!! FATAL ERROR LOGGED, SHUTTING DOWN !!!")
sys.exit(1)
if level == "RESTART":
print("!!! RESTART LOG LEVEL TRIGGERED, EXITING!!!")
sys.exit(0)
if level == "SHUTDOWN":
print("!!! SHUTDOWN LOG LEVEL TRIGGERED, EXITING!!!")
sys.exit(0)
def reset_curlogfile():
"""
Clear the current log file.
This function constructs the current log file path by prepending 'cur_'
to the log file path specified in the configuration data under the "logging"
section. It then opens the file in write mode, effectively truncating and
clearing its contents.
If an exception occurs while attempting to clear the log file, the error is
silently ignored.
"""
# Initiate logfile
lfp = config_data["logging"]["logfile_path"] # Log File Path
clfp = f"cur_{lfp}" # Current Log File Path
try:
open(clfp, "w")
# log(f"Current-run logfile cleared", "DEBUG")
except Exception as e:
# log(f"Failed to clear current-run logfile: {e}")
pass
def init_db_conn():
"""
Initialize and return a database connection.
This function reads the configuration settings and attempts to establish a
connection to the database by invoking `modules.db.init_db_connection()`. If
no valid connection is obtained (i.e. if the connection is None), it logs a
fatal error and terminates the program using sys.exit(1). If an exception is
raised during the initialization process, the error is logged and the function
returns None.
Returns:
DatabaseConnection or None: A valid database connection object if
successfully established; otherwise, None (or the program may exit if the
connection is missing).
"""
try:
import modules.db
db_conn = modules.db.init_db_connection(config_data)
if not db_conn:
# If we get None, it means a fatal error occurred.
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
class Constants:
@property
def config_data(self) -> dict:
"""Returns a dictionary of the contents of the config.json config file"""
return load_config_file()
def bot_start_time(self) -> float:
"""Returns the bot epoch start time"""
return _bot_start_time
def primary_discord_guild(self) -> object | None:
"""
Retrieve the primary Discord guild from the configuration.
This function attempts to obtain the primary Discord guild based on the
configuration data stored in `config_data["discord_guilds"]`. It converts the first
guild ID in the list to an integer and then creates a `discord.Object` from it. If the
configuration defines more than one (or fewer than the expected number of) guilds, the function
returns `None` for the guild ID.
Returns:
dict: A dictionary with the following keys:
- "object": A `discord.Object` representing the primary Discord guild if exactly one
guild is defined; otherwise, `None`.
- "id": The integer ID of the primary guild if available; otherwise, `None`.
"""
# Checks for a True/False value in the config file to determine if commands sync should be global or single-guild
# If this is 'true' in config, it will
sync_commands_globally = config_data["sync_commands_globally"]
primary_guild_object = None
primary_guild_int = None
if not sync_commands_globally:
log("Discord commands sync set to single-guild in config")
primary_guild_int = int(config_data["discord_guilds"][0]) if len(config_data["discord_guilds"]) > 0 else None
if primary_guild_int:
primary_guild_object = discord.Object(id=primary_guild_int)
return_dict = {"object": primary_guild_object, "id": primary_guild_int}
return return_dict
def twitch_channels_config(self):
with open("settings/twitch_channels_config.json", "r") as f:
CHANNEL_CONFIG = json.load(f)
return CHANNEL_CONFIG
constants = Constants()

16
license.md Normal file
View File

@ -0,0 +1,16 @@
# Business Source License 1.1 (BSL-1.1)
Licensed Work: OokamiPup V2
Licensor: Franz Rolfsvaag / OokamiKunTV
## You may use this software freely for
- Development, testing, and non-commercial purposes.
- Contributions to the project.
## However, the following restrictions apply
- Commercial use, including selling, monetizing, or running in a production setting, is prohibited unless licensed by the original author.
- You may not sublicense, lease, or profit from this work in any form, directly or indirectly.
This license will automatically convert to MIT after 10, unless otherwise specified by the licensor.

1220
modules/db.py Normal file

File diff suppressed because it is too large Load Diff

103
modules/permissions.py Normal file
View File

@ -0,0 +1,103 @@
import json
import os
import discord
PERMISSIONS_FILE = "permissions.json"
# Load permission settings
def load_permissions():
"""Loads the permissions from JSON."""
if not os.path.exists(PERMISSIONS_FILE):
return {}
with open(PERMISSIONS_FILE, "r", encoding="utf-8") as file:
return json.load(file)
def map_roles(platform: str, user_roles: list) -> list:
"""
Maps platform-specific roles to a unified role system.
:param platform: "discord" or "twitch"
:param user_roles: A list of raw roles/badges from the platform
:return: A list of mapped roles based on the JSON role mapping
"""
permissions = load_permissions()
role_mappings = permissions.get("role_mappings", {}).get(platform, {})
mapped_roles = []
for role in user_roles:
normalized_role = role.lower()
mapped_role = role_mappings.get(normalized_role, None)
if mapped_role:
mapped_roles.append(mapped_role)
return mapped_roles if mapped_roles else ["everyone"]
def has_permission(command_name: str, user_id: str, user_roles: list, platform: str) -> bool:
"""
Checks if a user has permission to run a command.
:param command_name: The name of the command being checked.
:param user_id: The ID of the user requesting the command.
:param user_roles: A list of roles/badges the user has (platform-specific).
:param platform: "discord" or "twitch"
:return: True if the user has permission, otherwise False.
"""
permissions = load_permissions()
command_perms = permissions.get("commands", {}).get(command_name, {})
# Extract settings
min_role = command_perms.get("min_role", "")
allowed_roles = command_perms.get("allowed_roles", [])
allowed_users = command_perms.get("allowed_users", [])
# If no min_role and no allowed_roles/users, it's open to everyone
if not min_role and not allowed_roles and not allowed_users:
return True
# Check if user is explicitly allowed
if user_id in allowed_users:
return True # Bypass role check
# Convert platform-specific roles to mapped roles
mapped_roles = map_roles(platform, user_roles)
# If a min_role is set, check against it
if min_role:
role_hierarchy = ["everyone", "follower", "subscriber", "vip", "moderator", "admin", "owner"]
user_role_level = max([role_hierarchy.index(role) for role in mapped_roles if role in role_hierarchy], default=0)
min_role_level = role_hierarchy.index(min_role) if min_role in role_hierarchy else 0
if user_role_level >= min_role_level:
return True
# Check if the user has any explicitly allowed roles
if any(role in allowed_roles for role in mapped_roles):
return True
return False
def has_custom_vc_permission(member: discord.Member, vc_owner_id: int = None) -> bool:
"""
Checks if the given member is either the channel's owner (vc_owner_id)
or a recognized moderator (one of the roles in MODERATOR_ROLE_IDS).
Returns True if they can manage the channel, False otherwise.
"""
MODERATOR_ROLE_IDS = {
896715419681947650, # Pack Owner
1345410182674513920, # Pack Host
958415559370866709, # Discord-specific moderator
896715186357043200, # Pack-general moderator
}
# 1) Check if user is the “owner” of the channel
if vc_owner_id is not None and member.id == vc_owner_id:
return True
# 2) Check if the user has any of the allowed moderator roles
user_role_ids = {r.id for r in member.roles}
if MODERATOR_ROLE_IDS.intersection(user_role_ids):
return True
# Otherwise, no permission
return False

File diff suppressed because it is too large Load Diff

24
permissions.json Normal file
View File

@ -0,0 +1,24 @@
{
"role_mappings": {
"twitch": {
"moderator": "moderator",
"vip": "vip",
"subscriber": "subscriber",
"follower": "follower",
"broadcaster": "owner"
},
"discord": {
"pack mods": "moderator",
"trusted pack member": "vip",
"subscriber": "subscriber",
"everyone": "everyone"
}
},
"commands": {
"hi": {
"min_role": "",
"allowed_roles": ["broadcaster", "owner"],
"allowed_users": ["203190147582394369"]
}
}
}

View File

@ -11,3 +11,4 @@ twitchio==2.7.1 # Twitch chat bot library (async)
# Utility & Logging # Utility & Logging
aiohttp==3.9.1 # Async HTTP requests (dependency for discord.py & twitchio) aiohttp==3.9.1 # Async HTTP requests (dependency for discord.py & twitchio)
regex==2024.11.6 # REGular EXpressions

View File

@ -0,0 +1,26 @@
{
"notes_on_activity_mode": {
"0": "Disable bot activites",
"1": "Static activity",
"2": "Rotating activity",
"3": "Dynamic activity"
},
"activity_mode": 2,
"static_activity": {
"type": "Custom",
"name": "Listening for howls!"
},
"rotating_activities": [
{ "type": "Listening", "name": "howls" },
{ "type": "Playing", "name": "with my commands" },
{ "type": "Watching", "name": "Twitch streams" },
{ "type": "Watching", "name": "Kami code" },
{ "type": "Custom", "name": "I AM EVOLVING!"},
{ "type": "Watching", "name": "the Elder do their thing"}
],
"dynamic_activities": {
"twitch_live": { "type": "Streaming", "name": "OokamiKunTV", "url": "https://twitch.tv/OokamiKunTV" },
"default_idle": { "type": "Playing", "name": "around in Discord" }
},
"rotation_interval": 300
}

View File

@ -0,0 +1,12 @@
{
"896713616089309184": {
"customvc_settings": {
"lobby_vc_id": 1345509388651069583,
"customvc_category_id": 1337034437136879628,
"vc_creation_user_cooldown": 5,
"vc_creation_global_per_min": 2,
"empty_vc_autodelete_delay": 10,
"customvc_max_limit": 9
}
}
}

View File

@ -0,0 +1,14 @@
{
"ookamikuntv": {
"commands_filter_mode": "exclude",
"commands_filtered": []
},
"ookamipup": {
"commands_filter_mode": "exclude",
"commands_filtered": []
},
"packcommunity": {
"commands_filter_mode": "include",
"commands_filtered": ["howl"]
}
}

View File