Compare commits
45 Commits
main
...
experiment
Author | SHA1 | Date |
---|---|---|
|
58270b1fbe | |
|
f717ad0f14 | |
|
d5581710a7 | |
|
5023ea9919 | |
|
d541f65804 | |
|
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__
|
__pycache__
|
||||||
Test ?/
|
Test ?/
|
||||||
.venv
|
.venv
|
||||||
|
permissions.json
|
||||||
|
local_database.sqlite
|
||||||
|
logo.*
|
||||||
|
_SQL_PREFILL_QUERIES_
|
641
bot_discord.py
641
bot_discord.py
|
@ -1,32 +1,647 @@
|
||||||
# bot_discord.py
|
# bot_discord.py
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord import app_commands
|
||||||
|
from discord.ext import commands, tasks
|
||||||
import importlib
|
import importlib
|
||||||
import cmd_discord
|
import cmd_discord
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import globals
|
||||||
|
from globals import logger
|
||||||
|
|
||||||
|
import modules
|
||||||
|
import modules.utility
|
||||||
|
from modules.db import log_message, lookup_user, log_bot_event, log_discord_activity
|
||||||
|
|
||||||
|
|
||||||
|
primary_guild = globals.constants.primary_discord_guild()
|
||||||
|
|
||||||
class DiscordBot(commands.Bot):
|
class DiscordBot(commands.Bot):
|
||||||
def __init__(self, config, log_func):
|
def __init__(self):
|
||||||
super().__init__(command_prefix="!", intents=discord.Intents.all())
|
super().__init__(command_prefix="!", intents=discord.Intents.all())
|
||||||
self.config = config
|
self.remove_command("help") # Remove built-in help function
|
||||||
self.log = log_func # Use the logging function from bots.py
|
self.config = globals.constants.config_data
|
||||||
self.load_commands()
|
self.log = globals.logger # 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):
|
logger.info("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]
|
||||||
|
logger.debug(f"Loaded Discord command cog '{short_name}'")
|
||||||
|
|
||||||
|
logger.info("All Discord command cogs loaded successfully.")
|
||||||
|
|
||||||
|
# Now that cogs are all loaded, run any help file initialization:
|
||||||
|
help_json_path = "dictionary/help_discord.json"
|
||||||
|
modules.utility.initialize_help_data(
|
||||||
|
bot=self,
|
||||||
|
help_json_path=help_json_path,
|
||||||
|
is_discord=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@commands.command(name="reload")
|
||||||
|
@commands.is_owner()
|
||||||
|
async def reload(ctx, cog_name: str):
|
||||||
|
"""Reloads a specific cog without restarting the bot."""
|
||||||
try:
|
try:
|
||||||
importlib.reload(cmd_discord)
|
await ctx.bot.unload_extension(f"cmd_discord.{cog_name}")
|
||||||
cmd_discord.setup(self)
|
await ctx.bot.load_extension(f"cmd_discord.{cog_name}")
|
||||||
self.log("Discord commands loaded successfully.", "INFO")
|
await ctx.reply(f"✅ Reloaded `{cog_name}` successfully!")
|
||||||
|
logger.info(f"Successfully reloaded the command cog `{cog_name}`")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"Error loading Discord commands: {e}", "ERROR")
|
await ctx.reply(f"❌ Error reloading `{cog_name}`: {e}")
|
||||||
|
logger.error(f"Failed to reload the command cog `{cog_name}`")
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
logger.debug(f"Message detected by '{message.author.name}' in '{guild_name}' - #'{channel_name}'")
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.warning(f"... UUI lookup failed: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.process_commands(message)
|
||||||
|
logger.debug(f"Command processing complete")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Command processing failed: {e}")
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# logger.debug(f"Reaction added by '{user.name}' on message '{reaction.message.id}' in '{guild_name}' - #{channel_name}")
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# logger.debug(f"Reaction removed by '{user.name}' on message '{reaction.message.id}' in '{guild_name}' - #{channel_name}")
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# logger.debug(f"Message edited by '{before.author.name}' in '{guild_name}' - #{channel_name}")
|
||||||
|
# logger.debug(f"Before: {before.content}\nAfter: {after.content}")
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
# logger.debug(f"Thread '{thread.name}' created in #{thread.parent.name}")
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
# logger.debug(f"Thread updated: '{before.name}' -> '{after.name}'")
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
# logger.debug(f"Thread '{thread.name}' deleted")
|
||||||
|
|
||||||
|
# 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.error(f"Failed to load settings: {e}")
|
||||||
|
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
|
||||||
|
logger.debug(f"Command '{ctx.command}' (Discord) initiated by {ctx.author} in #{channel_name}")
|
||||||
|
if len(_cmd_args) > 1: logger.debug(f"!{ctx.command} arguments: {_cmd_args}")
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
logger.debug(f"Command '{command_name}' (Discord) initiated by {interaction.user} in #{channel_name}")
|
||||||
|
|
||||||
|
if option_values:
|
||||||
|
logger.debug(f"Command '{command_name}' arguments: {option_values}")
|
||||||
|
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
self.log(f"Discord bot is online as {self.user}", "INFO")
|
"""Runs when the bot successfully logs in."""
|
||||||
|
|
||||||
|
# Load activity settings
|
||||||
|
self.settings = self.load_bot_settings()
|
||||||
|
|
||||||
|
# Set initial bot activity
|
||||||
|
await self.update_activity()
|
||||||
|
|
||||||
|
# Sync Slash Commands
|
||||||
|
try:
|
||||||
|
# Sync slash commands globally
|
||||||
|
#await self.tree.sync()
|
||||||
|
#logger.info("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"]}"
|
||||||
|
logger.error(f"Guild lookup failed: {e}")
|
||||||
|
|
||||||
|
_log_message = f"{num_guilds} guilds (global)" if num_guilds > 1 else f"guild: {primary_guild_name}"
|
||||||
|
logger.info(f"Discord slash commands force synced to {_log_message}")
|
||||||
|
logger.info(f"Discord slash commands that got synced: {command_names}")
|
||||||
|
else:
|
||||||
|
logger.info("Discord commands synced globally.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unable to sync Discord slash commands: {e}")
|
||||||
|
|
||||||
|
# Log successful bot startup
|
||||||
|
logger.info(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):
|
||||||
|
logger.warning("Discord bot has lost connection!")
|
||||||
|
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.debug("Activity disabled")
|
||||||
|
|
||||||
|
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.debug(f"Static activity set: {activity_data['type']} {activity_data['name']}")
|
||||||
|
else:
|
||||||
|
await self.change_presence(activity=None)
|
||||||
|
self.log.debug("No static activity defined")
|
||||||
|
|
||||||
|
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.debug("Rotating activity mode enabled")
|
||||||
|
else:
|
||||||
|
self.log.info("No rotating activities defined, falling back to static.")
|
||||||
|
await self.update_activity_static()
|
||||||
|
|
||||||
|
elif mode == 3:
|
||||||
|
# Dynamic activity with fallback
|
||||||
|
if not await self.set_dynamic_activity():
|
||||||
|
self.log.info("Dynamic activity unavailable, falling back.")
|
||||||
|
# Fallback to rotating or static
|
||||||
|
if self.settings.get("rotating_activities"):
|
||||||
|
self.change_rotating_activity.start()
|
||||||
|
self.log.debug("Falling back to rotating activity.")
|
||||||
|
else:
|
||||||
|
await self.update_activity_static()
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.log.warning("Invalid activity mode, defaulting to disabled.")
|
||||||
|
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.debug(f"Static activity set: {activity_data['type']} {activity_data['name']}")
|
||||||
|
else:
|
||||||
|
await self.change_presence(activity=None)
|
||||||
|
self.log.debug("No static activity defined, activity disabled.")
|
||||||
|
|
||||||
|
@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.info("No rotating activities available, stopping rotation.")
|
||||||
|
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.debug(f"Rotating activity: {activity_data['type']} {activity_data['name']}")
|
||||||
|
|
||||||
|
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.debug(f"Dynamic activity set: {activity_data['type']} {activity_data['name']}")
|
||||||
|
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:
|
||||||
|
logger.info(f"User {member.name} ({discord_user_id}) not found in 'users'. Attempting to add...")
|
||||||
|
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:
|
||||||
|
logger.warning(f"Failed to associate {member.name} ({discord_user_id}) with a UUID. Skipping activity log.")
|
||||||
|
return # Prevent logging with invalid UUID
|
||||||
|
if user_uuid:
|
||||||
|
logger.info(f"Successfully added {member.name} ({discord_user_id}) to the UUI database.")
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
logger.info(f"User {after.name} ({discord_user_id}) not found in 'users'. Attempting to add...")
|
||||||
|
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:
|
||||||
|
logger.error(f"Failed to associate {after.name} ({discord_user_id}) with a UUID. Skipping activity log.")
|
||||||
|
return
|
||||||
|
if user_uuid:
|
||||||
|
logger.info(f"Successfully added {after.name} ({discord_user_id}) to the UUI database.")
|
||||||
|
|
||||||
|
# Check all activities
|
||||||
|
new_activity = None
|
||||||
|
for n_activity in after.activities:
|
||||||
|
if isinstance(n_activity, discord.Game):
|
||||||
|
new_activity = ("GAME_START", n_activity.name)
|
||||||
|
elif isinstance(n_activity, discord.Spotify):
|
||||||
|
# Get artist name(s) and format as "{artist_name} - {song_title}"
|
||||||
|
artist_name = ", ".join(n_activity.artists)
|
||||||
|
song_name = n_activity.title
|
||||||
|
spotify_detail = f"{artist_name} - {song_name}"
|
||||||
|
new_activity = ("LISTENING_SPOTIFY", spotify_detail)
|
||||||
|
elif isinstance(n_activity, discord.Streaming):
|
||||||
|
new_activity = ("STREAM_START", n_activity.game or "Sharing screen")
|
||||||
|
|
||||||
|
# Check all activities
|
||||||
|
old_activity = None
|
||||||
|
for o_activity in before.activities:
|
||||||
|
if isinstance(o_activity, discord.Game):
|
||||||
|
old_activity = ("GAME_STOP", o_activity.name)
|
||||||
|
# IGNORE OLD SPOTIFY EVENTS
|
||||||
|
elif isinstance(o_activity, discord.Streaming):
|
||||||
|
old_activity = ("STREAM_STOP", o_activity.game or "Sharing screen")
|
||||||
|
|
||||||
|
if new_activity:
|
||||||
|
modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, new_activity[0], None, new_activity[1])
|
||||||
|
if old_activity:
|
||||||
|
modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, old_activity[0], None, old_activity[1])
|
||||||
|
|
||||||
|
# async def start_account_linking(self, interaction: discord.Interaction):
|
||||||
|
# """Starts the linking process by generating a link code and displaying instructions."""
|
||||||
|
# user_id = str(interaction.user.id)
|
||||||
|
|
||||||
|
# # Check if the user already has a linked account
|
||||||
|
# user_data = modules.db.lookup_user(self.db_conn, self.log, identifier=user_id, identifier_type="discord_user_id")
|
||||||
|
# if user_data and user_data["twitch_user_id"]:
|
||||||
|
# link_date = user_data["datetime_linked"]
|
||||||
|
# await interaction.response.send_message(
|
||||||
|
# f"Your Discord account is already linked to Twitch user **{user_data['twitch_user_display_name']}** "
|
||||||
|
# f"(linked on {link_date}). You must remove the link before linking another account.", ephemeral=True)
|
||||||
|
# return
|
||||||
|
|
||||||
|
# # Generate a unique link code
|
||||||
|
# link_code = modules.utility.generate_link_code()
|
||||||
|
# modules.db.run_db_operation(
|
||||||
|
# self.db_conn, "write",
|
||||||
|
# "INSERT INTO link_codes (DISCORD_USER_ID, LINK_CODE) VALUES (?, ?)",
|
||||||
|
# (user_id, link_code), self.log
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Show the user the link modal
|
||||||
|
# await interaction.response.send_message(
|
||||||
|
# f"To link your Twitch account, post the following message in Twitch chat:\n"
|
||||||
|
# f"`!acc_link {link_code}`\n\n"
|
||||||
|
# f"Then, return here and click 'Done'.", ephemeral=True
|
||||||
|
# )
|
||||||
|
|
||||||
|
# async def finalize_account_linking(self, interaction: discord.Interaction):
|
||||||
|
# """Finalizes the linking process by merging duplicate UUIDs."""
|
||||||
|
# from modules import db
|
||||||
|
# user_id = str(interaction.user.id)
|
||||||
|
|
||||||
|
# # Fetch the updated user info
|
||||||
|
# user_data = modules.db.lookup_user(self.db_conn, self.log, identifier=user_id, identifier_type="discord_user_id")
|
||||||
|
# if not user_data or not user_data["twitch_user_id"]:
|
||||||
|
# await interaction.response.send_message(
|
||||||
|
# "No linked Twitch account found. Please complete the linking process first.", ephemeral=True)
|
||||||
|
# return
|
||||||
|
|
||||||
|
# discord_uuid = user_data["UUID"]
|
||||||
|
# twitch_uuid = modules.db.lookup_user(self.db_conn, self.log, identifier=user_data["twitch_user_id"], identifier_type="twitch_user_id")["UUID"]
|
||||||
|
|
||||||
|
# if discord_uuid == twitch_uuid:
|
||||||
|
# await interaction.response.send_message("Your accounts are already fully linked.", ephemeral=True)
|
||||||
|
# return
|
||||||
|
|
||||||
|
# # Merge all records from `twitch_uuid` into `discord_uuid`
|
||||||
|
# db.merge_uuid_data(self.db_conn, self.log, old_uuid=twitch_uuid, new_uuid=discord_uuid)
|
||||||
|
|
||||||
|
# # Delete the old Twitch UUID entry
|
||||||
|
# db.run_db_operation(self.db_conn, "write", "DELETE FROM users WHERE UUID = ?", (twitch_uuid,), self.log)
|
||||||
|
|
||||||
|
# # Confirm the final linking
|
||||||
|
# await interaction.response.send_message("Your Twitch and Discord accounts are now fully linked.", ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
async def run(self, token):
|
async def run(self, token):
|
||||||
try:
|
try:
|
||||||
await super().start(token)
|
await super().start(token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"Discord bot error: {e}", "CRITICAL")
|
logger.critical(f"Discord bot error: {e}")
|
||||||
|
|
272
bot_twitch.py
272
bot_twitch.py
|
@ -6,31 +6,106 @@ from twitchio.ext import commands
|
||||||
import importlib
|
import importlib
|
||||||
import cmd_twitch
|
import cmd_twitch
|
||||||
|
|
||||||
|
import globals
|
||||||
|
from globals import logger
|
||||||
|
|
||||||
|
import modules
|
||||||
|
import modules.utility
|
||||||
|
from modules.db import log_message, lookup_user, log_bot_event
|
||||||
|
|
||||||
|
twitch_channels = globals.constants.config_data["twitch_channels"]
|
||||||
|
|
||||||
class TwitchBot(commands.Bot):
|
class TwitchBot(commands.Bot):
|
||||||
def __init__(self, config, log_func):
|
def __init__(self):
|
||||||
self.client_id = os.getenv("TWITCH_CLIENT_ID")
|
self.client_id = os.getenv("TWITCH_CLIENT_ID")
|
||||||
self.client_secret = os.getenv("TWITCH_CLIENT_SECRET")
|
self.client_secret = os.getenv("TWITCH_CLIENT_SECRET")
|
||||||
self.token = os.getenv("TWITCH_BOT_TOKEN")
|
self.token = os.getenv("TWITCH_BOT_TOKEN")
|
||||||
self.refresh_token = os.getenv("TWITCH_REFRESH_TOKEN")
|
self.refresh_token = os.getenv("TWITCH_REFRESH_TOKEN")
|
||||||
self.log = log_func # Use the logging function from bots.py
|
self.log = globals.logger # Use the logging function from bots.py
|
||||||
self.config = config
|
self.config = globals.constants.config_data
|
||||||
|
self.db_conn = None # We'll set this later
|
||||||
|
self.help_data = None # We'll set this later
|
||||||
|
|
||||||
# 1) Initialize the parent Bot FIRST
|
# 1) Initialize the parent Bot FIRST
|
||||||
super().__init__(
|
super().__init__(
|
||||||
token=self.token,
|
token=self.token,
|
||||||
prefix="!",
|
prefix="!",
|
||||||
initial_channels=config["twitch_channels"]
|
initial_channels=twitch_channels
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Twitch bot initiated")
|
||||||
|
|
||||||
# 2) Then load commands
|
# 2) Then load commands
|
||||||
self.load_commands()
|
self.load_commands()
|
||||||
|
|
||||||
async def refresh_access_token(self):
|
def set_db_connection(self, db_conn):
|
||||||
"""
|
"""
|
||||||
Refreshes the Twitch access token using the stored refresh token.
|
Store the DB connection so that commands can use it.
|
||||||
Retries up to 2 times before logging a fatal error.
|
|
||||||
"""
|
"""
|
||||||
self.log("Attempting to refresh Twitch token...", "INFO")
|
self.db_conn = db_conn
|
||||||
|
|
||||||
|
async def event_message(self, message):
|
||||||
|
"""
|
||||||
|
Called every time a Twitch message is received (chat message in a channel).
|
||||||
|
"""
|
||||||
|
|
||||||
|
if message.echo:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
author = message.author
|
||||||
|
if not author:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_bot = False
|
||||||
|
user_id = str(author.id)
|
||||||
|
user_name = author.name
|
||||||
|
display_name = author.display_name or user_name
|
||||||
|
|
||||||
|
logger.debug(f"Message detected, attempting UUI lookup on {user_name} ...")
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("... UUI lookup complete.")
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.error(f"... UUI lookup failed: {e}")
|
||||||
|
|
||||||
|
await self.handle_commands(message)
|
||||||
|
|
||||||
|
|
||||||
|
async def event_ready(self):
|
||||||
|
logger.info(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"
|
||||||
|
logger.info(kami_status)
|
||||||
|
log_bot_event(self.db_conn, "TWITCH_RECONNECTED", "Twitch bot logged in.")
|
||||||
|
|
||||||
|
async def event_disconnected(self):
|
||||||
|
logger.warning("Twitch bot has lost connection!")
|
||||||
|
log_bot_event(self.db_conn, "TWITCH_DISCONNECTED", "Twitch bot lost connection.")
|
||||||
|
|
||||||
|
async def refresh_access_token(self, automatic=False, retries=1):
|
||||||
|
"""Refresh the Twitch access token and ensure it is applied correctly."""
|
||||||
|
self.log.info("Attempting to refresh Twitch token...")
|
||||||
|
|
||||||
url = "https://id.twitch.tv/oauth2/token"
|
url = "https://id.twitch.tv/oauth2/token"
|
||||||
params = {
|
params = {
|
||||||
|
@ -40,31 +115,126 @@ class TwitchBot(commands.Bot):
|
||||||
"grant_type": "refresh_token"
|
"grant_type": "refresh_token"
|
||||||
}
|
}
|
||||||
|
|
||||||
for attempt in range(3): # Attempt up to 3 times
|
for attempt in range(retries + 1):
|
||||||
try:
|
try:
|
||||||
response = requests.post(url, params=params)
|
response = requests.post(url, params=params)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
self.log.debug(f"Twitch token response: {data}")
|
||||||
|
|
||||||
if "access_token" in data:
|
if "access_token" in data:
|
||||||
|
_before_token = os.getenv("TWITCH_BOT_TOKEN", "")
|
||||||
|
|
||||||
self.token = data["access_token"]
|
self.token = data["access_token"]
|
||||||
self.refresh_token = data.get("refresh_token", self.refresh_token)
|
self.refresh_token = data.get("refresh_token", self.refresh_token)
|
||||||
|
|
||||||
os.environ["TWITCH_BOT_TOKEN"] = self.token
|
os.environ["TWITCH_BOT_TOKEN"] = self.token
|
||||||
os.environ["TWITCH_REFRESH_TOKEN"] = self.refresh_token
|
os.environ["TWITCH_REFRESH_TOKEN"] = self.refresh_token
|
||||||
self.update_env_file()
|
|
||||||
|
|
||||||
self.log("Twitch token refreshed successfully.", "INFO")
|
self.update_env_file()
|
||||||
return # Success, exit function
|
await asyncio.sleep(1) # Allow Twitch API time to register the new token
|
||||||
|
|
||||||
|
# Ensure bot reloads the updated token
|
||||||
|
self.token = os.getenv("TWITCH_BOT_TOKEN")
|
||||||
|
|
||||||
|
if self.token == _before_token:
|
||||||
|
self.log.critical("Token did not change after refresh! Avoiding refresh loop.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.log.info("Twitch token successfully refreshed.")
|
||||||
|
|
||||||
|
# Validate the new token
|
||||||
|
if not await self.validate_token():
|
||||||
|
self.log.critical("New token is still invalid, re-auth required.")
|
||||||
|
if not automatic:
|
||||||
|
await self.prompt_manual_token()
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True # Successful refresh
|
||||||
|
|
||||||
|
elif "error" in data and data["error"] == "invalid_grant":
|
||||||
|
self.log.critical("Refresh token is invalid or expired; manual re-auth required.")
|
||||||
|
if not automatic:
|
||||||
|
await self.prompt_manual_token()
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
self.log(f"Twitch token refresh failed (Attempt {attempt+1}/3): {data}", "WARNING")
|
self.log.error(f"Unexpected refresh response: {data}")
|
||||||
|
if not automatic:
|
||||||
|
await self.prompt_manual_token()
|
||||||
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"Twitch token refresh error (Attempt {attempt+1}/3): {e}", "ERROR")
|
self.log.error(f"Twitch token refresh error: {e}")
|
||||||
|
if attempt < retries:
|
||||||
|
self.log.warning(f"Retrying token refresh in 2 seconds... (Attempt {attempt + 1}/{retries})")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
else:
|
||||||
|
if not automatic:
|
||||||
|
await self.prompt_manual_token()
|
||||||
|
return False
|
||||||
|
|
||||||
await asyncio.sleep(10) # Wait before retrying
|
async def shutdown_gracefully(self):
|
||||||
|
"""
|
||||||
|
Gracefully shuts down the bot, ensuring all resources are cleaned up.
|
||||||
|
"""
|
||||||
|
self.log.info("Closing Twitch bot gracefully...")
|
||||||
|
try:
|
||||||
|
await self.close() # Closes TwitchIO bot properly
|
||||||
|
self.log.info("Twitch bot closed successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Error during bot shutdown: {e}")
|
||||||
|
|
||||||
|
self.log.fatal("Bot has been stopped. Please restart it manually.")
|
||||||
|
|
||||||
|
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.debug(f"Token validation response: {response.status_code}, {response.text}")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Error during token validation: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def prompt_manual_token(self):
|
||||||
|
"""
|
||||||
|
Prompt the user in-terminal to manually enter a new Twitch access token.
|
||||||
|
"""
|
||||||
|
self.log.warning("Prompting user for manual Twitch token input.")
|
||||||
|
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.info("New Twitch token entered manually. Please restart the bot.")
|
||||||
|
else:
|
||||||
|
self.log.fatal("No valid token entered. Bot cannot continue.")
|
||||||
|
|
||||||
|
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.info("Re-initializing the Twitch connection with the new token...")
|
||||||
|
self._http.token = self.token # Make sure TwitchIO sees the new token
|
||||||
|
await self.start()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Auto-reconnect failed after token refresh: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
# If all attempts fail, log error
|
|
||||||
self.log("Twitch token refresh failed after 3 attempts.", "FATAL")
|
|
||||||
|
|
||||||
def update_env_file(self):
|
def update_env_file(self):
|
||||||
"""
|
"""
|
||||||
|
@ -83,37 +253,73 @@ class TwitchBot(commands.Bot):
|
||||||
else:
|
else:
|
||||||
file.write(line)
|
file.write(line)
|
||||||
|
|
||||||
self.log("Updated .env file with new Twitch token.", "INFO")
|
logger.info("Updated .env file with new Twitch token.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"Failed to update .env file: {e}", "ERROR")
|
logger.error(f"Failed to update .env file: {e}")
|
||||||
|
|
||||||
def load_commands(self):
|
def load_commands(self):
|
||||||
"""
|
"""
|
||||||
Load all commands dynamically from cmd_twitch.py.
|
Load all commands from cmd_twitch.py
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
importlib.reload(cmd_twitch)
|
|
||||||
cmd_twitch.setup(self)
|
cmd_twitch.setup(self)
|
||||||
self.log("Twitch commands loaded successfully.", "INFO")
|
logger.info("Twitch commands loaded successfully.")
|
||||||
|
|
||||||
|
# Now load the help info from dictionary/help_twitch.json
|
||||||
|
help_json_path = "dictionary/help_twitch.json"
|
||||||
|
modules.utility.initialize_help_data(
|
||||||
|
bot=self,
|
||||||
|
help_json_path=help_json_path,
|
||||||
|
is_discord=False # Twitch
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"Error loading Twitch commands: {e}", "ERROR")
|
logger.error(f"Error loading Twitch commands: {e}")
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
"""
|
"""
|
||||||
Run the Twitch bot, refreshing tokens if needed.
|
Attempt to start the bot once. If the token is invalid, refresh it first,
|
||||||
|
then re-instantiate a fresh TwitchBot within the same Python process.
|
||||||
|
This avoids manual restarts or external managers.
|
||||||
"""
|
"""
|
||||||
|
# Attempt token refresh before starting the bot
|
||||||
|
refresh_success = await self.refresh_access_token()
|
||||||
|
|
||||||
|
if not refresh_success:
|
||||||
|
self.log.shutdown("Token refresh failed. Shutting down in the same run.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.sleep(1) # Give Twitch a moment to recognize the refreshed token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.log(f"Twitch bot connecting...", "INFO")
|
|
||||||
self.log(f"...Consider online if no further messages", "INFO")
|
|
||||||
await self.start()
|
await self.start()
|
||||||
#while True:
|
|
||||||
# await self.refresh_access_token()
|
|
||||||
# await asyncio.sleep(10800) # Refresh every 3 hours
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"Twitch bot failed to start: {e}", "CRITICAL")
|
self.log.critical(f"Twitch bot failed to start: {e}")
|
||||||
|
|
||||||
if "Invalid or unauthorized Access Token passed." in str(e):
|
if "Invalid or unauthorized Access Token passed." in str(e):
|
||||||
|
self.log.warning("Token became invalid after refresh. Attempting another refresh...")
|
||||||
|
|
||||||
|
if not await self.refresh_access_token():
|
||||||
|
self.log.shutdown("Second refresh attempt failed. Shutting down.")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.refresh_access_token()
|
self.log.debug("Closing old bot instance after refresh...")
|
||||||
except Exception as e:
|
await self.close()
|
||||||
self.log(f"Unable to refresh Twitch token! Twitch bot will be offline!", "CRITICAL")
|
await asyncio.sleep(1) # give the old WebSocket time to fully close
|
||||||
|
except Exception as close_err:
|
||||||
|
self.log.debug(f"Ignored close() error: {close_err}")
|
||||||
|
|
||||||
|
self.log.info("Creating a fresh TwitchBot instance with the new token...")
|
||||||
|
from bot_twitch import TwitchBot
|
||||||
|
new_bot = TwitchBot()
|
||||||
|
new_bot.set_db_connection(self.db_conn)
|
||||||
|
|
||||||
|
self.log.info("Starting the new TwitchBot in the same run in 3 seconds...")
|
||||||
|
await asyncio.sleep(1) # final delay
|
||||||
|
await new_bot.run()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.log.shutdown("Could not connect due to an unknown error. Shutting down.")
|
||||||
|
return
|
121
bots.py
121
bots.py
|
@ -5,79 +5,106 @@ import asyncio
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import globals
|
||||||
|
from functools import partial
|
||||||
|
import twitchio.ext
|
||||||
|
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from globals import logger
|
||||||
|
|
||||||
from bot_discord import DiscordBot
|
from bot_discord import DiscordBot
|
||||||
from bot_twitch import TwitchBot
|
from bot_twitch import TwitchBot
|
||||||
|
|
||||||
|
#from modules.db import init_db_connection, run_db_operation
|
||||||
|
#from modules.db import ensure_quotes_table, ensure_users_table, ensure_chatlog_table, checkenable_db_fk
|
||||||
|
|
||||||
|
from modules import db, utility
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Clear previous current-run logfile
|
||||||
|
logger.reset_curlogfile()
|
||||||
|
|
||||||
# Load bot configuration
|
# Load bot configuration
|
||||||
CONFIG_PATH = "config.json"
|
config_data = globals.Constants.config_data
|
||||||
try:
|
|
||||||
with open(CONFIG_PATH, "r") as f:
|
|
||||||
config_data = json.load(f)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print("Error: config.json not found.")
|
|
||||||
sys.exit(1)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
print(f"Error parsing config.json: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Global settings
|
|
||||||
bot_start_time = time.time()
|
|
||||||
|
|
||||||
###############################
|
|
||||||
# Simple Logging System
|
|
||||||
###############################
|
|
||||||
|
|
||||||
def log(message, level="INFO"):
|
|
||||||
"""
|
|
||||||
A simple logging function with adjustable log levels.
|
|
||||||
Logs messages in a structured format.
|
|
||||||
|
|
||||||
Available levels:
|
|
||||||
DEBUG, INFO, WARNING, ERROR, CRITICAL, FATAL
|
|
||||||
See 'config.json' for disabling/enabling logging levels
|
|
||||||
"""
|
|
||||||
log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"]
|
|
||||||
if level not in log_levels:
|
|
||||||
level = "INFO" # Default to INFO if an invalid level is provided
|
|
||||||
|
|
||||||
if level in config_data["log_levels"]:
|
|
||||||
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
log_message = f"[{timestamp}] [{level}] {message}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(log_message) # Print to terminal
|
|
||||||
except Exception:
|
|
||||||
pass # Prevent logging failures from crashing the bot
|
|
||||||
|
|
||||||
# Placeholder for future expansions (e.g., file logging, Discord alerts, etc.)
|
|
||||||
|
|
||||||
###############################
|
###############################
|
||||||
# Main Event Loop
|
# Main Event Loop
|
||||||
###############################
|
###############################
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
global discord_bot, twitch_bot
|
global discord_bot, twitch_bot, db_conn
|
||||||
|
|
||||||
log("Initializing bots...", "INFO")
|
# Log initial start
|
||||||
|
logger.info("--------------- BOT STARTUP ---------------")
|
||||||
|
# Before creating your DiscordBot/TwitchBot, initialize DB
|
||||||
|
db_conn = globals.init_db_conn()
|
||||||
|
|
||||||
discord_bot = DiscordBot(config_data, log)
|
try: # Ensure FKs are enabled
|
||||||
twitch_bot = TwitchBot(config_data, log)
|
db.checkenable_db_fk(db_conn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unable to ensure Foreign keys are enabled: {e}")
|
||||||
|
|
||||||
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
|
||||||
|
logger.debug(f"{table} ensured.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.fatal(f"Unable to ensure DB tables exist: {e}")
|
||||||
|
|
||||||
|
logger.info("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)
|
||||||
|
logger.info(f"Initialized database connection to both bots")
|
||||||
|
except Exception as e:
|
||||||
|
logger.fatal(f"Unable to initialize database connection to one or both bots: {e}")
|
||||||
|
|
||||||
|
logger.info("Starting Discord and Twitch bots...")
|
||||||
|
|
||||||
discord_task = asyncio.create_task(discord_bot.run(os.getenv("DISCORD_BOT_TOKEN")))
|
discord_task = asyncio.create_task(discord_bot.run(os.getenv("DISCORD_BOT_TOKEN")))
|
||||||
twitch_task = asyncio.create_task(twitch_bot.run())
|
twitch_task = asyncio.create_task(twitch_bot.run())
|
||||||
|
|
||||||
|
from modules.utility import dev_func
|
||||||
|
enable_dev_func = False
|
||||||
|
if enable_dev_func:
|
||||||
|
dev_func_result = dev_func(db_conn, enable_dev_func)
|
||||||
|
logger.debug(f"dev_func output: {dev_func_result}")
|
||||||
|
|
||||||
await asyncio.gather(discord_task, twitch_task)
|
await asyncio.gather(discord_task, twitch_task)
|
||||||
|
#await asyncio.gather(discord_task)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
utility.log_bot_shutdown(db_conn, intent="User Shutdown")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_trace = traceback.format_exc()
|
error_trace = traceback.format_exc()
|
||||||
log(f"Fatal Error: {e}\n{error_trace}", "FATAL")
|
logger.fatal(f"Fatal Error: {e}\n{error_trace}")
|
||||||
|
utility.log_bot_shutdown(db_conn)
|
|
@ -1,67 +1,940 @@
|
||||||
# cmd_common/common_commands.py
|
# cmd_common/common_commands.py
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from modules import utility
|
||||||
|
import globals
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
def generate_howl_message(username: str) -> str:
|
from globals import logger
|
||||||
"""
|
|
||||||
Return a random howl message (0-100).
|
from modules import db
|
||||||
- If 0%: a fail message.
|
|
||||||
- If 100%: a perfect success.
|
#def howl(username: str) -> str:
|
||||||
- Otherwise: normal message with the random %.
|
# """
|
||||||
"""
|
# Generates a howl response based on a random percentage.
|
||||||
howl_percentage = random.randint(0, 100)
|
# 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:
|
else:
|
||||||
return f"{username} howled at {howl_percentage}%!"
|
# normal usage => random generation
|
||||||
|
utility.wfetl()
|
||||||
|
return handle_howl_normal(ctx, platform, author_id, author_display_name)
|
||||||
|
|
||||||
def ping():
|
def extract_ctx_info(ctx):
|
||||||
"""
|
"""
|
||||||
Returns a string, confirming the bot is online.
|
Figures out if this is Discord or Twitch,
|
||||||
|
returns (platform_str, author_id, author_name, author_display_name, args).
|
||||||
"""
|
"""
|
||||||
from modules.utility import format_uptime
|
utility.wfstl()
|
||||||
from bots import bot_start_time # where you stored the start timestamp
|
# Is it discord.py or twitchio?
|
||||||
|
if hasattr(ctx, "guild"): # typically means discord.py context
|
||||||
|
platform_str = "discord"
|
||||||
|
author_id = str(ctx.author.id)
|
||||||
|
author_name = ctx.author.name
|
||||||
|
author_display_name = ctx.author.display_name
|
||||||
|
# parse arguments from ctx.message.content
|
||||||
|
parts = ctx.message.content.strip().split()
|
||||||
|
args = parts[1:] if len(parts) > 1 else []
|
||||||
|
else:
|
||||||
|
# assume twitchio
|
||||||
|
platform_str = "twitch"
|
||||||
|
author = ctx.author
|
||||||
|
author_id = str(author.id)
|
||||||
|
author_name = author.name
|
||||||
|
author_display_name = author.display_name or author.name
|
||||||
|
# parse arguments from ctx.message.content
|
||||||
|
parts = ctx.message.content.strip().split()
|
||||||
|
args = parts[1:] if len(parts) > 1 else []
|
||||||
|
|
||||||
current_time = time.time()
|
utility.wfetl()
|
||||||
elapsed = current_time - bot_start_time
|
return (platform_str, author_id, author_name, author_display_name, args)
|
||||||
uptime_str, uptime_s = format_uptime(elapsed)
|
|
||||||
# Define thresholds in ascending order
|
|
||||||
# (threshold_in_seconds, message)
|
|
||||||
# The message is a short "desperation" or "awake" text.
|
|
||||||
time_ranges = [
|
|
||||||
(3600, f"I've been awake for {uptime_str}. I just woke up, feeling great!"), # < 1 hour
|
|
||||||
(10800, f"I've been awake for {uptime_str}. I'm still fairly fresh!"), # 3 hours
|
|
||||||
(21600, f"I've been awake for {uptime_str}. I'm starting to get a bit weary..."), # 6 hours
|
|
||||||
(43200, f"I've been awake for {uptime_str}. 12 hours?! Might be time for coffee."), # 12 hours
|
|
||||||
(86400, f"I've been awake for {uptime_str}. A whole day without sleep... I'm okay?"), # 1 day
|
|
||||||
(172800, f"I've been awake for {uptime_str}. Two days... I'd love a nap."), # 2 days
|
|
||||||
(259200, f"I've been awake for {uptime_str}. Three days. Is sleep optional now?"), # 3 days
|
|
||||||
(345600, f"I've been awake for {uptime_str}. Four days... I'm running on fumes."), # 4 days
|
|
||||||
(432000, f"I've been awake for {uptime_str}. Five days. Please send more coffee."), # 5 days
|
|
||||||
(518400, f"I've been awake for {uptime_str}. Six days. I've forgotten what dreams are."), # 6 days
|
|
||||||
(604800, f"I've been awake for {uptime_str}. One week. I'm turning into a zombie."), # 7 days
|
|
||||||
(1209600, f"I've been awake for {uptime_str}. Two weeks. Are you sure I can't rest?"), # 14 days
|
|
||||||
(2592000, f"I've been awake for {uptime_str}. A month! The nightmares never end."), # 30 days
|
|
||||||
(7776000, f"I've been awake for {uptime_str}. Three months. I'm mostly coffee now."), # 90 days
|
|
||||||
(15552000,f"I've been awake for {uptime_str}. Six months. This is insane..."), # 180 days
|
|
||||||
(23328000,f"I've been awake for {uptime_str}. Nine months. I might be unstoppable."), # 270 days
|
|
||||||
(31536000,f"I've been awake for {uptime_str}. A year?! I'm a legend of insomnia..."), # 365 days
|
|
||||||
]
|
|
||||||
|
|
||||||
# We'll iterate from smallest to largest threshold
|
def handle_howl_normal(ctx, platform, author_id, author_display_name) -> str:
|
||||||
for threshold, msg in time_ranges:
|
"""
|
||||||
if uptime_s < threshold:
|
Normal usage: random generation, store in DB.
|
||||||
return msg
|
"""
|
||||||
|
utility.wfstl()
|
||||||
|
db_conn = ctx.bot.db_conn
|
||||||
|
|
||||||
# If none matched, it means uptime_s >= 31536000 (1 year+)
|
# Random logic for howl percentage
|
||||||
return f"I've been awake for {uptime_str}. Over a year awake... I'm beyond mortal limits!"
|
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:
|
||||||
|
logger.warning(f"Could not find user by ID={author_id} on {platform}. Not storing howl.")
|
||||||
|
|
||||||
|
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:
|
||||||
|
user_data = db.lookup_user(db_conn, identifier=target_name, identifier_type=f"{platform}_displayname")
|
||||||
|
if not user_data:
|
||||||
|
user_data = db.lookup_user(db_conn, identifier=target_name, identifier_type=f"{platform}_user_id")
|
||||||
|
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_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_display_name")
|
||||||
|
if ud:
|
||||||
|
utility.wfetl()
|
||||||
|
return ud
|
||||||
|
ud = db.lookup_user(db_conn, name_str, "twitch_username")
|
||||||
|
utility.wfetl()
|
||||||
|
return ud
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown platform '{platform}' in lookup_user_by_name")
|
||||||
|
utility.wfetl()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def ping() -> str:
|
||||||
|
"""
|
||||||
|
Returns a dynamic, randomized uptime response.
|
||||||
|
"""
|
||||||
|
utility.wfstl()
|
||||||
|
debug = False
|
||||||
|
|
||||||
|
# Use function to retrieve correct startup time and calculate uptime
|
||||||
|
elapsed = time.time() - globals.get_bot_start_time()
|
||||||
|
uptime_str, uptime_s = utility.format_uptime(elapsed)
|
||||||
|
|
||||||
|
# Define threshold categories
|
||||||
|
thresholds = [600, 1800, 3600, 10800, 21600, 43200, 86400, 172800, 259200, 345600,
|
||||||
|
432000, 518400, 604800, 1209600, 2592000, 7776000, 15552000, 23328000, 31536000]
|
||||||
|
|
||||||
|
# Find the highest matching threshold
|
||||||
|
selected_threshold = max([t for t in thresholds if uptime_s >= t], default=600)
|
||||||
|
|
||||||
|
# Get a random response from the dictionary
|
||||||
|
response = utility.get_random_reply(dictionary_name="ping_replies", category=str(selected_threshold), uptime_str=uptime_str)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print(f"Elapsed time: {elapsed}\nuptime_str: {uptime_str}\nuptime_s: {uptime_s}\nselected threshold: {selected_threshold}\nresponse: {response}")
|
||||||
|
|
||||||
|
utility.wfetl()
|
||||||
|
return response
|
||||||
|
|
||||||
def greet(target_display_name: str, platform_name: str) -> str:
|
def greet(target_display_name: str, platform_name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Returns a greeting string for the given user displayname on a given platform.
|
Returns a greeting string for the given user displayname on a given platform.
|
||||||
"""
|
"""
|
||||||
return f"Hello {target_display_name}, welcome to {platform_name}!"
|
return f"Hello {target_display_name}, welcome to {platform_name}!"
|
||||||
|
|
||||||
|
######################
|
||||||
|
# Quotes
|
||||||
|
######################
|
||||||
|
|
||||||
|
def create_quotes_table(db_conn):
|
||||||
|
"""
|
||||||
|
Creates the 'quotes' table if it does not exist, with the columns:
|
||||||
|
ID, QUOTE_TEXT, QUOTEE, QUOTE_CHANNEL, QUOTE_DATETIME, QUOTE_GAME, QUOTE_REMOVED, QUOTE_REMOVED_DATETIME
|
||||||
|
Uses a slightly different CREATE statement depending on MariaDB vs SQLite.
|
||||||
|
"""
|
||||||
|
utility.wfstl()
|
||||||
|
if not db_conn:
|
||||||
|
logger.fatal("No database connection available to create quotes table!")
|
||||||
|
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:
|
||||||
|
logger.error(f"handle_quote_command() failed to retrieve a random quote: {e}", 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:
|
||||||
|
logger.error(f"handle_quote_command() failed to add a new quote: {e}", 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:
|
||||||
|
logger.error(f"handle_quote_command() failed to remove a quote: {e}", 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:
|
||||||
|
logger.error(f"handle_quote_command() failed to restore a quote: {e}", 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:
|
||||||
|
logger.error(f"handle_quote_command() failed to retrieve quote info: {e}", 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:
|
||||||
|
logger.error(f"handle_quote_command() failed to process quote search: {e}", exec_info=True)
|
||||||
|
elif sub in ["last", "latest", "newest"]:
|
||||||
|
try:
|
||||||
|
utility.wfetl()
|
||||||
|
return await retrieve_latest_quote(db_conn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"handle_quote_command() failed to retrieve latest quote: {e}", 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:
|
||||||
|
logger.error(f"handle_quote_command() failed to retrieve a specific quote: {e}", exec_info=True)
|
||||||
|
else:
|
||||||
|
# unrecognized subcommand => fallback to random
|
||||||
|
try:
|
||||||
|
utility.wfetl()
|
||||||
|
return await retrieve_random_quote(db_conn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"handle_quote_command() failed to retrieve a random quote: {e}", 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:
|
||||||
|
logger.error(f"Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not saved.")
|
||||||
|
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)
|
||||||
|
logger.info(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:
|
||||||
|
logger.error(f"Could not find UUID for user {ctx.author.name} ({user_id}) on {platform}. Quote not removed.")
|
||||||
|
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:
|
||||||
|
logger.info(f"Could not find platform name for quotee UUID {quotee}. Default to 'Unknown'")
|
||||||
|
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:
|
||||||
|
logger.warning(f"Could not find platform name for remover UUID {quote_removed_by}. Default to 'Unknown'")
|
||||||
|
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:
|
||||||
|
logger.info(f"Could not find display name for quotee UUID {quotee}.")
|
||||||
|
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:
|
||||||
|
logger.info(f"Could not find display name for remover UUID {quote_removed_by}.")
|
||||||
|
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"
|
||||||
|
logger.info(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:
|
||||||
|
logger.info(f"Could not find display name for quotee UUID {quotee}.")
|
||||||
|
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"
|
||||||
|
logger.info(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"
|
||||||
|
logger.info(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,872 @@
|
||||||
|
# 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, tasks
|
||||||
|
import datetime
|
||||||
|
import globals
|
||||||
|
|
||||||
|
from globals import logger
|
||||||
|
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 = {}
|
||||||
|
"""
|
||||||
|
Each entry in CUSTOM_VC_INFO will look like:
|
||||||
|
{
|
||||||
|
"owner_id": <int>,
|
||||||
|
"autoname": <bool>,
|
||||||
|
"locked": <bool>,
|
||||||
|
"allowed_ids": set(),
|
||||||
|
"denied_ids": set(),
|
||||||
|
"user_limit": <int or None>,
|
||||||
|
"bitrate": <int or None>,
|
||||||
|
"last_rename_time": datetime.datetime or None # We'll add this for the rename cooldown
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.USER_LAST_CREATED = {}
|
||||||
|
self.GLOBAL_CREATIONS = []
|
||||||
|
self.CHANNEL_COUNTER = 0
|
||||||
|
self.PENDING_DELETIONS = {}
|
||||||
|
|
||||||
|
# Start the auto-rename loop
|
||||||
|
self.rename_cooldown_seconds = 300 # For example, 5 minutes
|
||||||
|
self.auto_rename_loop.start()
|
||||||
|
|
||||||
|
def cog_unload(self):
|
||||||
|
"""Stop the loop when the cog is unloaded."""
|
||||||
|
self.auto_rename_loop.cancel()
|
||||||
|
|
||||||
|
@tasks.loop(seconds=30.0) # Desired interval
|
||||||
|
async def auto_rename_loop(self):
|
||||||
|
"""
|
||||||
|
Periodically checks for auto-named channels and updates their name
|
||||||
|
based on the owner’s current game, respecting a cooldown.
|
||||||
|
"""
|
||||||
|
# For debug, let's log that the loop is running:
|
||||||
|
logger.debug("[AutoRenameLoop] Starting iteration over all custom VCs...")
|
||||||
|
|
||||||
|
# We can fetch the one guild if you only have one, or do this for multiple
|
||||||
|
# For simplicity, we assume the single guild from your JSON:
|
||||||
|
guild_id = 896713616089309184
|
||||||
|
guild = self.bot.get_guild(guild_id)
|
||||||
|
if not guild:
|
||||||
|
logger.warning("[AutoRenameLoop] Guild not found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
for vc_id, info in list(self.CUSTOM_VC_INFO.items()):
|
||||||
|
if not info.get("autoname"):
|
||||||
|
continue # skip if auto-naming disabled
|
||||||
|
|
||||||
|
vc = guild.get_channel(vc_id)
|
||||||
|
if not vc or not isinstance(vc, discord.VoiceChannel):
|
||||||
|
logger.debug(f"[AutoRenameLoop] VC {vc_id} not found or not a VoiceChannel.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
owner_id = info["owner_id"]
|
||||||
|
owner = guild.get_member(owner_id)
|
||||||
|
if not owner:
|
||||||
|
logger.debug(f"[AutoRenameLoop] Owner {owner_id} not found in guild.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine what name we *want* the channel to have
|
||||||
|
desired_name = self.get_desired_name(owner)
|
||||||
|
current_name = vc.name
|
||||||
|
|
||||||
|
# Compare to see if there's a difference
|
||||||
|
if desired_name != current_name:
|
||||||
|
# Check when we last renamed
|
||||||
|
last_rename = info.get("last_rename_time")
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
if last_rename is None:
|
||||||
|
# Means we've never renamed, so let's rename immediately
|
||||||
|
logger.debug(
|
||||||
|
f"[AutoRenameLoop] VC {vc.id}: No last rename; renaming from '{current_name}' to '{desired_name}'."
|
||||||
|
)
|
||||||
|
await vc.edit(name=desired_name, reason="Auto-naming initial rename.")
|
||||||
|
info["last_rename_time"] = now
|
||||||
|
else:
|
||||||
|
# We have a last rename time, see how many seconds
|
||||||
|
diff = (now - last_rename).total_seconds()
|
||||||
|
logger.debug(
|
||||||
|
f"[AutoRenameLoop] VC {vc.id} name differ. Last rename was {int(diff)}s ago. "
|
||||||
|
f"Cooldown = {self.rename_cooldown_seconds}."
|
||||||
|
)
|
||||||
|
if diff >= self.rename_cooldown_seconds:
|
||||||
|
# We rename now
|
||||||
|
logger.debug(
|
||||||
|
f"[AutoRenameLoop] Renaming VC {vc.id} from '{current_name}' to '{desired_name}'"
|
||||||
|
)
|
||||||
|
await vc.edit(name=desired_name, reason="Auto-naming update.")
|
||||||
|
info["last_rename_time"] = now
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"[AutoRenameLoop] Skipping rename of VC {vc.id} because cooldown not reached ({int(diff)} < {self.rename_cooldown_seconds})."
|
||||||
|
)
|
||||||
|
# end if last_rename is None
|
||||||
|
else:
|
||||||
|
logger.debug(f"[AutoRenameLoop] VC {vc.id} already at desired name '{current_name}'. No rename needed.")
|
||||||
|
|
||||||
|
logger.debug("[AutoRenameLoop] Finished iteration.")
|
||||||
|
|
||||||
|
@auto_rename_loop.before_loop
|
||||||
|
async def before_auto_rename_loop(self):
|
||||||
|
"""
|
||||||
|
If we need the bot to be ready, wait until on_ready is done.
|
||||||
|
"""
|
||||||
|
await self.bot.wait_until_ready()
|
||||||
|
|
||||||
|
@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.
|
||||||
|
logger.debug(f"{ctx.author.name} executed Custom VC subcommand '{ctx.invoked_subcommand}' in {ctx.channel.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"'customvc {ctx.invoked_subcommand}' failed to execute: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
|
||||||
|
def get_desired_name(self, member: discord.Member) -> str:
|
||||||
|
game_name = self.get_current_game(member)
|
||||||
|
if game_name:
|
||||||
|
return game_name
|
||||||
|
else:
|
||||||
|
return f"{member.display_name}'s Channel"
|
||||||
|
|
||||||
|
@auto_rename_loop.before_loop
|
||||||
|
async def before_auto_rename_loop(self):
|
||||||
|
"""
|
||||||
|
If we need the bot to be ready, wait until on_ready is done.
|
||||||
|
"""
|
||||||
|
logger.debug("[AutoRenameLoop] Waiting for bot to be ready...")
|
||||||
|
await self.bot.wait_until_ready()
|
||||||
|
logger.debug("[AutoRenameLoop] Bot is ready. Starting loop...")
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_ready(self):
|
||||||
|
"""Handles checking existing voice channels on bot startup."""
|
||||||
|
logger.info("[CustomVCCog] on_ready triggered, scanning for existing custom VCs.")
|
||||||
|
await self.scan_existing_custom_vcs()
|
||||||
|
|
||||||
|
def get_current_game(self, member: discord.Member) -> str | None:
|
||||||
|
"""
|
||||||
|
Return the name of the game if the member is playing one, else None.
|
||||||
|
"""
|
||||||
|
for activity in member.activities or []:
|
||||||
|
# In modern discord.py, a "game" is typically an activity with type=ActivityType.playing
|
||||||
|
if activity.type == discord.ActivityType.playing and activity.name:
|
||||||
|
return activity.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --------------
|
||||||
|
# Helper: identify if user is playing a game
|
||||||
|
# --------------
|
||||||
|
def get_current_game(self, member: discord.Member) -> str | None:
|
||||||
|
"""
|
||||||
|
Return the name of the game if the member is playing one, else None.
|
||||||
|
"""
|
||||||
|
for activity in member.activities or []:
|
||||||
|
# In modern discord.py, a "game" is typically an activity with type=ActivityType.playing
|
||||||
|
if activity.type == discord.ActivityType.playing and activity.name:
|
||||||
|
return activity.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------
|
||||||
|
# EVENT: on_voice_state_update
|
||||||
|
# --------------
|
||||||
|
@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)
|
||||||
|
|
||||||
|
async def on_voice_update(self, member, before, after):
|
||||||
|
# If user changed channels
|
||||||
|
if before.channel != after.channel:
|
||||||
|
logger.debug(
|
||||||
|
f"[on_voice_update] {member.display_name} changed channels "
|
||||||
|
f"from '{getattr(before.channel, 'name', None)}' to '{getattr(after.channel, 'name', None)}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1) If user just joined the "lobby"
|
||||||
|
if after.channel and after.channel.id == self.LOBBY_VC_ID:
|
||||||
|
logger.debug(f"[on_voice_update] {member.display_name} joined the lobby.")
|
||||||
|
# Respect rate-limits
|
||||||
|
if not await self.can_create_vc(member):
|
||||||
|
# forcibly disconnect
|
||||||
|
await member.move_to(None)
|
||||||
|
logger.debug(
|
||||||
|
f"[on_voice_update] {member.display_name} exceeded creation limits. Disconnected."
|
||||||
|
)
|
||||||
|
lobby_chan = after.channel
|
||||||
|
if lobby_chan and hasattr(lobby_chan, "send"):
|
||||||
|
await lobby_chan.send(
|
||||||
|
f"{member.mention}, you’ve exceeded custom VC creation limits. Try again later!"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Identify category
|
||||||
|
category = member.guild.get_channel(self.CUSTOM_VC_CATEGORY_ID)
|
||||||
|
if not category or not isinstance(category, discord.CategoryChannel):
|
||||||
|
logger.error(
|
||||||
|
f"[CustomVC] Could not find valid category for guild {member.guild.id}. Aborting creation."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Attempt to figure out the game or fallback
|
||||||
|
game_name = self.get_current_game(member)
|
||||||
|
if game_name:
|
||||||
|
vc_name = game_name
|
||||||
|
else:
|
||||||
|
vc_name = f"{member.display_name}'s Channel"
|
||||||
|
|
||||||
|
if not vc_name:
|
||||||
|
logger.error(
|
||||||
|
f"[CustomVC] Could not derive channel name from user {member.display_name}"
|
||||||
|
)
|
||||||
|
lobby_chan = after.channel
|
||||||
|
if lobby_chan and hasattr(lobby_chan, "send"):
|
||||||
|
await lobby_chan.send(
|
||||||
|
f"Could not create your channel, {member.mention}. Please try again."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.CHANNEL_COUNTER += 1
|
||||||
|
new_vc = await category.create_voice_channel(name=vc_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CustomVC] Failed to create voice channel: {e}", exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[CustomVC] Created new channel '{vc_name}' (ID {new_vc.id}) for {member.display_name}."
|
||||||
|
)
|
||||||
|
await member.move_to(new_vc)
|
||||||
|
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
self.USER_LAST_CREATED[member.id] = now
|
||||||
|
self.GLOBAL_CREATIONS.append(now)
|
||||||
|
# prune old from GLOBAL_CREATIONS
|
||||||
|
cutoff = now - datetime.timedelta(seconds=60)
|
||||||
|
self.GLOBAL_CREATIONS[:] = [t for t in self.GLOBAL_CREATIONS if t > cutoff]
|
||||||
|
|
||||||
|
# Store custom VC info
|
||||||
|
self.CUSTOM_VC_INFO[new_vc.id] = {
|
||||||
|
"owner_id": member.id,
|
||||||
|
"autoname": False,
|
||||||
|
"locked": False,
|
||||||
|
"allowed_ids": set(),
|
||||||
|
"denied_ids": set(),
|
||||||
|
"user_limit": None,
|
||||||
|
"bitrate": None,
|
||||||
|
"last_rename_time": None, # We haven't renamed it yet
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasattr(new_vc, "send"):
|
||||||
|
await new_vc.send(
|
||||||
|
f"{member.display_name}, your custom voice channel is ready! "
|
||||||
|
"Type `!customvc` here for help with subcommands."
|
||||||
|
)
|
||||||
|
|
||||||
|
# If user left a channel that is tracked
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Also check if they joined a channel pending deletion
|
||||||
|
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 self.DELETE_DELAY_SECONDS if it stays empty.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def delete_task():
|
||||||
|
await asyncio.sleep(self.DELETE_DELAY_SECONDS)
|
||||||
|
if len(vc.members) == 0:
|
||||||
|
logger.info(f"[CustomVC] Deleting empty channel '{vc.name}' (ID {vc.id}).")
|
||||||
|
self.CUSTOM_VC_INFO.pop(vc.id, None)
|
||||||
|
try:
|
||||||
|
await vc.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CustomVC] Could not delete VC {vc.id}: {e}", exc_info=True)
|
||||||
|
self.PENDING_DELETIONS.pop(vc.id, None)
|
||||||
|
|
||||||
|
loop = vc.guild._state.loop
|
||||||
|
task = loop.create_task(delete_task())
|
||||||
|
self.PENDING_DELETIONS[vc.id] = task
|
||||||
|
logger.debug(f"[CustomVC] Scheduled deletion for channel '{vc.name}' in {self.DELETE_DELAY_SECONDS}s.")
|
||||||
|
|
||||||
|
|
||||||
|
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 X minutes
|
||||||
|
- globally only Y 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):
|
||||||
|
logger.debug(
|
||||||
|
f"[can_create_vc] {member.display_name} last created a VC {int(diff)}s ago, "
|
||||||
|
f"less than cooldown of {self.USER_COOLDOWN_MINUTES*60}s."
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
logger.debug(
|
||||||
|
f"[can_create_vc] Global creation limit of {self.GLOBAL_CHANNELS_PER_MINUTE}/min reached."
|
||||||
|
)
|
||||||
|
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
|
||||||
|
- If empty, delete immediately
|
||||||
|
- If non-empty, first occupant is new owner
|
||||||
|
"""
|
||||||
|
logger.debug("[scan_existing_custom_vcs] Checking leftover channels at startup...")
|
||||||
|
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:
|
||||||
|
if ch.id == self.LOBBY_VC_ID:
|
||||||
|
continue
|
||||||
|
if len(ch.members) == 0:
|
||||||
|
# safe to delete
|
||||||
|
try:
|
||||||
|
await ch.delete()
|
||||||
|
logger.info(
|
||||||
|
f"[scan_existing_custom_vcs] Deleted empty leftover channel: {ch.name} (ID {ch.id})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[scan_existing_custom_vcs] Could not delete leftover VC {ch.id}: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# pick first occupant as owner
|
||||||
|
first = ch.members[0]
|
||||||
|
self.CHANNEL_COUNTER += 1
|
||||||
|
self.CUSTOM_VC_INFO[ch.id] = {
|
||||||
|
"owner_id": first.id,
|
||||||
|
"autoname": False,
|
||||||
|
"locked": False,
|
||||||
|
"allowed_ids": set(),
|
||||||
|
"denied_ids": set(),
|
||||||
|
"user_limit": None,
|
||||||
|
"bitrate": None,
|
||||||
|
"last_rename_time": None,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
f"[scan_existing_custom_vcs] Assigned {first.display_name} as owner "
|
||||||
|
f"of leftover VC: {ch.name} (ID {ch.id})."
|
||||||
|
)
|
||||||
|
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 toggle_autoname(self, ctx: commands.Context):
|
||||||
|
"""
|
||||||
|
Toggles automatic renaming of the channel based on the owner's current game.
|
||||||
|
- If enabling, we rename immediately.
|
||||||
|
- If disabling, we do nothing else but set the flag to False.
|
||||||
|
"""
|
||||||
|
vc = self.get_custom_vc_for(ctx)
|
||||||
|
if not vc:
|
||||||
|
await ctx.send("You are not in a custom voice channel.")
|
||||||
|
return
|
||||||
|
if not self.is_initiator_or_mod(ctx, vc.id):
|
||||||
|
await ctx.send("No permission to toggle auto-naming.")
|
||||||
|
return
|
||||||
|
|
||||||
|
info = self.CUSTOM_VC_INFO[vc.id]
|
||||||
|
if info["autoname"]:
|
||||||
|
info["autoname"] = False
|
||||||
|
logger.info(f"[autoname] Disabled for VC {vc.id}.")
|
||||||
|
await ctx.send("Auto-naming disabled. The channel name will remain as-is.")
|
||||||
|
else:
|
||||||
|
info["autoname"] = True
|
||||||
|
logger.info(f"[autoname] Enabled for VC {vc.id}.")
|
||||||
|
await ctx.send(
|
||||||
|
"Auto-naming enabled! The channel will periodically update to reflect your current game."
|
||||||
|
)
|
||||||
|
# rename now
|
||||||
|
owner_id = info["owner_id"]
|
||||||
|
owner = vc.guild.get_member(owner_id)
|
||||||
|
if owner:
|
||||||
|
await self.update_channel_name(owner, vc, immediate=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_game(self, member: discord.Member) -> str | None:
|
||||||
|
"""
|
||||||
|
Return the name of the game if the member is playing one, else None.
|
||||||
|
"""
|
||||||
|
for activity in member.activities or []:
|
||||||
|
# In modern discord.py, a "game" is typically an activity with type=ActivityType.playing
|
||||||
|
if activity.type == discord.ActivityType.playing and activity.name:
|
||||||
|
return activity.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
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, immediate: bool = False
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Attempt to rename the channel to reflect the owner’s current game or fallback.
|
||||||
|
If `immediate=True`, we ignore the rename cooldown (used for first-time enabling).
|
||||||
|
"""
|
||||||
|
if not vc or not member:
|
||||||
|
return
|
||||||
|
|
||||||
|
desired_name = self.get_desired_name(member)
|
||||||
|
current_name = vc.name
|
||||||
|
if current_name == desired_name:
|
||||||
|
logger.debug(
|
||||||
|
f"[update_channel_name] VC {vc.id} already named '{current_name}', no change needed."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
info = self.CUSTOM_VC_INFO.get(vc.id)
|
||||||
|
if not info:
|
||||||
|
return # safety check
|
||||||
|
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
last_rename = info.get("last_rename_time")
|
||||||
|
if immediate:
|
||||||
|
logger.debug(
|
||||||
|
f"[update_channel_name] Doing an immediate rename of VC {vc.id} to '{desired_name}'."
|
||||||
|
)
|
||||||
|
await vc.edit(name=desired_name, reason="Manual immediate rename (autoname toggled on).")
|
||||||
|
info["last_rename_time"] = now
|
||||||
|
else:
|
||||||
|
# Normal logic with cooldown
|
||||||
|
if last_rename is None:
|
||||||
|
logger.debug(
|
||||||
|
f"[update_channel_name] VC {vc.id}: first rename => '{current_name}' -> '{desired_name}'."
|
||||||
|
)
|
||||||
|
await vc.edit(name=desired_name, reason="Auto-naming initial rename.")
|
||||||
|
info["last_rename_time"] = now
|
||||||
|
else:
|
||||||
|
diff = (now - last_rename).total_seconds()
|
||||||
|
if diff >= self.rename_cooldown_seconds:
|
||||||
|
logger.debug(
|
||||||
|
f"[update_channel_name] VC {vc.id} rename from '{current_name}' -> '{desired_name}'"
|
||||||
|
f"(cooldown {int(diff)}s >= {self.rename_cooldown_seconds})."
|
||||||
|
)
|
||||||
|
await vc.edit(name=desired_name, reason="Auto-naming update.")
|
||||||
|
info["last_rename_time"] = now
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"[update_channel_name] Skipping rename for VC {vc.id}, only {int(diff)}s "
|
||||||
|
f"since last rename (cooldown {self.rename_cooldown_seconds}s)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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?")
|
||||||
|
|
||||||
|
autoname_str = "Yes" if info["autoname"] else "No"
|
||||||
|
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"Auto rename: {autoname_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,380 @@
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
import globals
|
||||||
|
from globals import logger
|
||||||
|
from utility.PoE2_wishlist import PoeTradeWatcher
|
||||||
|
import re, json, asyncio, os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
watcher = PoeTradeWatcher()
|
||||||
|
|
||||||
|
# Load currency metadata from JSON
|
||||||
|
storage_path = Path(__file__).resolve().parent.parent / "local_storage"
|
||||||
|
currency_lookup_path = storage_path / "poe_currency_data.json"
|
||||||
|
with open(currency_lookup_path, "r", encoding="utf-8") as f:
|
||||||
|
CURRENCY_METADATA = json.load(f)
|
||||||
|
|
||||||
|
class PoE2TradeCog(commands.Cog):
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
watcher.set_update_callback(self.handle_poe2_updates)
|
||||||
|
|
||||||
|
|
||||||
|
@commands.command(name="poe2trade")
|
||||||
|
async def poe2trade(self, ctx, *, arg_str: str = ""):
|
||||||
|
if not arg_str:
|
||||||
|
await ctx.author.send(embed=self._get_help_embed())
|
||||||
|
await ctx.reply("I've sent you a DM with usage instructions.")
|
||||||
|
return
|
||||||
|
|
||||||
|
args = arg_str.strip().split()
|
||||||
|
subcommand = args[0].lower()
|
||||||
|
user_id = str(ctx.author.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if subcommand == "add" and len(args) >= 2:
|
||||||
|
filter_id = args[1]
|
||||||
|
custom_name = " ".join(args[2:]).strip() if len(args) > 2 else None
|
||||||
|
|
||||||
|
# Validate name (only basic safe characters)
|
||||||
|
if custom_name and not re.fullmatch(r"[a-zA-Z0-9\s\-\_\!\?\(\)\[\]]+", custom_name):
|
||||||
|
await ctx.reply("❌ Invalid characters in custom name. Only letters, numbers, spaces, and basic punctuation are allowed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await watcher.add_filter(user_id, filter_id, custom_name)
|
||||||
|
|
||||||
|
if result["status"] == "success":
|
||||||
|
await ctx.reply(f"✅ Filter `{filter_id}` was successfully added to your watchlist.")
|
||||||
|
logger.info(f"User {user_id} added filter {filter_id} ({custom_name or 'no name'})")
|
||||||
|
elif result["status"] == "exists":
|
||||||
|
await ctx.reply(f"⚠️ Filter `{filter_id}` is already on your watchlist.")
|
||||||
|
logger.info(f"User {user_id} attempted to add existing filter {filter_id} ({custom_name or 'no name'})")
|
||||||
|
elif result["status"] == "rate_limited":
|
||||||
|
await ctx.reply("⏱️ You're adding filters too quickly. Please wait a bit before trying again.")
|
||||||
|
logger.warning(f"User {user_id} adding filters too quickly! Filter {filter_id} ({custom_name or 'no name'})")
|
||||||
|
elif result["status"] == "limit_reached":
|
||||||
|
await ctx.reply("🚫 You've reached the maximum number of filters. Remove one before adding another.")
|
||||||
|
logger.info(f"User {user_id} already at max filters when attempting to add filter {filter_id} ({custom_name or 'no name'})")
|
||||||
|
elif result["status"] == "warning":
|
||||||
|
await ctx.reply("⚠️ That filter returns too many results. Add it with better filters or use force mode (not currently supported).")
|
||||||
|
logger.info(f"User {user_id} attempted to add too broad filter: {filter_id} ({custom_name or 'no name'})")
|
||||||
|
elif result["status"] == "invalid_session":
|
||||||
|
await ctx.reply("🔒 The session token is invalid or expired. Please update it via `!poe2trade set POESESSID <token>` (owner-only).")
|
||||||
|
logger.error(f"POESESSID token invalid when user {user_id} attempted to add filter {filter_id} ({custom_name or 'no name'})")
|
||||||
|
elif result["status"] == "queued":
|
||||||
|
await ctx.reply("🕑 The filter has been queued for addition")
|
||||||
|
logger.error(f"User {user_id} queued filter {filter_id} ({custom_name or 'no name'}) for addition.")
|
||||||
|
else:
|
||||||
|
await ctx.reply(f"❌ Failed to add filter `{filter_id}`. Status: {result['status']}")
|
||||||
|
logger.error(f"An unknown error occured when user {user_id} attempted to add filter {filter_id} ({custom_name or 'no name'})")
|
||||||
|
|
||||||
|
|
||||||
|
elif subcommand == "remove" and len(args) >= 2:
|
||||||
|
filter_id = args[1]
|
||||||
|
|
||||||
|
if filter_id.lower() == "all":
|
||||||
|
result = watcher.remove_all_filters(user_id)
|
||||||
|
if result["status"] == "removed":
|
||||||
|
await ctx.reply("✅ All filters have been removed from your watchlist.")
|
||||||
|
logger.info(f"User {user_id} removed all filters.")
|
||||||
|
else:
|
||||||
|
await ctx.reply("⚠️ You don't have any filters to remove.")
|
||||||
|
else:
|
||||||
|
result = watcher.remove_filter(user_id, filter_id)
|
||||||
|
if result["status"] == "removed":
|
||||||
|
await ctx.reply(f"✅ Filter `{filter_id}` was successfully removed.")
|
||||||
|
logger.info(f"User {user_id} removed filter {filter_id}")
|
||||||
|
elif result["status"] == "not_found":
|
||||||
|
await ctx.reply(f"⚠️ Filter `{filter_id}` was not found on your watchlist.")
|
||||||
|
else:
|
||||||
|
await ctx.reply(f"❌ Failed to remove filter `{filter_id}`. Status: {result['status']}")
|
||||||
|
|
||||||
|
|
||||||
|
elif subcommand == "list":
|
||||||
|
filters = watcher.watchlists.get(user_id, [])
|
||||||
|
if not filters:
|
||||||
|
await ctx.reply("You don't have any watchlisted filters.")
|
||||||
|
else:
|
||||||
|
embed = discord.Embed(title="Your PoE2 Watchlist", color=discord.Color.orange())
|
||||||
|
for fid in filters:
|
||||||
|
name = watcher.get_filter_name(user_id, fid)
|
||||||
|
label = f"{fid} — *{name}*" if name else fid
|
||||||
|
|
||||||
|
next_time = watcher.get_next_query_time(user_id, fid)
|
||||||
|
if next_time:
|
||||||
|
time_line = f"Next check: ~<t:{next_time}:R>\n"
|
||||||
|
else:
|
||||||
|
time_line = ""
|
||||||
|
|
||||||
|
embed.add_field(
|
||||||
|
name=label,
|
||||||
|
value=f"{time_line}[Open in Trade Site](https://www.pathofexile.com/trade2/search/poe2/Standard/{fid})\n`!poe2trade remove {fid}`",
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
await ctx.reply(embed=embed)
|
||||||
|
logger.info(f"User {user_id} requested their watchlist.")
|
||||||
|
|
||||||
|
elif subcommand == "set" and len(args) >= 3:
|
||||||
|
if not self._is_owner(user_id):
|
||||||
|
await ctx.reply("Only the server owner can modify settings.")
|
||||||
|
return
|
||||||
|
key, value = args[1], args[2]
|
||||||
|
parsed_value = int(value) if value.isdigit() else value
|
||||||
|
|
||||||
|
result = await watcher.set_setting(key, parsed_value)
|
||||||
|
|
||||||
|
if result["status"] == "ok":
|
||||||
|
logger.info(f"Setting updated: {key} = {value}")
|
||||||
|
await ctx.reply(f"✅ Setting `{key}` updated successfully.")
|
||||||
|
elif result["status"] == "invalid_key":
|
||||||
|
await ctx.reply(f"❌ Setting `{key}` is not recognized.")
|
||||||
|
else:
|
||||||
|
await ctx.reply("⚠️ Something went wrong while updating the setting.")
|
||||||
|
|
||||||
|
elif subcommand == "settings":
|
||||||
|
if not self._is_owner(user_id):
|
||||||
|
await ctx.reply("Only the server owner can view settings.")
|
||||||
|
logger.info(f"User {user_id} requested (but not allowed) to see current settings.")
|
||||||
|
return
|
||||||
|
result = watcher.get_settings()
|
||||||
|
|
||||||
|
embed = discord.Embed(title="Current `PoE 2 Trade` Settings", color=discord.Color.teal())
|
||||||
|
embed.set_footer(text="Use `!poe2trade set <key> <value>` to change a setting (owner-only)")
|
||||||
|
|
||||||
|
status = result.get("status", "unknown")
|
||||||
|
settings = result.get("settings", {})
|
||||||
|
|
||||||
|
embed = discord.Embed(title="Current PoE 2 Trade Settings", color=discord.Color.teal())
|
||||||
|
embed.add_field(name="Status", value=f"`{status}`", inline=False)
|
||||||
|
|
||||||
|
user = self.bot.get_user(int(user_id))
|
||||||
|
if user:
|
||||||
|
owner_display_name = user.display_name # or user.name
|
||||||
|
else:
|
||||||
|
owner_display_name = f"Unknown User"
|
||||||
|
|
||||||
|
for key, value in settings.items():
|
||||||
|
if key == "POESESSID":
|
||||||
|
value = f"`{value[:4]}...{value[-4:]}` (*Obfuscated*)"
|
||||||
|
if key == "AUTO_POLL_INTERVAL":
|
||||||
|
value = f"`{value}` seconds"
|
||||||
|
if key == "MAX_SAFE_RESULTS":
|
||||||
|
value = f"`{value}` max results"
|
||||||
|
if key == "USER_ADD_INTERVAL":
|
||||||
|
value = f"`{value}` seconds"
|
||||||
|
if key == "MAX_FILTERS_PER_USER":
|
||||||
|
value = f"`{value}` filters"
|
||||||
|
if key == "RATE_LIMIT_INTERVAL":
|
||||||
|
value = f"`{value}` seconds"
|
||||||
|
if key == "OWNER_ID":
|
||||||
|
value = f"`{value}` (**{owner_display_name}**)"
|
||||||
|
if key == "LEGACY_WEBHOOK_URL":
|
||||||
|
value = f"`{value[:23]}...{value[-10:]}` (*Obfuscated*)"
|
||||||
|
|
||||||
|
embed.add_field(name=key, value=f"{value}", inline=False)
|
||||||
|
|
||||||
|
embed.set_footer(text="Use `!poe2trade set <key> <value>` to change a setting (owner-only)")
|
||||||
|
|
||||||
|
await ctx.reply(embed=embed)
|
||||||
|
logger.info(f"User {user_id} requested and received current settings.")
|
||||||
|
|
||||||
|
|
||||||
|
elif subcommand == "pause":
|
||||||
|
if watcher.pause_user(user_id):
|
||||||
|
await ctx.reply("⏸️ Your filter notifications are now paused.")
|
||||||
|
logger.info(f"User {user_id} paused their filters.")
|
||||||
|
else:
|
||||||
|
await ctx.reply("⏸️ Your filters were already paused.")
|
||||||
|
|
||||||
|
elif subcommand == "resume":
|
||||||
|
if watcher.resume_user(user_id):
|
||||||
|
await ctx.reply("▶️ Filter notifications have been resumed.")
|
||||||
|
logger.info(f"User {user_id} resumed their filters.")
|
||||||
|
else:
|
||||||
|
await ctx.reply("▶️ Your filters were not paused.")
|
||||||
|
|
||||||
|
elif subcommand == "clearcache":
|
||||||
|
if not self._is_owner(user_id):
|
||||||
|
await ctx.reply("❌ Only the bot owner can use this command.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "-y" in args:
|
||||||
|
result = watcher.clear_seen_cache(user_id)
|
||||||
|
if result["status"] == "success":
|
||||||
|
await ctx.reply("🧹 All cached seen listings have been cleared.")
|
||||||
|
logger.info(f"User {user_id} cleared their listing cache.")
|
||||||
|
else:
|
||||||
|
await ctx.reply("⚠️ Cache clearing failed or confirmation window expired.")
|
||||||
|
else:
|
||||||
|
watcher.initiate_cache_clear(user_id)
|
||||||
|
await ctx.reply(
|
||||||
|
"⚠️ Are you sure you want to clear all listing cache?\n"
|
||||||
|
"Run `!poe2trade clearcache -y` within 60 seconds to confirm."
|
||||||
|
)
|
||||||
|
logger.info(f"User {user_id} requested cache clear confirmation.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
await ctx.reply("Unknown subcommand. Try `!poe2trade` to see help.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in !poe2trade command: {e}", exc_info=True)
|
||||||
|
await ctx.reply("Something went wrong handling your command.")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_help_embed(self):
|
||||||
|
embed = discord.Embed(title="PoE2 Trade Tracker Help", color=discord.Color.blue())
|
||||||
|
embed.description = (
|
||||||
|
"This bot allows you to track Path of Exile 2 trade listings.\n\n"
|
||||||
|
"**Usage:**\n"
|
||||||
|
"`!poe2trade add <filter_id> [filter_nickname]` — Adds a new filter to your watchlist.\n"
|
||||||
|
"`!poe2trade remove <filter_id | all>` — Removes a filter or all.\n"
|
||||||
|
"`!poe2trade list` — Shows your currently watched filters.\n"
|
||||||
|
"`!poe2trade pause` — Temporarily stop notifications for your filters.\n"
|
||||||
|
"`!poe2trade resume` — Resume notifications for your filters.\n"
|
||||||
|
"`!poe2trade set <key> <value>` — *(Owner only)* Change a system setting.\n"
|
||||||
|
"`!poe2trade settings` — *(Owner only)* View current settings.\n"
|
||||||
|
"`!poe2trade clearcache` — *(Owner only)* Clear current data cache\n\n"
|
||||||
|
"**How to use filters:**\n"
|
||||||
|
"Go to the official trade site: https://www.pathofexile.com/trade2/search/poe2/Standard\n"
|
||||||
|
"Apply the filters you want (stat filters, name, price, etc).\n"
|
||||||
|
"Copy the **filter ID** at the end of the URL.\n"
|
||||||
|
"Example: `https://www.pathofexile.com/trade2/search/poe2/Standard/Ezn0zLzf5`\n"
|
||||||
|
"Filter ID = `Ezn0zLzf5`.\n"
|
||||||
|
"Then run: `!poe2trade add Ezn0zLzf5`\n\n"
|
||||||
|
"**Important note**\n"
|
||||||
|
"Only the first 10 results from the filter is checked.\n"
|
||||||
|
"This means if you sort by eg. price, only the 10 cheapest results will be checked.\n"
|
||||||
|
"If you sort by a stat from highest to lowest on the other hand, only the 10 results \n"
|
||||||
|
"with the highest stat will be checked. So double-check your filter sorting!"
|
||||||
|
)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_poe2_updates(self, data):
|
||||||
|
EMBED_CHAR_LIMIT = 6000
|
||||||
|
MAX_EMBEDS_PER_MESSAGE = 10
|
||||||
|
|
||||||
|
for (user_id, filter_id), payload in data.items():
|
||||||
|
user = self.bot.get_user(int(user_id))
|
||||||
|
if not user:
|
||||||
|
logger.warning(f"User ID {user_id} not found in cache.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
embeds = []
|
||||||
|
current_char_count = 0
|
||||||
|
|
||||||
|
search_id = payload["search_id"]
|
||||||
|
items = payload["items"]
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
listing = item.get("listing", {})
|
||||||
|
item_data = item.get("item", {})
|
||||||
|
price_data = listing.get("price", {})
|
||||||
|
account_data = listing.get("account", {})
|
||||||
|
|
||||||
|
item_name = item_data.get("name") or item_data.get("typeLine", "Unknown Item")
|
||||||
|
item_type = item_data.get("typeLine", "Unknown")
|
||||||
|
full_name = item_name if item_name.strip().lower() == item_type.strip().lower() else f"{item_name} ({item_type})"
|
||||||
|
|
||||||
|
rarity = item_data.get("frameType", 2)
|
||||||
|
rarity_colors = {
|
||||||
|
0: discord.Color.light_grey(), # Normal
|
||||||
|
1: discord.Color.blue(), # Magic
|
||||||
|
2: discord.Color.gold(), # Rare
|
||||||
|
3: discord.Color.dark_orange(), # Unique
|
||||||
|
}
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=full_name,
|
||||||
|
url=f"https://www.pathofexile.com/trade2/search/poe2/Standard/{filter_id}",
|
||||||
|
color=rarity_colors.get(rarity, discord.Color.gold())
|
||||||
|
)
|
||||||
|
|
||||||
|
icon_url = item_data.get("icon")
|
||||||
|
if icon_url:
|
||||||
|
embed.set_thumbnail(url=icon_url)
|
||||||
|
|
||||||
|
amount = price_data.get("amount", "?")
|
||||||
|
currency = price_data.get("currency", "?")
|
||||||
|
if currency in CURRENCY_METADATA:
|
||||||
|
name = CURRENCY_METADATA[currency]["name"]
|
||||||
|
currency_field = f"{amount} × {name}"
|
||||||
|
else:
|
||||||
|
currency_field = f"{amount} {currency}"
|
||||||
|
|
||||||
|
embed.add_field(name="Price", value=currency_field, inline=True)
|
||||||
|
|
||||||
|
seller_name = account_data.get("name", "Unknown Seller")
|
||||||
|
seller_url = f"https://www.pathofexile.com/account/view-profile/{quote(seller_name)}"
|
||||||
|
embed.add_field(name="Seller", value=f"[{seller_name}]({seller_url})", inline=True)
|
||||||
|
embed.add_field(name="Item Level", value=str(item_data.get("ilvl", "?")), inline=True)
|
||||||
|
|
||||||
|
def clean_stat(stat: str) -> str:
|
||||||
|
stat = re.sub(r"\[.*?\|(.+?)\]", r"\1", stat)
|
||||||
|
stat = re.sub(r"\[(.+?)\]", r"\1", stat)
|
||||||
|
return stat
|
||||||
|
|
||||||
|
stats = []
|
||||||
|
if "implicitMods" in item["item"]:
|
||||||
|
stats.append("__**Implicit Mods**__")
|
||||||
|
stats.extend(item["item"]["implicitMods"])
|
||||||
|
if "explicitMods" in item["item"]:
|
||||||
|
stats.append("__**Explicit Mods**__")
|
||||||
|
stats.extend(item["item"]["explicitMods"])
|
||||||
|
if stats:
|
||||||
|
stats_str = "\n".join(clean_stat(stat) for stat in stats)
|
||||||
|
embed.add_field(name="Stats", value=stats_str, inline=False)
|
||||||
|
|
||||||
|
next_check = watcher.get_next_query_time(user_id, filter_id)
|
||||||
|
timestamp_str = f"\nNext check: ~<t:{next_check}:R>" if next_check else "Unknown"
|
||||||
|
embed.add_field(
|
||||||
|
name="*Remove from watchlist*:",
|
||||||
|
value=f"`!poe2trade remove {filter_id}`{timestamp_str}",
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_name = watcher.get_filter_name(str(user_id), filter_id)
|
||||||
|
footer_lines = ["New PoE 2 trade listing"]
|
||||||
|
if filter_name:
|
||||||
|
footer_lines.insert(0, f"Filter name: {filter_name}")
|
||||||
|
embed.set_footer(text="\n".join(footer_lines))
|
||||||
|
|
||||||
|
est_char_len = len(embed.title or "") + sum(len(f.name or "") + len(f.value or "") for f in embed.fields)
|
||||||
|
|
||||||
|
if (len(embeds) + 1 > MAX_EMBEDS_PER_MESSAGE) or (current_char_count + est_char_len > EMBED_CHAR_LIMIT):
|
||||||
|
await user.send(embeds=embeds)
|
||||||
|
embeds = []
|
||||||
|
current_char_count = 0
|
||||||
|
|
||||||
|
embeds.append(embed)
|
||||||
|
current_char_count += est_char_len
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to process or build embed for user {user_id}: {e}")
|
||||||
|
|
||||||
|
if embeds:
|
||||||
|
try:
|
||||||
|
await user.send(embeds=embeds)
|
||||||
|
logger.info(f"Sent {len(embeds)} embeds to user {user_id} for filter {filter_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send batched embeds to user {user_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_owner(self, user_id):
|
||||||
|
"""
|
||||||
|
Returns True or False depending on user ID comparison to owner ID
|
||||||
|
"""
|
||||||
|
owner_id = watcher.settings.get("OWNER_ID")
|
||||||
|
if str(user_id) != owner_id:
|
||||||
|
return False
|
||||||
|
elif str(user_id) == owner_id:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to verify author as owner! user_id: '{user_id}', owner_id: '{owner_id}'")
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot):
|
||||||
|
await bot.add_cog(PoE2TradeCog(bot))
|
|
@ -0,0 +1,41 @@
|
||||||
|
# cmd_discord/quote.py
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
import globals
|
||||||
|
|
||||||
|
from globals import logger
|
||||||
|
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 []
|
||||||
|
logger.debug(f"'quote' command initiated with arguments: {args}")
|
||||||
|
|
||||||
|
result = await cc.handle_quote_command(
|
||||||
|
db_conn=globals.init_db_conn,
|
||||||
|
is_discord=True,
|
||||||
|
ctx=ctx,
|
||||||
|
args=args,
|
||||||
|
game_name=None
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"'quote' result: {result}")
|
||||||
|
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,26 @@
|
||||||
|
# cmd_twitch/howl.py
|
||||||
|
from twitchio.ext import commands
|
||||||
|
from cmd_common import common_commands as cc
|
||||||
|
from modules.utility import is_channel_live
|
||||||
|
from modules.permissions import has_permission
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
user_roles = ctx.author.badges.keys() # Extract Twitch user badges
|
||||||
|
if not has_permission("howl", str(ctx.author.id), user_roles, "twitch", ctx.channel.name):
|
||||||
|
await ctx.reply(f"You don't have permission to use this command.")
|
||||||
|
return
|
||||||
|
|
||||||
|
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],
|
"discord_guilds": [896713616089309184],
|
||||||
|
"sync_commands_globally": true,
|
||||||
"twitch_channels": ["OokamiKunTV", "ookamipup"],
|
"twitch_channels": ["OokamiKunTV", "ookamipup"],
|
||||||
"command_modules": ["cmd_discord", "cmd_twitch", "cmd_common"],
|
"command_modules": ["cmd_discord", "cmd_twitch", "cmd_common"],
|
||||||
"log_levels": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"]
|
"logging": {
|
||||||
|
"log_levels": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"],
|
||||||
|
"logfile_path": "logfile.log",
|
||||||
|
"file": {
|
||||||
|
"log_to_file": true,
|
||||||
|
"log_debug": true,
|
||||||
|
"log_info": true,
|
||||||
|
"log_warning": true,
|
||||||
|
"log_error": true,
|
||||||
|
"log_critical": true,
|
||||||
|
"log_fatal": true
|
||||||
|
},
|
||||||
|
"terminal": {
|
||||||
|
"log_to_terminal": true,
|
||||||
|
"log_debug": false,
|
||||||
|
"log_info": true,
|
||||||
|
"log_warning": true,
|
||||||
|
"log_error": true,
|
||||||
|
"log_critical": true,
|
||||||
|
"log_fatal": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"use_mariadb": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,349 @@
|
||||||
|
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):
|
||||||
|
logger.fatal(f"Unable to read the settings file {file}!")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
return config_data
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.fatal(f"Error parsing {file}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
###############################
|
||||||
|
# Simple Logging System
|
||||||
|
###############################
|
||||||
|
class Logger:
|
||||||
|
"""
|
||||||
|
Custom logger class to handle different log levels, terminal & file output,
|
||||||
|
and system events (FATAL, RESTART, SHUTDOWN).
|
||||||
|
"""
|
||||||
|
|
||||||
|
allowed_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL", "RESTART", "SHUTDOWN"}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
Initializes the Logger with configurations from `config_data`.
|
||||||
|
"""
|
||||||
|
self.default_level = "INFO"
|
||||||
|
self.log_file_path = config_data["logging"]["logfile_path"]
|
||||||
|
self.current_log_file_path = f"cur_{self.log_file_path}"
|
||||||
|
self.log_to_terminal = config_data["logging"]["terminal"]["log_to_terminal"]
|
||||||
|
self.log_to_file = config_data["logging"]["file"]["log_to_file"]
|
||||||
|
self.enabled_log_levels = set(config_data["logging"]["log_levels"])
|
||||||
|
|
||||||
|
# Check if both logging outputs are disabled
|
||||||
|
if not self.log_to_terminal and not self.log_to_file:
|
||||||
|
print("!!! WARNING !!! LOGGING DISABLED !!! NO LOGS WILL BE PROVIDED !!!")
|
||||||
|
|
||||||
|
def log(self, message: str, level="INFO", exec_info=False, linebreaks=False):
|
||||||
|
"""
|
||||||
|
Logs a message at the specified log level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message (str): The message to log.
|
||||||
|
level (str, optional): Log level. Defaults to "INFO".
|
||||||
|
exec_info (bool, optional): Append traceback information if True. Defaults to False.
|
||||||
|
linebreaks (bool, optional): Preserve line breaks if True. Defaults to False.
|
||||||
|
"""
|
||||||
|
from modules.utility import format_uptime
|
||||||
|
|
||||||
|
level = level.upper()
|
||||||
|
if level not in self.allowed_levels:
|
||||||
|
level = self.default_level
|
||||||
|
|
||||||
|
# Capture calling function name
|
||||||
|
caller_name = self.get_caller_function()
|
||||||
|
|
||||||
|
# Remove line breaks if required
|
||||||
|
if not linebreaks:
|
||||||
|
message = message.replace("\n", " ")
|
||||||
|
|
||||||
|
# Timestamp & uptime
|
||||||
|
elapsed = time.time() - get_bot_start_time() # Assuming this function exists
|
||||||
|
uptime_str, _ = format_uptime(elapsed)
|
||||||
|
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# Format log message
|
||||||
|
log_message = f"[{timestamp} - {uptime_str}] [{level}] [Func: {caller_name}] {message}"
|
||||||
|
|
||||||
|
# Append traceback if needed
|
||||||
|
if exec_info or level in {"ERROR", "CRITICAL", "FATAL"}:
|
||||||
|
log_message += f"\n{traceback.format_exc()}"
|
||||||
|
|
||||||
|
# Log to terminal
|
||||||
|
self._log_to_terminal(log_message, level)
|
||||||
|
|
||||||
|
# Log to file
|
||||||
|
self._log_to_file(log_message, level)
|
||||||
|
|
||||||
|
# Handle special log levels
|
||||||
|
self._handle_special_levels(level)
|
||||||
|
|
||||||
|
def _log_to_terminal(self, log_message: str, level: str):
|
||||||
|
"""
|
||||||
|
Outputs log messages to the terminal if enabled.
|
||||||
|
"""
|
||||||
|
if self.log_to_terminal or level == "FATAL":
|
||||||
|
if config_data["logging"]["terminal"].get(f"log_{level.lower()}", False) or level == "FATAL":
|
||||||
|
print(log_message)
|
||||||
|
|
||||||
|
def _log_to_file(self, log_message: str, level: str):
|
||||||
|
"""
|
||||||
|
Writes log messages to a file if enabled.
|
||||||
|
"""
|
||||||
|
if self.log_to_file or level == "FATAL":
|
||||||
|
if config_data["logging"]["file"].get(f"log_{level.lower()}", False) or level == "FATAL":
|
||||||
|
try:
|
||||||
|
with open(self.log_file_path, "a", encoding="utf-8") as logfile:
|
||||||
|
logfile.write(f"{log_message}\n")
|
||||||
|
logfile.flush()
|
||||||
|
with open(self.current_log_file_path, "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}")
|
||||||
|
|
||||||
|
def _handle_special_levels(self, level: str):
|
||||||
|
"""
|
||||||
|
Handles special log levels like FATAL, RESTART, and 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 get_caller_function(self):
|
||||||
|
"""
|
||||||
|
Retrieves the calling function's name using `inspect`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
caller_frame = inspect.stack()[3]
|
||||||
|
return caller_frame.function
|
||||||
|
except Exception:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def reset_curlogfile(self):
|
||||||
|
"""
|
||||||
|
Clears the current log file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
open(self.current_log_file_path, "w").close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARNING] Failed to clear current-run logfile: {e}")
|
||||||
|
|
||||||
|
## Shorter, cleaner methods for each log level
|
||||||
|
def debug(self, message: str, exec_info=False, linebreaks=False):
|
||||||
|
"""
|
||||||
|
Debug message for development troubleshooting.
|
||||||
|
"""
|
||||||
|
self.log(message, "DEBUG", exec_info, linebreaks)
|
||||||
|
|
||||||
|
def info(self, message: str, exec_info=False, linebreaks=False):
|
||||||
|
"""
|
||||||
|
General informational messages.
|
||||||
|
"""
|
||||||
|
self.log(message, "INFO", exec_info, linebreaks)
|
||||||
|
|
||||||
|
def warning(self, message: str, exec_info=False, linebreaks=False):
|
||||||
|
"""
|
||||||
|
Warning messages.
|
||||||
|
Something unusal happened, but shouldn't affect the program overall.
|
||||||
|
"""
|
||||||
|
self.log(message, "WARNING", exec_info, linebreaks)
|
||||||
|
|
||||||
|
def error(self, message: str, exec_info=True, linebreaks=False):
|
||||||
|
"""
|
||||||
|
Error messages.
|
||||||
|
Something didn't execute properly, but the bot overall should manage.
|
||||||
|
"""
|
||||||
|
self.log(message, "ERROR", exec_info, linebreaks)
|
||||||
|
|
||||||
|
def critical(self, message: str, exec_info=True, linebreaks=False):
|
||||||
|
"""
|
||||||
|
Critical error messages.
|
||||||
|
Something happened that is very likely to cause unintended behaviour and potential crashes.
|
||||||
|
"""
|
||||||
|
self.log(message, "CRITICAL", exec_info, linebreaks)
|
||||||
|
|
||||||
|
def fatal(self, message: str, exec_info=True, linebreaks=False):
|
||||||
|
"""
|
||||||
|
Fatal error messages.
|
||||||
|
Something happened that requires the bot to shut down somewhat nicely.
|
||||||
|
"""
|
||||||
|
self.log(message, "FATAL", exec_info, linebreaks)
|
||||||
|
|
||||||
|
def restart(self, message: str):
|
||||||
|
"""
|
||||||
|
Indicate bot restart log event.
|
||||||
|
"""
|
||||||
|
self.log(message, "RESTART")
|
||||||
|
|
||||||
|
def shutdown(self, message: str):
|
||||||
|
"""
|
||||||
|
Indicate bot shutdown log event.
|
||||||
|
"""
|
||||||
|
self.log(message, "SHUTDOWN")
|
||||||
|
|
||||||
|
|
||||||
|
# Instantiate Logger globally
|
||||||
|
logger = Logger()
|
||||||
|
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
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.
|
||||||
|
logger.fatal("Terminating bot due to no DB connection.")
|
||||||
|
sys.exit(1)
|
||||||
|
return db_conn
|
||||||
|
except Exception as e:
|
||||||
|
logger.fatal(f"Unable to initialize database!: {e}")
|
||||||
|
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:
|
||||||
|
logger.info("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.
|
|
@ -0,0 +1,374 @@
|
||||||
|
{
|
||||||
|
"alt": {
|
||||||
|
"name": "Orb of Alteration",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxNYWdpYyIsInNjYWxlIjoxfV0/6308fc8ca2/CurrencyRerollMagic.png"
|
||||||
|
},
|
||||||
|
"fusing": {
|
||||||
|
"name": "Orb of Fusing",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXRMaW5rcyIsInNjYWxlIjoxfV0/c5e1959880/CurrencyRerollSocketLinks.png"
|
||||||
|
},
|
||||||
|
"alch": {
|
||||||
|
"name": "Orb of Alchemy",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlVG9SYXJlIiwic2NhbGUiOjF9XQ/0c72cd1d44/CurrencyUpgradeToRare.png"
|
||||||
|
},
|
||||||
|
"chaos": {
|
||||||
|
"name": "Chaos Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxSYXJlIiwic2NhbGUiOjF9XQ/46a2347805/CurrencyRerollRare.png"
|
||||||
|
},
|
||||||
|
"gcp": {
|
||||||
|
"name": "Gemcutter's Prism",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lHZW1RdWFsaXR5Iiwic2NhbGUiOjF9XQ/dbe9678a28/CurrencyGemQuality.png"
|
||||||
|
},
|
||||||
|
"exalted": {
|
||||||
|
"name": "Exalted Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBZGRNb2RUb1JhcmUiLCJzY2FsZSI6MX1d/33f2656aea/CurrencyAddModToRare.png"
|
||||||
|
},
|
||||||
|
"chrome": {
|
||||||
|
"name": "Chromatic Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXRDb2xvdXJzIiwic2NhbGUiOjF9XQ/19c8ddae20/CurrencyRerollSocketColours.png"
|
||||||
|
},
|
||||||
|
"jewellers": {
|
||||||
|
"name": "Jeweller's Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXROdW1iZXJzIiwic2NhbGUiOjF9XQ/ba411ff58a/CurrencyRerollSocketNumbers.png"
|
||||||
|
},
|
||||||
|
"engineers": {
|
||||||
|
"name": "Engineer's Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRW5naW5lZXJzT3JiIiwic2NhbGUiOjF9XQ/114b671d41/EngineersOrb.png"
|
||||||
|
},
|
||||||
|
"infused-engineers-orb": {
|
||||||
|
"name": "Infused Engineer's Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mdXNlZEVuZ2luZWVyc09yYiIsInNjYWxlIjoxfV0/55774baf2f/InfusedEngineersOrb.png"
|
||||||
|
},
|
||||||
|
"chance": {
|
||||||
|
"name": "Orb of Chance",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlUmFuZG9tbHkiLCJzY2FsZSI6MX1d/a3f9bf0917/CurrencyUpgradeRandomly.png"
|
||||||
|
},
|
||||||
|
"chisel": {
|
||||||
|
"name": "Cartographer's Chisel",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lNYXBRdWFsaXR5Iiwic2NhbGUiOjF9XQ/0246313b99/CurrencyMapQuality.png"
|
||||||
|
},
|
||||||
|
"scour": {
|
||||||
|
"name": "Orb of Scouring",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lDb252ZXJ0VG9Ob3JtYWwiLCJzY2FsZSI6MX1d/a0981d67fe/CurrencyConvertToNormal.png"
|
||||||
|
},
|
||||||
|
"blessed": {
|
||||||
|
"name": "Blessed Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJbXBsaWNpdE1vZCIsInNjYWxlIjoxfV0/48e700cc20/CurrencyImplicitMod.png"
|
||||||
|
},
|
||||||
|
"regret": {
|
||||||
|
"name": "Orb of Regret",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lQYXNzaXZlU2tpbGxSZWZ1bmQiLCJzY2FsZSI6MX1d/32d499f562/CurrencyPassiveSkillRefund.png"
|
||||||
|
},
|
||||||
|
"regal": {
|
||||||
|
"name": "Regal Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlTWFnaWNUb1JhcmUiLCJzY2FsZSI6MX1d/0ded706f57/CurrencyUpgradeMagicToRare.png"
|
||||||
|
},
|
||||||
|
"divine": {
|
||||||
|
"name": "Divine Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lNb2RWYWx1ZXMiLCJzY2FsZSI6MX1d/ec48896769/CurrencyModValues.png"
|
||||||
|
},
|
||||||
|
"vaal": {
|
||||||
|
"name": "Vaal Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lWYWFsIiwic2NhbGUiOjF9XQ/593fe2e22e/CurrencyVaal.png"
|
||||||
|
},
|
||||||
|
"annul": {
|
||||||
|
"name": "Orb of Annulment",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5udWxsT3JiIiwic2NhbGUiOjF9XQ/0858a418ac/AnnullOrb.png"
|
||||||
|
},
|
||||||
|
"orb-of-binding": {
|
||||||
|
"name": "Orb of Binding",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQmluZGluZ09yYiIsInNjYWxlIjoxfV0/aac9579bd2/BindingOrb.png"
|
||||||
|
},
|
||||||
|
"ancient-orb": {
|
||||||
|
"name": "Ancient Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5jaWVudE9yYiIsInNjYWxlIjoxfV0/83015d0dc9/AncientOrb.png"
|
||||||
|
},
|
||||||
|
"orb-of-horizons": {
|
||||||
|
"name": "Orb of Horizons",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSG9yaXpvbk9yYiIsInNjYWxlIjoxfV0/0891338fb0/HorizonOrb.png"
|
||||||
|
},
|
||||||
|
"harbingers-orb": {
|
||||||
|
"name": "Harbinger's Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFyYmluZ2VyT3JiIiwic2NhbGUiOjF9XQ/0a26e01f15/HarbingerOrb.png"
|
||||||
|
},
|
||||||
|
"fracturing-orb": {
|
||||||
|
"name": "Fracturing Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRnJhY3R1cmluZ09yYkNvbWJpbmVkIiwic2NhbGUiOjF9XQ/3fb18e8a5b/FracturingOrbCombined.png"
|
||||||
|
},
|
||||||
|
"wisdom": {
|
||||||
|
"name": "Scroll of Wisdom",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJZGVudGlmaWNhdGlvbiIsInNjYWxlIjoxfV0/c2d03ed3fd/CurrencyIdentification.png"
|
||||||
|
},
|
||||||
|
"portal": {
|
||||||
|
"name": "Portal Scroll",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lQb3J0YWwiLCJzY2FsZSI6MX1d/d92d3478a0/CurrencyPortal.png"
|
||||||
|
},
|
||||||
|
"scrap": {
|
||||||
|
"name": "Armourer's Scrap",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBcm1vdXJRdWFsaXR5Iiwic2NhbGUiOjF9XQ/fc4e26afbc/CurrencyArmourQuality.png"
|
||||||
|
},
|
||||||
|
"whetstone": {
|
||||||
|
"name": "Blacksmith's Whetstone",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lXZWFwb25RdWFsaXR5Iiwic2NhbGUiOjF9XQ/c9cd72719e/CurrencyWeaponQuality.png"
|
||||||
|
},
|
||||||
|
"bauble": {
|
||||||
|
"name": "Glassblower's Bauble",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lGbGFza1F1YWxpdHkiLCJzY2FsZSI6MX1d/59e57027e5/CurrencyFlaskQuality.png"
|
||||||
|
},
|
||||||
|
"transmute": {
|
||||||
|
"name": "Orb of Transmutation",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlVG9NYWdpYyIsInNjYWxlIjoxfV0/ded9e8ee63/CurrencyUpgradeToMagic.png"
|
||||||
|
},
|
||||||
|
"aug": {
|
||||||
|
"name": "Orb of Augmentation",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBZGRNb2RUb01hZ2ljIiwic2NhbGUiOjF9XQ/d879c15321/CurrencyAddModToMagic.png"
|
||||||
|
},
|
||||||
|
"mirror": {
|
||||||
|
"name": "Mirror of Kalandra",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lEdXBsaWNhdGUiLCJzY2FsZSI6MX1d/8d7fea29d1/CurrencyDuplicate.png"
|
||||||
|
},
|
||||||
|
"eternal": {
|
||||||
|
"name": "Eternal Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJbXByaW50T3JiIiwic2NhbGUiOjF9XQ/49500c70ba/CurrencyImprintOrb.png"
|
||||||
|
},
|
||||||
|
"rogues-marker": {
|
||||||
|
"name": "Rogue's Marker",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVpc3QvSGVpc3RDb2luQ3VycmVuY3kiLCJzY2FsZSI6MX1d/335e66630d/HeistCoinCurrency.png"
|
||||||
|
},
|
||||||
|
"facetors": {
|
||||||
|
"name": "Facetor's Lens",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lHZW1FeHBlcmllbmNlIiwic2NhbGUiOjF9XQ/7011b1ed48/CurrencyGemExperience.png"
|
||||||
|
},
|
||||||
|
"tempering-orb": {
|
||||||
|
"name": "Tempering Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRGl2aW5lRW5jaGFudEJvZHlBcm1vdXJDdXJyZW5jeSIsInNjYWxlIjoxfV0/37681eda1c/DivineEnchantBodyArmourCurrency.png"
|
||||||
|
},
|
||||||
|
"tailoring-orb": {
|
||||||
|
"name": "Tailoring Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRGl2aW5lRW5jaGFudFdlYXBvbkN1cnJlbmN5Iiwic2NhbGUiOjF9XQ/d417654a23/DivineEnchantWeaponCurrency.png"
|
||||||
|
},
|
||||||
|
"orb-of-unmaking": {
|
||||||
|
"name": "Orb of Unmaking",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVncmV0T3JiIiwic2NhbGUiOjF9XQ/beae1b00c7/RegretOrb.png"
|
||||||
|
},
|
||||||
|
"veiled-orb": {
|
||||||
|
"name": "Veiled Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVmVpbGVkQ2hhb3NPcmIiLCJzY2FsZSI6MX1d/fd913b89d0/VeiledChaosOrb.png"
|
||||||
|
},
|
||||||
|
"enkindling-orb": {
|
||||||
|
"name": "Enkindling Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhwZWRpdGlvbi9GbGFza1BsYXRlIiwic2NhbGUiOjF9XQ/7c1a584a8d/FlaskPlate.png"
|
||||||
|
},
|
||||||
|
"instilling-orb": {
|
||||||
|
"name": "Instilling Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhwZWRpdGlvbi9GbGFza0luamVjdG9yIiwic2NhbGUiOjF9XQ/efc518b1be/FlaskInjector.png"
|
||||||
|
},
|
||||||
|
"sacred-orb": {
|
||||||
|
"name": "Sacred Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvU2FjcmVkT3JiIiwic2NhbGUiOjF9XQ/0380fd0dba/SacredOrb.png"
|
||||||
|
},
|
||||||
|
"stacked-deck": {
|
||||||
|
"name": "Stacked Deck",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvRGl2aW5hdGlvbi9EZWNrIiwic2NhbGUiOjF9XQ/8e83aea79a/Deck.png"
|
||||||
|
},
|
||||||
|
"veiled-scarab": {
|
||||||
|
"name": "Veiled Scarab",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvU2NhcmFicy9TdGFja2VkU2NhcmFiIiwic2NhbGUiOjF9XQ/4674c86ff2/StackedScarab.png"
|
||||||
|
},
|
||||||
|
"crusaders-exalted-orb": {
|
||||||
|
"name": "Crusader's Exalted Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9DcnVzYWRlck9yYiIsInNjYWxlIjoxfV0/8b48230188/CrusaderOrb.png"
|
||||||
|
},
|
||||||
|
"redeemers-exalted-orb": {
|
||||||
|
"name": "Redeemer's Exalted Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9FeXJpZU9yYiIsInNjYWxlIjoxfV0/8ec9b52d65/EyrieOrb.png"
|
||||||
|
},
|
||||||
|
"hunters-exalted-orb": {
|
||||||
|
"name": "Hunter's Exalted Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9CYXNpbGlza09yYiIsInNjYWxlIjoxfV0/cd2131d564/BasiliskOrb.png"
|
||||||
|
},
|
||||||
|
"warlords-exalted-orb": {
|
||||||
|
"name": "Warlord's Exalted Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9Db25xdWVyb3JPcmIiLCJzY2FsZSI6MX1d/57f0d85951/ConquerorOrb.png"
|
||||||
|
},
|
||||||
|
"awakeners-orb": {
|
||||||
|
"name": "Awakener's Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVHJhbnNmZXJPcmIiLCJzY2FsZSI6MX1d/f3b1c1566f/TransferOrb.png"
|
||||||
|
},
|
||||||
|
"mavens-orb": {
|
||||||
|
"name": "Orb of Dominance",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5PcmIiLCJzY2FsZSI6MX1d/f307d80bfd/MavenOrb.png"
|
||||||
|
},
|
||||||
|
"eldritch-chaos-orb": {
|
||||||
|
"name": "Eldritch Chaos Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hDaGFvc09yYiIsInNjYWxlIjoxfV0/98091fc653/EldritchChaosOrb.png"
|
||||||
|
},
|
||||||
|
"eldritch-exalted-orb": {
|
||||||
|
"name": "Eldritch Exalted Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hFeGFsdGVkT3JiIiwic2NhbGUiOjF9XQ/2da131e652/EldritchExaltedOrb.png"
|
||||||
|
},
|
||||||
|
"eldritch-orb-of-annulment": {
|
||||||
|
"name": "Eldritch Orb of Annulment",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hBbm51bG1lbnRPcmIiLCJzY2FsZSI6MX1d/b58add03eb/EldritchAnnulmentOrb.png"
|
||||||
|
},
|
||||||
|
"lesser-eldritch-ember": {
|
||||||
|
"name": "Lesser Eldritch Ember",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmsxIiwic2NhbGUiOjF9XQ/c7df0e0316/CleansingFireOrbRank1.png"
|
||||||
|
},
|
||||||
|
"greater-eldritch-ember": {
|
||||||
|
"name": "Greater Eldritch Ember",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmsyIiwic2NhbGUiOjF9XQ/698817b93d/CleansingFireOrbRank2.png"
|
||||||
|
},
|
||||||
|
"grand-eldritch-ember": {
|
||||||
|
"name": "Grand Eldritch Ember",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmszIiwic2NhbGUiOjF9XQ/0486f1ac82/CleansingFireOrbRank3.png"
|
||||||
|
},
|
||||||
|
"exceptional-eldritch-ember": {
|
||||||
|
"name": "Exceptional Eldritch Ember",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbms0Iiwic2NhbGUiOjF9XQ/c2c828fa16/CleansingFireOrbRank4.png"
|
||||||
|
},
|
||||||
|
"lesser-eldritch-ichor": {
|
||||||
|
"name": "Lesser Eldritch Ichor",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazEiLCJzY2FsZSI6MX1d/70e5e53590/TangleOrbRank1.png"
|
||||||
|
},
|
||||||
|
"greater-eldritch-ichor": {
|
||||||
|
"name": "Greater Eldritch Ichor",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazIiLCJzY2FsZSI6MX1d/689d1897c7/TangleOrbRank2.png"
|
||||||
|
},
|
||||||
|
"grand-eldritch-ichor": {
|
||||||
|
"name": "Grand Eldritch Ichor",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazMiLCJzY2FsZSI6MX1d/199f3b36f3/TangleOrbRank3.png"
|
||||||
|
},
|
||||||
|
"exceptional-eldritch-ichor": {
|
||||||
|
"name": "Exceptional Eldritch Ichor",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazQiLCJzY2FsZSI6MX1d/dcf73ecd8e/TangleOrbRank4.png"
|
||||||
|
},
|
||||||
|
"orb-of-conflict": {
|
||||||
|
"name": "Orb of Conflict",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ29uZmxpY3RPcmJSYW5rMSIsInNjYWxlIjoxfV0/7e02c990fc/ConflictOrbRank1.png"
|
||||||
|
},
|
||||||
|
"tainted-chromatic-orb": {
|
||||||
|
"name": "Tainted Chromatic Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUNocm9tYXRpY09yYiIsInNjYWxlIjoxfV0/702d29c7ab/HellscapeChromaticOrb.png"
|
||||||
|
},
|
||||||
|
"tainted-orb-of-fusing": {
|
||||||
|
"name": "Tainted Orb of Fusing",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZU9yYk9mRnVzaW5nIiwic2NhbGUiOjF9XQ/845f3c20ed/HellscapeOrbOfFusing.png"
|
||||||
|
},
|
||||||
|
"tainted-jewellers-orb": {
|
||||||
|
"name": "Tainted Jeweller's Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUpld2VsbGVyc09yYiIsInNjYWxlIjoxfV0/f146c29db2/HellscapeJewellersOrb.png"
|
||||||
|
},
|
||||||
|
"tainted-chaos-orb": {
|
||||||
|
"name": "Tainted Chaos Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUNoYW9zT3JiIiwic2NhbGUiOjF9XQ/64d1f4db99/HellscapeChaosOrb.png"
|
||||||
|
},
|
||||||
|
"tainted-exalted-orb": {
|
||||||
|
"name": "Tainted Exalted Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUV4YWx0ZWRPcmIiLCJzY2FsZSI6MX1d/68a0ea3020/HellscapeExaltedOrb.png"
|
||||||
|
},
|
||||||
|
"tainted-mythic-orb": {
|
||||||
|
"name": "Tainted Mythic Orb",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZU15dGhpY09yYiIsInNjYWxlIjoxfV0/72ba97d1a8/HellscapeMythicOrb.png"
|
||||||
|
},
|
||||||
|
"tainted-armourers-scrap": {
|
||||||
|
"name": "Tainted Armourer's Scrap",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUFybW91cmVyc1NjcmFwIiwic2NhbGUiOjF9XQ/9ee0a10625/HellscapeArmourersScrap.png"
|
||||||
|
},
|
||||||
|
"tainted-blacksmiths-whetstone": {
|
||||||
|
"name": "Tainted Blacksmith's Whetstone",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUJsYWNrc21pdGhXaGV0c3RvbmUiLCJzY2FsZSI6MX1d/0309648ccb/HellscapeBlacksmithWhetstone.png"
|
||||||
|
},
|
||||||
|
"tainted-divine-teardrop": {
|
||||||
|
"name": "Tainted Divine Teardrop",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZVRlYXJkcm9wT3JiIiwic2NhbGUiOjF9XQ/0d251b9d52/HellscapeTeardropOrb.png"
|
||||||
|
},
|
||||||
|
"wild-lifeforce": {
|
||||||
|
"name": "Wild Crystallised Lifeforce",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9XaWxkTGlmZWZvcmNlIiwic2NhbGUiOjF9XQ/e3d0b372b0/WildLifeforce.png"
|
||||||
|
},
|
||||||
|
"vivid-lifeforce": {
|
||||||
|
"name": "Vivid Crystallised Lifeforce",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9WaXZpZExpZmVmb3JjZSIsInNjYWxlIjoxfV0/a355b8a5a2/VividLifeforce.png"
|
||||||
|
},
|
||||||
|
"primal-lifeforce": {
|
||||||
|
"name": "Primal Crystallised Lifeforce",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9QcmltYWxMaWZlZm9yY2UiLCJzY2FsZSI6MX1d/c498cdfd7f/PrimalLifeforce.png"
|
||||||
|
},
|
||||||
|
"sacred-lifeforce": {
|
||||||
|
"name": "Sacred Crystallised Lifeforce",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9TYWNyZWRMaWZlZm9yY2UiLCJzY2FsZSI6MX1d/edfba3c893/SacredLifeforce.png"
|
||||||
|
},
|
||||||
|
"hinekoras-lock": {
|
||||||
|
"name": "Hinekora's Lock",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGluZWtvcmFzTG9jayIsInNjYWxlIjoxfV0/b188026e7f/HinekorasLock.png"
|
||||||
|
},
|
||||||
|
"mavens-chisel-of-procurement": {
|
||||||
|
"name": "Maven's Chisel of Procurement",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwxIiwic2NhbGUiOjF9XQ/a21d7d73da/MavenChisel1.png"
|
||||||
|
},
|
||||||
|
"mavens-chisel-of-proliferation": {
|
||||||
|
"name": "Maven's Chisel of Proliferation",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwyIiwic2NhbGUiOjF9XQ/bb82bb4150/MavenChisel2.png"
|
||||||
|
},
|
||||||
|
"mavens-chisel-of-divination": {
|
||||||
|
"name": "Maven's Chisel of Divination",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWw1Iiwic2NhbGUiOjF9XQ/ff3e7f02eb/MavenChisel5.png"
|
||||||
|
},
|
||||||
|
"mavens-chisel-of-scarabs": {
|
||||||
|
"name": "Maven's Chisel of Scarabs",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwzIiwic2NhbGUiOjF9XQ/a7a9ac8f01/MavenChisel3.png"
|
||||||
|
},
|
||||||
|
"mavens-chisel-of-avarice": {
|
||||||
|
"name": "Maven's Chisel of Avarice",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWw0Iiwic2NhbGUiOjF9XQ/d878502dc7/MavenChisel4.png"
|
||||||
|
},
|
||||||
|
"reflecting-mist": {
|
||||||
|
"name": "Reflecting Mist",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVmbGVjdGl2ZU1pc3QiLCJzY2FsZSI6MX1d/26956f795e/ReflectiveMist.png"
|
||||||
|
},
|
||||||
|
"chaos-shard": {
|
||||||
|
"name": "Chaos Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2hhb3NTaGFyZCIsInNjYWxlIjoxfV0/db7041e193/ChaosShard.png"
|
||||||
|
},
|
||||||
|
"exalted-shard": {
|
||||||
|
"name": "Exalted Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhhbHRlZFNoYXJkIiwic2NhbGUiOjF9XQ/b9e4013af5/ExaltedShard.png"
|
||||||
|
},
|
||||||
|
"engineers-shard": {
|
||||||
|
"name": "Engineer's Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRW5naW5lZXJzU2hhcmQiLCJzY2FsZSI6MX1d/9fe1384ff9/EngineersShard.png"
|
||||||
|
},
|
||||||
|
"regal-shard": {
|
||||||
|
"name": "Regal Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVnYWxTaGFyZCIsInNjYWxlIjoxfV0/6f7fc44a91/RegalShard.png"
|
||||||
|
},
|
||||||
|
"annulment-shard": {
|
||||||
|
"name": "Annulment Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5udWxsU2hhcmQiLCJzY2FsZSI6MX1d/1cf9962d97/AnnullShard.png"
|
||||||
|
},
|
||||||
|
"binding-shard": {
|
||||||
|
"name": "Binding Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQmluZGluZ1NoYXJkIiwic2NhbGUiOjF9XQ/569d09ac86/BindingShard.png"
|
||||||
|
},
|
||||||
|
"ancient-shard": {
|
||||||
|
"name": "Ancient Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5jaWVudFNoYXJkIiwic2NhbGUiOjF9XQ/3695589639/AncientShard.png"
|
||||||
|
},
|
||||||
|
"horizon-shard": {
|
||||||
|
"name": "Horizon Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSG9yaXpvblNoYXJkIiwic2NhbGUiOjF9XQ/627ae5f273/HorizonShard.png"
|
||||||
|
},
|
||||||
|
"harbingers-shard": {
|
||||||
|
"name": "Harbinger's Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFyYmluZ2VyU2hhcmQiLCJzY2FsZSI6MX1d/ad19e27b2f/HarbingerShard.png"
|
||||||
|
},
|
||||||
|
"fracturing-shard": {
|
||||||
|
"name": "Fracturing Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRnJhY3R1cmluZ09yYlNoYXJkIiwic2NhbGUiOjF9XQ/34fdc6a813/FracturingOrbShard.png"
|
||||||
|
},
|
||||||
|
"mirror-shard": {
|
||||||
|
"name": "Mirror Shard",
|
||||||
|
"icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWlycm9yU2hhcmQiLCJzY2FsZSI6MX1d/698183ea2b/MirrorShard.png"
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,236 @@
|
||||||
|
# modules/permissions.py
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import globals
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from globals import logger
|
||||||
|
|
||||||
|
PERMISSIONS_FILE = "permissions.json"
|
||||||
|
TWITCH_CONFIG_FILE = "settings/twitch_channels_config.json"
|
||||||
|
|
||||||
|
##########################
|
||||||
|
# Load Configurations
|
||||||
|
##########################
|
||||||
|
|
||||||
|
def load_json_file(file_path):
|
||||||
|
"""Loads JSON data from a file, returns an empty dict if missing or invalid."""
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as file:
|
||||||
|
return json.load(file)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Error parsing JSON file {file_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def load_permissions():
|
||||||
|
"""Dynamically loads permissions from `permissions.json`."""
|
||||||
|
return load_json_file(PERMISSIONS_FILE)
|
||||||
|
|
||||||
|
def load_twitch_config():
|
||||||
|
"""Dynamically loads Twitch-specific command allow/deny lists."""
|
||||||
|
return load_json_file(TWITCH_CONFIG_FILE)
|
||||||
|
|
||||||
|
##########################
|
||||||
|
# Role Mapping
|
||||||
|
##########################
|
||||||
|
|
||||||
|
def map_roles(platform: str, user_roles: list, context_identifier: str = None) -> list:
|
||||||
|
"""
|
||||||
|
Maps platform-specific roles to a unified role system.
|
||||||
|
Supports per-guild (Discord) and per-channel (Twitch) overrides.
|
||||||
|
|
||||||
|
:param platform: "discord" or "twitch"
|
||||||
|
:param user_roles: List of raw roles/badges from the platform
|
||||||
|
:param context_identifier: Guild ID (for Discord) or Channel Name (for Twitch)
|
||||||
|
:return: List of mapped roles
|
||||||
|
"""
|
||||||
|
permissions = load_permissions()
|
||||||
|
role_mappings = permissions.get("role_mappings", {}).get(platform, {})
|
||||||
|
|
||||||
|
# Allow per-context overrides
|
||||||
|
if context_identifier:
|
||||||
|
specific_mappings = permissions.get("role_mappings", {}).get(f"{platform}_{context_identifier}", {})
|
||||||
|
role_mappings.update(specific_mappings) # Override defaults
|
||||||
|
|
||||||
|
mapped_roles = [role_mappings.get(role.lower(), role.lower()) for role in user_roles]
|
||||||
|
|
||||||
|
return list(set(mapped_roles)) if mapped_roles else ["everyone"]
|
||||||
|
|
||||||
|
##########################
|
||||||
|
# Permissions Checks
|
||||||
|
##########################
|
||||||
|
|
||||||
|
def has_permission(command_name: str, user_id: str, user_roles: list, platform: str, context_identifier: str = None) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if a user has permission to execute a command.
|
||||||
|
|
||||||
|
:param command_name: The command to check
|
||||||
|
:param user_id: The user's ID
|
||||||
|
:param user_roles: The user's roles/badges
|
||||||
|
:param platform: "discord" or "twitch"
|
||||||
|
:param context_identifier: Guild ID (for Discord) or Channel Name (for Twitch)
|
||||||
|
:return: True if the user has permission, False otherwise
|
||||||
|
"""
|
||||||
|
permissions = load_permissions()
|
||||||
|
command_perms = permissions.get("commands", {}).get(command_name, {})
|
||||||
|
|
||||||
|
# Extract permission settings
|
||||||
|
min_role = command_perms.get("min_role", "")
|
||||||
|
allowed_roles = command_perms.get("allowed_roles", [])
|
||||||
|
allowed_users = command_perms.get("allowed_users", [])
|
||||||
|
|
||||||
|
# Auto-allow if no specific rules exist
|
||||||
|
if not min_role and not allowed_roles and not allowed_users:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Explicit user whitelist
|
||||||
|
if user_id in allowed_users:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Convert platform roles
|
||||||
|
mapped_roles = map_roles(platform, user_roles, context_identifier)
|
||||||
|
|
||||||
|
# Check minimum required role
|
||||||
|
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 explicitly allowed roles
|
||||||
|
if any(role in allowed_roles for role in mapped_roles):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
##########################
|
||||||
|
# Twitch Command Filtering
|
||||||
|
##########################
|
||||||
|
|
||||||
|
def is_command_allowed_twitch(command_name: str, channel_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if a command is allowed in a specific Twitch channel.
|
||||||
|
|
||||||
|
:param command_name: The command being checked
|
||||||
|
:param channel_name: The Twitch channel name
|
||||||
|
:return: True if allowed, False if blocked
|
||||||
|
"""
|
||||||
|
twitch_config = load_twitch_config()
|
||||||
|
channel_config = twitch_config.get(channel_name.lower(), {})
|
||||||
|
|
||||||
|
if not channel_config:
|
||||||
|
return False # Default to deny if no config exists
|
||||||
|
|
||||||
|
mode = channel_config.get("commands_filter_mode", "exclude")
|
||||||
|
filtered_commands = channel_config.get("commands_filtered", [])
|
||||||
|
|
||||||
|
if mode == "exclude" and command_name in filtered_commands:
|
||||||
|
return False
|
||||||
|
if mode == "include" and command_name not in filtered_commands:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# modules/permissions.py
|
||||||
|
|
||||||
|
# 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
|
1041
modules/utility.py
1041
modules/utility.py
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"special_vip_command": {
|
||||||
|
"min_role": "",
|
||||||
|
"allowed_roles": ["broadcaster", "vip"]
|
||||||
|
},
|
||||||
|
"owner_only": {
|
||||||
|
"min_role": "owner",
|
||||||
|
"allowed_roles": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,3 +11,4 @@ twitchio==2.7.1 # Twitch chat bot library (async)
|
||||||
|
|
||||||
# Utility & Logging
|
# Utility & Logging
|
||||||
aiohttp==3.9.1 # Async HTTP requests (dependency for discord.py & twitchio)
|
aiohttp==3.9.1 # Async HTTP requests (dependency for discord.py & twitchio)
|
||||||
|
regex==2024.11.6 # REGular EXpressions
|
||||||
|
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,704 @@
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# --- Base64 encode/decode helpers ---
|
||||||
|
def encrypt_token(token: str) -> str:
|
||||||
|
return base64.b64encode(token.encode()).decode()
|
||||||
|
|
||||||
|
def decrypt_token(encoded: str) -> str:
|
||||||
|
return base64.b64decode(encoded.encode()).decode()
|
||||||
|
|
||||||
|
class PoeTradeWatcher:
|
||||||
|
"""
|
||||||
|
A watcher class for managing Path of Exile trade filters, querying updates,
|
||||||
|
and notifying clients of new item listings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
Initializes the watcher with default settings and loads persistent state.
|
||||||
|
"""
|
||||||
|
self._poll_task = None
|
||||||
|
self._on_update_callback = None
|
||||||
|
self.logger = self._init_logger()
|
||||||
|
|
||||||
|
self.BASE_URL = "https://www.pathofexile.com/api/trade2"
|
||||||
|
self.LEAGUE = "Standard"
|
||||||
|
self.HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.COOKIES = {}
|
||||||
|
self.settings = {
|
||||||
|
"AUTO_POLL_INTERVAL": 300,
|
||||||
|
"MAX_SAFE_RESULTS": 100,
|
||||||
|
"USER_ADD_INTERVAL": 60,
|
||||||
|
"MAX_FILTERS_PER_USER": 5,
|
||||||
|
"RATE_LIMIT_INTERVAL": 90,
|
||||||
|
"POESESSID": "",
|
||||||
|
"OWNER_ID": "203190147582394369",
|
||||||
|
"LEGACY_WEBHOOK_URL": "https://discord.com/api/webhooks/1354003262709305364/afkTjeXcu1bfZXsQzFl-QqSb3R1MmQ4hdZhosR3vm4I__QVEyZ0jO9cqndUTQwb1mt5Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.dynamic_rate_limit = self.settings.get("RATE_LIMIT_INTERVAL", 90)
|
||||||
|
base_path = Path(__file__).resolve().parent.parent / "local_storage"
|
||||||
|
base_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.storage_file = base_path / "poe_trade_state.json"
|
||||||
|
self.log_file = base_path / "poe_trade.log"
|
||||||
|
|
||||||
|
self.watchlists = defaultdict(list)
|
||||||
|
self.last_seen_items = defaultdict(set)
|
||||||
|
self.last_add_time = defaultdict(lambda: 0)
|
||||||
|
self.last_api_time = 0
|
||||||
|
self.session_valid = True
|
||||||
|
self.filter_names = defaultdict(dict) # {user_id: {filter_id: custom_name}}
|
||||||
|
self.last_query_time = defaultdict(dict) # user_id -> filter_id -> timestamp
|
||||||
|
|
||||||
|
self.paused_users = set()
|
||||||
|
|
||||||
|
self.verification_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
self._load_state()
|
||||||
|
asyncio.create_task(self._validate_session())
|
||||||
|
asyncio.create_task(self._start_verification_worker())
|
||||||
|
asyncio.create_task(self.start_auto_poll())
|
||||||
|
|
||||||
|
|
||||||
|
def _init_logger(self):
|
||||||
|
logger = logging.getLogger("PoeTradeWatcher")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
log_path = Path(__file__).resolve().parent.parent / "local_storage" / "poe_trade.log"
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||||
|
formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def should_notify(self, user_id, filter_id, item_id, price, currency) -> bool:
|
||||||
|
try:
|
||||||
|
entry = self.last_seen_items[user_id][filter_id][item_id]
|
||||||
|
return entry.get("price") != price or entry.get("currency") != currency
|
||||||
|
except KeyError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def mark_seen(self, user_id, filter_id, item_id, price, currency):
|
||||||
|
self.last_seen_items.setdefault(user_id, {}).setdefault(filter_id, {})[item_id] = {
|
||||||
|
"price": price,
|
||||||
|
"currency": currency
|
||||||
|
}
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
|
||||||
|
async def start_auto_poll(self):
|
||||||
|
"""
|
||||||
|
Starts a rolling polling system where each user/filter is queried one at a time.
|
||||||
|
Automatically adjusts wait time based on rate limit headers.
|
||||||
|
"""
|
||||||
|
if self._poll_task is not None:
|
||||||
|
return # Already running
|
||||||
|
|
||||||
|
async def poll_loop():
|
||||||
|
base_delay = self.settings.get("RATE_LIMIT_INTERVAL", 90)
|
||||||
|
safety_margin = 2
|
||||||
|
|
||||||
|
while True:
|
||||||
|
for user_id, filters in self.watchlists.items():
|
||||||
|
if str(user_id) in self.paused_users:
|
||||||
|
self.logger.debug(f"Skipping paused user {user_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for filter_id in filters:
|
||||||
|
try:
|
||||||
|
# Always wait at least base_delay
|
||||||
|
delay = max(base_delay, self.dynamic_rate_limit) + safety_margin
|
||||||
|
|
||||||
|
result = await self.query_single(user_id, filter_id)
|
||||||
|
|
||||||
|
if result == "ratelimited":
|
||||||
|
# dynamic_rate_limit has already been adjusted within query_single
|
||||||
|
delay = max(base_delay, self.dynamic_rate_limit) + safety_margin
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Failed query for {filter_id} of user {user_id}: {e}")
|
||||||
|
delay = base_delay + safety_margin
|
||||||
|
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
|
||||||
|
self._poll_task = asyncio.create_task(poll_loop())
|
||||||
|
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
"""
|
||||||
|
Pauses the auto-polling loop if it's currently running.
|
||||||
|
"""
|
||||||
|
if self._poll_task:
|
||||||
|
self._poll_task.cancel()
|
||||||
|
self._poll_task = None
|
||||||
|
self.logger.info("Auto-polling paused.")
|
||||||
|
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
"""
|
||||||
|
Resumes auto-polling if it was previously paused.
|
||||||
|
"""
|
||||||
|
if self._poll_task is None:
|
||||||
|
asyncio.create_task(self.start_auto_poll())
|
||||||
|
self.logger.info("Auto-polling resumed.")
|
||||||
|
|
||||||
|
|
||||||
|
def set_update_callback(self, callback):
|
||||||
|
"""
|
||||||
|
Sets the callback function that will be triggered when new results are found during polling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback (Callable): An async function that receives a dict of new results.
|
||||||
|
"""
|
||||||
|
self._on_update_callback = callback
|
||||||
|
|
||||||
|
|
||||||
|
async def set_setting(self, key, value, force: bool = False):
|
||||||
|
"""
|
||||||
|
Updates a configuration setting and persists the state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): Setting name to update.
|
||||||
|
value (Any): New value for the setting.
|
||||||
|
force (bool): Allows overriding even admin-only settings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result with status and current setting value.
|
||||||
|
"""
|
||||||
|
result = self._template()
|
||||||
|
admin_only_keys = {"RATE_LIMIT_INTERVAL", "USER_ADD_INTERVAL", "MAX_FILTERS_PER_USER", "AUTO_POLL_INTERVAL", "MAX_SAFE_RESULTS"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if key in admin_only_keys and not force:
|
||||||
|
result["status"] = "restricted"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if key == "POESESSID":
|
||||||
|
self.settings[key] = value
|
||||||
|
self.COOKIES = {"POESESSID": value}
|
||||||
|
await self._validate_session()
|
||||||
|
result["session"] = value
|
||||||
|
result["status"] = "ok" if self.session_valid else "invalid"
|
||||||
|
else:
|
||||||
|
self.settings[key] = type(self.settings.get(key, value))(value)
|
||||||
|
result["status"] = "ok"
|
||||||
|
|
||||||
|
self._save_state()
|
||||||
|
except Exception as e:
|
||||||
|
result["status"] = "error"
|
||||||
|
self.logger.error(f"Failed to update setting {key}: {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(self):
|
||||||
|
"""
|
||||||
|
Returns the current settings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary with current settings and status.
|
||||||
|
"""
|
||||||
|
return {"status": "ok", "settings": self.settings}
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_session(self):
|
||||||
|
"""
|
||||||
|
Performs a test request to verify if the current POESESSID is valid.
|
||||||
|
Updates internal `session_valid` flag.
|
||||||
|
Handles rate limit headers to avoid overloading the API at startup.
|
||||||
|
"""
|
||||||
|
test_url = f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/OzKEO5ltE"
|
||||||
|
try:
|
||||||
|
connector = aiohttp.TCPConnector(ssl=True)
|
||||||
|
async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES, connector=connector) as session:
|
||||||
|
async with session.get(test_url) as resp:
|
||||||
|
rate_info = {
|
||||||
|
"Status": resp.status,
|
||||||
|
"Retry-After": resp.headers.get("Retry-After"),
|
||||||
|
"X-Rate-Limit-Rules": resp.headers.get("X-Rate-Limit-Rules"),
|
||||||
|
"X-Rate-Limit-Ip": resp.headers.get("X-Rate-Limit-Ip"),
|
||||||
|
"X-Rate-Limit-State": resp.headers.get("X-Rate-Limit-State"),
|
||||||
|
}
|
||||||
|
self.logger.debug(f"[{resp.status}] GET {test_url} | Rate Info: {rate_info}")
|
||||||
|
|
||||||
|
if resp.status == 429:
|
||||||
|
retry_after = int(resp.headers.get("Retry-After", 10))
|
||||||
|
self.session_valid = False
|
||||||
|
self.logger.warning(f"Rate limited during session validation. Sleeping for {retry_after + 2} seconds.")
|
||||||
|
await asyncio.sleep(retry_after + 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
elif resp.status == 403:
|
||||||
|
self.session_valid = False
|
||||||
|
self.logger.error("POESESSID validation failed: status 403")
|
||||||
|
|
||||||
|
elif resp.status == 200:
|
||||||
|
self.session_valid = True
|
||||||
|
self.logger.info("POESESSID validated successfully.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.session_valid = False
|
||||||
|
self.logger.error(f"POESESSID validation returned unexpected status: {resp.status}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.session_valid = False
|
||||||
|
self.logger.error(f"Session validation request failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_query_time(self, user_id: str, filter_id: str) -> int | None:
|
||||||
|
"""
|
||||||
|
Estimate the next time this filter will be queried, accounting for queued filters,
|
||||||
|
current ratelimit interval, and safety margins.
|
||||||
|
This version ensures the returned time is in the future based on queue order.
|
||||||
|
"""
|
||||||
|
if str(user_id) in self.paused_users:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_interval = self.dynamic_rate_limit
|
||||||
|
safety_margin = 2
|
||||||
|
total_delay = base_interval + safety_margin
|
||||||
|
|
||||||
|
# Build a linear list of all filters in scheduled order
|
||||||
|
full_queue = [
|
||||||
|
(uid, fid)
|
||||||
|
for uid, flist in self.watchlists.items()
|
||||||
|
if str(uid) not in self.paused_users
|
||||||
|
for fid in flist
|
||||||
|
]
|
||||||
|
|
||||||
|
if (user_id, filter_id) not in full_queue:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Filter position in the total queue
|
||||||
|
position = full_queue.index((user_id, filter_id))
|
||||||
|
|
||||||
|
# Always start from last API time and simulate future ticks
|
||||||
|
now = time.time()
|
||||||
|
last = self.last_api_time or now
|
||||||
|
|
||||||
|
# Keep incrementing until we find a future timestamp
|
||||||
|
next_query = last + (position * total_delay)
|
||||||
|
while next_query <= now:
|
||||||
|
next_query += len(full_queue) * total_delay
|
||||||
|
|
||||||
|
return int(next_query)
|
||||||
|
|
||||||
|
|
||||||
|
def _template(self):
|
||||||
|
return {
|
||||||
|
"status": None,
|
||||||
|
"user": None,
|
||||||
|
"filter_id": None,
|
||||||
|
"result_count": None,
|
||||||
|
"summary": None,
|
||||||
|
"results": None,
|
||||||
|
"session": None,
|
||||||
|
"input": None,
|
||||||
|
"user_count": None,
|
||||||
|
"query_time": None,
|
||||||
|
"next_allowed_time": None,
|
||||||
|
"settings": None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state(self):
|
||||||
|
if self.storage_file.exists():
|
||||||
|
try:
|
||||||
|
with open(self.storage_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.watchlists = defaultdict(list, data.get("watchlists", {}))
|
||||||
|
self.filter_names = defaultdict(dict, data.get("filter_names", {}))
|
||||||
|
self.last_seen_items = defaultdict(lambda: defaultdict(dict), data.get("seen", {}))
|
||||||
|
self.settings.update(data.get("settings", {}))
|
||||||
|
|
||||||
|
# Decode POESESSID
|
||||||
|
enc_token = self.settings.get("POESESSID")
|
||||||
|
if enc_token:
|
||||||
|
try:
|
||||||
|
self.settings["POESESSID"] = decrypt_token(enc_token)
|
||||||
|
self.COOKIES = {"POESESSID": self.settings["POESESSID"]}
|
||||||
|
except Exception as de:
|
||||||
|
self.logger.warning(f"Could not decode POESESSID: {de}")
|
||||||
|
|
||||||
|
self.paused_users = set(data.get("paused_users", []))
|
||||||
|
self.logger.info("State loaded. Active filters: %s", sum(len(v) for v in self.watchlists.values()))
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to load state: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(self):
|
||||||
|
try:
|
||||||
|
settings_copy = self.settings.copy()
|
||||||
|
if "POESESSID" in settings_copy:
|
||||||
|
raw_token = settings_copy["POESESSID"]
|
||||||
|
settings_copy["POESESSID"] = encrypt_token(raw_token)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"watchlists": dict(self.watchlists),
|
||||||
|
"filter_names": self.filter_names,
|
||||||
|
"seen": self.last_seen_items,
|
||||||
|
"settings": settings_copy,
|
||||||
|
"paused_users": list(self.paused_users)
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(self.storage_file, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to save state: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_verification_worker(self):
|
||||||
|
while True:
|
||||||
|
user_id, filter_id, custom_name, response_callback = await self.verification_queue.get()
|
||||||
|
result = await self._verify_and_add_filter(user_id, filter_id, custom_name)
|
||||||
|
if callable(response_callback):
|
||||||
|
await response_callback(result)
|
||||||
|
await asyncio.sleep(self.settings["RATE_LIMIT_INTERVAL"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_and_add_filter(self, user_id, filter_id, custom_name):
|
||||||
|
result = self._template()
|
||||||
|
result["user"] = user_id
|
||||||
|
result["filter_id"] = filter_id
|
||||||
|
|
||||||
|
query_url = f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}"
|
||||||
|
try:
|
||||||
|
connector = aiohttp.TCPConnector(ssl=False)
|
||||||
|
async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES, connector=connector) as session:
|
||||||
|
async with session.get(query_url) as resp:
|
||||||
|
if resp.status == 403:
|
||||||
|
self.session_valid = False
|
||||||
|
result["status"] = "invalid_session"
|
||||||
|
return result
|
||||||
|
elif resp.status != 200:
|
||||||
|
result["status"] = "error"
|
||||||
|
return result
|
||||||
|
data = await resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error while verifying filter {filter_id}: {e}")
|
||||||
|
result["status"] = "error"
|
||||||
|
return result
|
||||||
|
|
||||||
|
total_results = data.get("total", 0)
|
||||||
|
result["result_count"] = total_results
|
||||||
|
|
||||||
|
if total_results > self.settings["MAX_SAFE_RESULTS"]:
|
||||||
|
result["status"] = "too_broad"
|
||||||
|
return result
|
||||||
|
|
||||||
|
self.watchlists[user_id].append(filter_id)
|
||||||
|
if custom_name:
|
||||||
|
self.filter_names[user_id][filter_id] = custom_name
|
||||||
|
|
||||||
|
self._save_state()
|
||||||
|
result["status"] = "success"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def add_filter(self, user_id: str, filter_id: str, custom_name: str = None, response_callback=None) -> dict:
|
||||||
|
result = self._template()
|
||||||
|
result["user"] = user_id
|
||||||
|
result["filter_id"] = filter_id
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if not self.session_valid:
|
||||||
|
result["status"] = "invalid_session"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if filter_id in self.watchlists[user_id]:
|
||||||
|
result["status"] = "exists"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if len(self.watchlists[user_id]) >= self.settings["MAX_FILTERS_PER_USER"]:
|
||||||
|
result["status"] = "limit_reached"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if now - self.last_add_time[user_id] < self.settings["USER_ADD_INTERVAL"]:
|
||||||
|
result["status"] = "cooldown"
|
||||||
|
result["next_allowed_time"] = self.last_add_time[user_id] + self.settings["USER_ADD_INTERVAL"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
self.last_add_time[user_id] = now
|
||||||
|
await self.verification_queue.put((user_id, filter_id, custom_name, response_callback))
|
||||||
|
result["status"] = "queued"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def remove_filter(self, user_id: str, filter_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Removes a specific filter from a user's watchlist and cleans up associated metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (str): Discord user ID.
|
||||||
|
filter_id (str): Filter ID to remove.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result template with status and user info.
|
||||||
|
"""
|
||||||
|
result = self._template()
|
||||||
|
result["user"] = user_id
|
||||||
|
result["filter_id"] = filter_id
|
||||||
|
|
||||||
|
if filter_id in self.watchlists[user_id]:
|
||||||
|
self.watchlists[user_id].remove(filter_id)
|
||||||
|
self.filter_names[user_id].pop(filter_id, None)
|
||||||
|
self.last_seen_items[user_id].pop(filter_id, None)
|
||||||
|
self.last_query_time[user_id].pop(filter_id, None)
|
||||||
|
result["status"] = "removed"
|
||||||
|
else:
|
||||||
|
result["status"] = "not_found"
|
||||||
|
|
||||||
|
self._save_state()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def remove_all_filters(self, user_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Removes all filters for the specified user and clears associated metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (str): Discord user ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result template with summary of removed filters.
|
||||||
|
"""
|
||||||
|
result = self._template()
|
||||||
|
result["user"] = user_id
|
||||||
|
removed = self.watchlists.pop(user_id, [])
|
||||||
|
self.filter_names.pop(user_id, None)
|
||||||
|
self.last_seen_items.pop(user_id, None)
|
||||||
|
self.last_add_time.pop(user_id, None)
|
||||||
|
self.last_query_time.pop(user_id, None)
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
result["status"] = "removed"
|
||||||
|
result["results"] = removed
|
||||||
|
result["summary"] = f"Removed {len(removed)} filters from your watchlist."
|
||||||
|
else:
|
||||||
|
result["status"] = "empty"
|
||||||
|
result["summary"] = "You have no filters to remove."
|
||||||
|
|
||||||
|
self._save_state()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_filters(self, user_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Returns all active filters for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (str): Discord user ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result template with active filters list and status.
|
||||||
|
"""
|
||||||
|
result = self._template()
|
||||||
|
result["user"] = user_id
|
||||||
|
result["results"] = self.watchlists[user_id]
|
||||||
|
result["status"] = "ok"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def query_single(self, user_id, filter_id):
|
||||||
|
if not self.session_valid:
|
||||||
|
self.logger.warning("Skipping query: POESESSID invalid.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if str(user_id) in self.paused_users:
|
||||||
|
return
|
||||||
|
|
||||||
|
found_items = {}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES) as session:
|
||||||
|
async def fetch_with_handling(url, method="GET", **kwargs):
|
||||||
|
async with getattr(session, method.lower())(url, **kwargs) as res:
|
||||||
|
rate_info = {
|
||||||
|
"Status": res.status,
|
||||||
|
"Retry-After": res.headers.get("Retry-After"),
|
||||||
|
"X-Rate-Limit-Rules": res.headers.get("X-Rate-Limit-Rules"),
|
||||||
|
"X-Rate-Limit-Ip": res.headers.get("X-Rate-Limit-Ip"),
|
||||||
|
"X-Rate-Limit-State": res.headers.get("X-Rate-Limit-State"),
|
||||||
|
}
|
||||||
|
self.logger.debug(f"[{res.status}] {method} {url} | Rate Info: {rate_info}")
|
||||||
|
|
||||||
|
if res.status == 429:
|
||||||
|
retry_after = int(res.headers.get("Retry-After", 10))
|
||||||
|
self.dynamic_rate_limit = retry_after + 2
|
||||||
|
self.logger.warning(f"Rate limited on {url}. Sleeping for {self.dynamic_rate_limit} seconds.")
|
||||||
|
await asyncio.sleep(self.dynamic_rate_limit)
|
||||||
|
return "ratelimited"
|
||||||
|
elif res.status >= 400:
|
||||||
|
self.logger.warning(f"HTTP {res.status} on {url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await res.json()
|
||||||
|
|
||||||
|
filter_data = await fetch_with_handling(
|
||||||
|
f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}", method="GET"
|
||||||
|
)
|
||||||
|
if not filter_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
query = {"query": filter_data.get("query", {})}
|
||||||
|
result_data = await fetch_with_handling(
|
||||||
|
f"{self.BASE_URL}/search/poe2/{self.LEAGUE}", method="POST", json=query
|
||||||
|
)
|
||||||
|
if not result_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
search_id = result_data.get("id")
|
||||||
|
result_ids = result_data.get("result", [])[:10]
|
||||||
|
self.logger.info(f"Filter {filter_id} returned {len(result_ids)} item IDs")
|
||||||
|
|
||||||
|
if not result_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
joined_ids = ",".join(result_ids)
|
||||||
|
fetch_url = f"{self.BASE_URL}/fetch/{joined_ids}?query={search_id}"
|
||||||
|
item_data = await fetch_with_handling(fetch_url, method="GET")
|
||||||
|
if not item_data:
|
||||||
|
return "ratelimited"
|
||||||
|
|
||||||
|
items = item_data.get("result", [])
|
||||||
|
filtered = []
|
||||||
|
for item in items:
|
||||||
|
item_id = item.get("id")
|
||||||
|
price_data = item.get("listing", {}).get("price", {})
|
||||||
|
price = price_data.get("amount")
|
||||||
|
currency = price_data.get("currency", "unknown")
|
||||||
|
|
||||||
|
if item_id and self.should_notify(user_id, filter_id, item_id, price, currency):
|
||||||
|
filtered.append(item)
|
||||||
|
self.mark_seen(user_id, filter_id, item_id, price, currency)
|
||||||
|
|
||||||
|
if filtered:
|
||||||
|
found_items[(user_id, filter_id)] = {
|
||||||
|
"search_id": search_id,
|
||||||
|
"items": filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
self.last_query_time[user_id][filter_id] = now
|
||||||
|
self.last_api_time = now
|
||||||
|
|
||||||
|
if found_items and self._on_update_callback:
|
||||||
|
await self._on_update_callback(found_items)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_filters(self, max_age_seconds: int = 86400) -> dict:
|
||||||
|
"""
|
||||||
|
Cleans up filters that haven't had results in a specified time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_seconds (int): Threshold of inactivity before filters are cleaned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result with summary of removed filters.
|
||||||
|
"""
|
||||||
|
result = self._template()
|
||||||
|
now = time.time()
|
||||||
|
removed = {}
|
||||||
|
|
||||||
|
for key in list(self.last_seen_items.keys()):
|
||||||
|
last_seen = max((now - self.last_add_time.get(key[0], now)), 0)
|
||||||
|
if last_seen > max_age_seconds:
|
||||||
|
user, filter_id = key
|
||||||
|
if filter_id in self.watchlists[user]:
|
||||||
|
self.watchlists[user].remove(filter_id)
|
||||||
|
removed.setdefault(user, []).append(filter_id)
|
||||||
|
del self.last_seen_items[key]
|
||||||
|
|
||||||
|
self._save_state()
|
||||||
|
result["status"] = "ok"
|
||||||
|
result["results"] = removed
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_filter_name(self, user_id: str, filter_id: str) -> str | None:
|
||||||
|
return self.filter_names.get(user_id, {}).get(filter_id)
|
||||||
|
|
||||||
|
|
||||||
|
def pause_user(self, user_id: str) -> bool:
|
||||||
|
if user_id in self.paused_users:
|
||||||
|
return False
|
||||||
|
self.paused_users.add(user_id)
|
||||||
|
self._save_state()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def resume_user(self, user_id: str) -> bool:
|
||||||
|
if user_id not in self.paused_users:
|
||||||
|
return False
|
||||||
|
self.paused_users.remove(user_id)
|
||||||
|
self._save_state()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_paused(self, user_id: str) -> bool:
|
||||||
|
return user_id in self.paused_users
|
||||||
|
|
||||||
|
def clear_cache(self, user_id: str, confirm: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Clears all persistent and in-memory cache for a user after confirmation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (str): Discord user ID (must be the owner).
|
||||||
|
confirm (bool): If True, actually clears cache; otherwise sends confirmation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Result with status and message.
|
||||||
|
"""
|
||||||
|
owner_id = self.settings.get("OWNER_ID", "203190147582394369")
|
||||||
|
result = self._template()
|
||||||
|
|
||||||
|
if str(user_id) != owner_id:
|
||||||
|
result["status"] = "unauthorized"
|
||||||
|
result["summary"] = "Only the bot owner may use this command."
|
||||||
|
return result
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if not hasattr(self, "_cache_clear_confirmations"):
|
||||||
|
self._cache_clear_confirmations = {}
|
||||||
|
|
||||||
|
if not confirm:
|
||||||
|
self._cache_clear_confirmations[user_id] = now
|
||||||
|
result["status"] = "confirm"
|
||||||
|
result["summary"] = "⚠️ This action will clear all filters, names, and seen cache.\nRun the same command again with `-y` within 60s to confirm."
|
||||||
|
return result
|
||||||
|
|
||||||
|
last = self._cache_clear_confirmations.get(user_id, 0)
|
||||||
|
if now - last > 60:
|
||||||
|
result["status"] = "expired"
|
||||||
|
result["summary"] = "Confirmation expired. Please run the command again."
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif confirm:
|
||||||
|
result["status"] = "invalid"
|
||||||
|
result["summary"] = "⚠️ Run the command without the `-y` flag first!"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Reset all critical in-memory and persistent states
|
||||||
|
self.watchlists.clear()
|
||||||
|
self.filter_names.clear()
|
||||||
|
self.last_seen_items.clear()
|
||||||
|
self.last_add_time.clear()
|
||||||
|
self.last_query_time.clear()
|
||||||
|
self.paused_users.clear()
|
||||||
|
|
||||||
|
self._cache_clear_confirmations.pop(user_id, None)
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
result["status"] = "cleared"
|
||||||
|
result["summary"] = "🧹 Cache successfully cleared for all users."
|
||||||
|
return result
|
|
@ -0,0 +1,159 @@
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
|
# CONFIGURATION
|
||||||
|
LEAGUE = "Standard" # PoE2 league name
|
||||||
|
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/1354003262709305364/afkTjeXcu1bfZXsQzFl-QqSb3R1MmQ4hdZhosR3vm4I__QVEyZ0jO9cqndUTQwb1mt5Z"
|
||||||
|
SEARCH_INTERVAL = 300 # 5 minutes in seconds
|
||||||
|
POESESSID = "e6f8684e56b4ceb489b10225222640f4" # Test session ID
|
||||||
|
|
||||||
|
# Memory to store previously seen listings
|
||||||
|
last_seen_ids = set()
|
||||||
|
|
||||||
|
# Headers and cookies for authenticated requests
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||||||
|
}
|
||||||
|
COOKIES = {
|
||||||
|
"POESESSID": POESESSID
|
||||||
|
}
|
||||||
|
|
||||||
|
# EXAMPLE WISHLIST FILTER for PoE2
|
||||||
|
query = {
|
||||||
|
"query": {
|
||||||
|
"status": {"option": "online"},
|
||||||
|
"name": "Taryn's Shiver",
|
||||||
|
"type": "Gelid Staff",
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"type": "and",
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"id": "skill.freezing_shards",
|
||||||
|
"value": {"min": 10},
|
||||||
|
"disabled": False
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "explicit.stat_3291658075",
|
||||||
|
"value": {"min": 100},
|
||||||
|
"disabled": False
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"disabled": False
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filters": {
|
||||||
|
"req_filters": {
|
||||||
|
"filters": {
|
||||||
|
"lvl": {
|
||||||
|
"max": 40,
|
||||||
|
"min": None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disabled": False
|
||||||
|
},
|
||||||
|
"trade_filters": {
|
||||||
|
"filters": {
|
||||||
|
"price": {
|
||||||
|
"max": 1,
|
||||||
|
"min": None,
|
||||||
|
"option": None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disabled": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def search_items():
|
||||||
|
global last_seen_ids
|
||||||
|
|
||||||
|
# Step 1: Submit search query
|
||||||
|
response = requests.post(f"https://www.pathofexile.com/api/trade2/search/poe2/{LEAGUE}", json=query, headers=HEADERS, cookies=COOKIES)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
search_id = data.get("id")
|
||||||
|
result_ids = data.get("result", [])[:10] # Limit to first 10 results
|
||||||
|
|
||||||
|
if not result_ids:
|
||||||
|
print("No results found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_ids = [item_id for item_id in result_ids if item_id not in last_seen_ids]
|
||||||
|
if not new_ids:
|
||||||
|
print("No new items since last check.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 2: Fetch item details
|
||||||
|
joined_ids = ",".join(new_ids)
|
||||||
|
fetch_url = f"https://www.pathofexile.com/api/trade2/fetch/{joined_ids}?query={search_id}"
|
||||||
|
details = requests.get(fetch_url, headers=HEADERS, cookies=COOKIES).json()
|
||||||
|
|
||||||
|
for item in details.get("result", []):
|
||||||
|
item_id = item.get("id")
|
||||||
|
last_seen_ids.add(item_id)
|
||||||
|
|
||||||
|
item_info = item.get("item", {})
|
||||||
|
listing = item.get("listing", {})
|
||||||
|
|
||||||
|
name = item_info.get("name", "")
|
||||||
|
type_line = item_info.get("typeLine", "")
|
||||||
|
price = listing.get("price", {}).get("amount", "?")
|
||||||
|
currency = listing.get("price", {}).get("currency", "?")
|
||||||
|
account = listing.get("account", {}).get("name", "")
|
||||||
|
whisper = listing.get("whisper", "")
|
||||||
|
icon = item_info.get("icon", "")
|
||||||
|
item_lvl = item_info.get("ilvl", "?")
|
||||||
|
required_lvl = item_info.get("requirements", [])
|
||||||
|
|
||||||
|
# Requirements formatting
|
||||||
|
req_str = ""
|
||||||
|
for r in required_lvl:
|
||||||
|
if r.get("name") == "Level":
|
||||||
|
req_str += f"Level {r.get('values')[0][0]}"
|
||||||
|
else:
|
||||||
|
req_str += f", {r.get('values')[0][0]} {r.get('name')}"
|
||||||
|
|
||||||
|
# Extract key stats for display (if available)
|
||||||
|
stats = item_info.get("explicitMods", [])
|
||||||
|
stat_text = "\n".join([f"{s}" for s in stats]) if stats else "No explicit stats."
|
||||||
|
|
||||||
|
# Construct listing URL
|
||||||
|
listing_url = f"https://www.pathofexile.com/trade2/search/poe2/{LEAGUE}/{search_id}" + f"/item/{item_id}"
|
||||||
|
|
||||||
|
embed = {
|
||||||
|
"embeds": [
|
||||||
|
{
|
||||||
|
"author": {
|
||||||
|
"name": f"{name} — {type_line}"
|
||||||
|
},
|
||||||
|
"title": f"{price} {currency} • Listed by {account}",
|
||||||
|
"url": listing_url,
|
||||||
|
"description": f"**Item Level:** {item_lvl}\n**Requirements:** {req_str}\n\n{stat_text}\n\n**Whisper:**\n`{whisper}`",
|
||||||
|
"thumbnail": {"url": icon},
|
||||||
|
"color": 7506394,
|
||||||
|
"footer": {"text": "Click the title to view full item details."}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
send_to_discord_embed(embed)
|
||||||
|
|
||||||
|
|
||||||
|
def send_to_discord_embed(embed_payload):
|
||||||
|
response = requests.post(DISCORD_WEBHOOK_URL, json=embed_payload)
|
||||||
|
if response.status_code == 204:
|
||||||
|
print("Embed sent to Discord.")
|
||||||
|
else:
|
||||||
|
print(f"Failed to send embed: {response.status_code}\n{response.text}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
while True:
|
||||||
|
print("Searching for wishlist items...")
|
||||||
|
try:
|
||||||
|
search_items()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
time.sleep(SEARCH_INTERVAL)
|
Loading…
Reference in New Issue