Compare commits
40 Commits
Author | SHA1 | Date |
---|---|---|
|
d1faf7f214 | |
|
cce0f21ab0 | |
|
86ac83f34c | |
|
50617ef9ab | |
|
d0313a6a92 | |
|
766c3ab690 | |
|
78e24a4641 | |
|
17fbb20cdc | |
|
6da1744990 | |
|
0f1077778f | |
|
01f002600c | |
|
1b141c10fb | |
|
71505b4de1 | |
|
66f3d03bc6 | |
|
699d8d493e | |
|
623aeab9fb | |
|
3ad6504d69 | |
|
aed3d24e33 | |
|
8074fbbef4 | |
|
403ee0aed5 | |
|
1a97a0a78e | |
|
63256b8984 | |
|
faea372a56 | |
|
87d05d961a | |
|
5730840209 | |
|
28d22da0c1 | |
|
780ec2e540 | |
|
af97b65c2f | |
|
afa45aa913 | |
|
a83e27c7ed | |
|
095514d95d | |
|
c4e51abc5b | |
|
dd05c26b5d | |
|
1bc5d5c042 | |
|
29e48907df | |
|
913f63c43b | |
|
5725439354 | |
|
c2676cf8c7 | |
|
1b9a78b3d6 | |
|
9ef553ecb0 |
|
@ -5,3 +5,7 @@ error_log.txt
|
|||
__pycache__
|
||||
Test ?/
|
||||
.venv
|
||||
permissions.json
|
||||
local_database.sqlite
|
||||
logo.*
|
||||
_SQL_PREFILL_QUERIES_
|
643
bot_discord.py
643
bot_discord.py
|
@ -1,32 +1,649 @@
|
|||
# bot_discord.py
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
from discord.ext import commands, tasks
|
||||
import importlib
|
||||
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):
|
||||
def __init__(self, config, log_func):
|
||||
def __init__(self):
|
||||
super().__init__(command_prefix="!", intents=discord.Intents.all())
|
||||
self.config = config
|
||||
self.log = log_func # Use the logging function from bots.py
|
||||
self.load_commands()
|
||||
self.remove_command("help") # Remove built-in help function
|
||||
self.config = globals.constants.config_data
|
||||
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:
|
||||
importlib.reload(cmd_discord)
|
||||
cmd_discord.setup(self)
|
||||
self.log("Discord commands loaded successfully.", "INFO")
|
||||
await ctx.bot.unload_extension(f"cmd_discord.{cog_name}")
|
||||
await ctx.bot.load_extension(f"cmd_discord.{cog_name}")
|
||||
await ctx.reply(f"✅ Reloaded `{cog_name}` successfully!")
|
||||
globals.log(f"Successfully reloaded the command cog `{cog_name}`", "INFO")
|
||||
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):
|
||||
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):
|
||||
try:
|
||||
await super().start(token)
|
||||
except Exception as e:
|
||||
self.log(f"Discord bot error: {e}", "CRITICAL")
|
||||
globals.log(f"Discord bot error: {e}", "CRITICAL")
|
||||
|
|
275
bot_twitch.py
275
bot_twitch.py
|
@ -6,31 +6,109 @@ from twitchio.ext import commands
|
|||
import importlib
|
||||
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):
|
||||
def __init__(self, config, log_func):
|
||||
def __init__(self):
|
||||
self.client_id = os.getenv("TWITCH_CLIENT_ID")
|
||||
self.client_secret = os.getenv("TWITCH_CLIENT_SECRET")
|
||||
self.token = os.getenv("TWITCH_BOT_TOKEN")
|
||||
self.refresh_token = os.getenv("TWITCH_REFRESH_TOKEN")
|
||||
self.log = log_func # Use the logging function from bots.py
|
||||
self.config = config
|
||||
self.log = globals.log # Use the logging function from bots.py
|
||||
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
|
||||
super().__init__(
|
||||
token=self.token,
|
||||
prefix="!",
|
||||
initial_channels=config["twitch_channels"]
|
||||
initial_channels=twitch_channels
|
||||
)
|
||||
|
||||
globals.log("Twitch bot initiated")
|
||||
|
||||
# 2) Then 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.
|
||||
Retries up to 2 times before logging a fatal error.
|
||||
Store the DB connection so that commands can use it.
|
||||
"""
|
||||
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"
|
||||
params = {
|
||||
|
@ -40,31 +118,109 @@ class TwitchBot(commands.Bot):
|
|||
"grant_type": "refresh_token"
|
||||
}
|
||||
|
||||
for attempt in range(3): # Attempt up to 3 times
|
||||
try:
|
||||
response = requests.post(url, params=params)
|
||||
data = response.json()
|
||||
try:
|
||||
response = requests.post(url, params=params)
|
||||
data = response.json()
|
||||
self.log(f"Twitch token response: {data}", "DEBUG")
|
||||
|
||||
if "access_token" in data:
|
||||
self.token = data["access_token"]
|
||||
self.refresh_token = data.get("refresh_token", self.refresh_token)
|
||||
if "access_token" in data:
|
||||
self.token = data["access_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
|
||||
os.environ["TWITCH_REFRESH_TOKEN"] = self.refresh_token
|
||||
self.update_env_file()
|
||||
# Validate newly refreshed token:
|
||||
if not await self.validate_token():
|
||||
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")
|
||||
return # Success, exit function
|
||||
else:
|
||||
self.log(f"Twitch token refresh failed (Attempt {attempt+1}/3): {data}", "WARNING")
|
||||
self.log("Twitch token refreshed successfully.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Twitch token refresh error (Attempt {attempt+1}/3): {e}", "ERROR")
|
||||
elif "error" in data and data["error"] == "invalid_grant":
|
||||
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):
|
||||
"""
|
||||
|
@ -83,37 +239,72 @@ class TwitchBot(commands.Bot):
|
|||
else:
|
||||
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:
|
||||
self.log(f"Failed to update .env file: {e}", "ERROR")
|
||||
globals.log(f"Failed to update .env file: {e}", "ERROR")
|
||||
|
||||
def load_commands(self):
|
||||
"""
|
||||
Load all commands dynamically from cmd_twitch.py.
|
||||
Load all commands from cmd_twitch.py
|
||||
"""
|
||||
try:
|
||||
importlib.reload(cmd_twitch)
|
||||
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:
|
||||
self.log(f"Error loading Twitch commands: {e}", "ERROR")
|
||||
globals.log(f"Error loading Twitch commands: {e}", "ERROR")
|
||||
|
||||
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:
|
||||
self.log(f"Twitch bot connecting...", "INFO")
|
||||
self.log(f"...Consider online if no further messages", "INFO")
|
||||
# Normal attempt: just call self.start()
|
||||
await self.start()
|
||||
#while True:
|
||||
# await self.refresh_access_token()
|
||||
# await asyncio.sleep(10800) # Refresh every 3 hours
|
||||
|
||||
except Exception as e:
|
||||
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):
|
||||
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:
|
||||
await self.refresh_access_token()
|
||||
except Exception as e:
|
||||
self.log(f"Unable to refresh Twitch token! Twitch bot will be offline!", "CRITICAL")
|
||||
self.log("Closing old bot instance after refresh...", "DEBUG")
|
||||
await self.close()
|
||||
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
119
bots.py
|
@ -5,79 +5,104 @@ import asyncio
|
|||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import globals
|
||||
from functools import partial
|
||||
import twitchio.ext
|
||||
|
||||
from discord.ext import commands
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from bot_discord import DiscordBot
|
||||
from bot_twitch import TwitchBot
|
||||
|
||||
#from modules.db import init_db_connection, run_db_operation
|
||||
#from modules.db import ensure_quotes_table, ensure_users_table, ensure_chatlog_table, checkenable_db_fk
|
||||
|
||||
from modules import db, utility
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Clear previous current-run logfile
|
||||
globals.reset_curlogfile()
|
||||
|
||||
# Load bot configuration
|
||||
CONFIG_PATH = "config.json"
|
||||
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.)
|
||||
config_data = globals.Constants.config_data
|
||||
|
||||
###############################
|
||||
# Main Event Loop
|
||||
###############################
|
||||
|
||||
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)
|
||||
twitch_bot = TwitchBot(config_data, log)
|
||||
try: # Ensure FKs are enabled
|
||||
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")))
|
||||
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)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
utility.log_bot_shutdown(db_conn, intent="User Shutdown")
|
||||
except Exception as e:
|
||||
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)
|
|
@ -1,67 +1,934 @@
|
|||
# cmd_common/common_commands.py
|
||||
import random
|
||||
import time
|
||||
from modules import utility
|
||||
import globals
|
||||
import json
|
||||
import re
|
||||
|
||||
def generate_howl_message(username: str) -> str:
|
||||
"""
|
||||
Return a random howl message (0-100).
|
||||
- If 0%: a fail message.
|
||||
- If 100%: a perfect success.
|
||||
- Otherwise: normal message with the random %.
|
||||
"""
|
||||
howl_percentage = random.randint(0, 100)
|
||||
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:
|
||||
"""
|
||||
A single function that handles !howl logic for both Discord and Twitch.
|
||||
We rely on ctx to figure out the platform, the user, the arguments, etc.
|
||||
Return a string that the caller will send.
|
||||
"""
|
||||
utility.wfstl()
|
||||
# 1) Detect which platform
|
||||
# We might do something like:
|
||||
platform, author_id, author_name, author_display_name, args = extract_ctx_info(ctx)
|
||||
|
||||
# 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)
|
||||
|
||||
if howl_percentage == 0:
|
||||
return f"Uh oh, {username} failed with a miserable 0% howl ..."
|
||||
elif howl_percentage == 100:
|
||||
return f"Wow, {username} performed a perfect 100% howl!"
|
||||
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
|
||||
from bots import bot_start_time # where you stored the start timestamp
|
||||
utility.wfstl()
|
||||
# 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()
|
||||
elapsed = current_time - bot_start_time
|
||||
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
|
||||
]
|
||||
utility.wfetl()
|
||||
return (platform_str, author_id, author_name, author_display_name, args)
|
||||
|
||||
# We'll iterate from smallest to largest threshold
|
||||
for threshold, msg in time_ranges:
|
||||
if uptime_s < threshold:
|
||||
return msg
|
||||
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
|
||||
|
||||
# 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!"
|
||||
# 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:
|
||||
"""
|
||||
Returns a greeting string for the given user displayname on a given platform.
|
||||
"""
|
||||
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)
|
|
@ -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}")
|
|
@ -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)
|
|
@ -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}, you’ve 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 it’s 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))
|
|
@ -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))
|
|
@ -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))
|
|
@ -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))
|
|
@ -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))
|
|
@ -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))
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
27
config.json
27
config.json
|
@ -1,7 +1,32 @@
|
|||
{
|
||||
"discord_guilds": [896713616089309184],
|
||||
"sync_commands_globally": true,
|
||||
"twitch_channels": ["OokamiKunTV", "ookamipup"],
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 15–20 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."
|
||||
]
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 bot’s latency or respond with a pun.",
|
||||
"subcommands": {},
|
||||
"examples": ["!ping"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!"
|
||||
]
|
||||
}
|
|
@ -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}. I’m a mix of wild heart and sharp, clever puns.",
|
||||
"With {uptime_str} online, I’m 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, I’m 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 day’s 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."
|
||||
]
|
||||
}
|
||||
|
|
@ -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()
|
|
@ -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.
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
1054
modules/utility.py
1054
modules/utility.py
File diff suppressed because it is too large
Load Diff
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,3 +11,4 @@ twitchio==2.7.1 # Twitch chat bot library (async)
|
|||
|
||||
# Utility & Logging
|
||||
aiohttp==3.9.1 # Async HTTP requests (dependency for discord.py & twitchio)
|
||||
regex==2024.11.6 # REGular EXpressions
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"]
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue