Improved platform logging and Custom VC

- Platform logs associated to a message are now associated with platform-specific IDs
- Fixes for `!customvc`, specifically in relation to commands execution.
  - Optimized logic
  - Fixed limits for certain values like VC users limit
  - Better code integrity: now self-references as a Cog
- Lots of minor other tweaks, adjustments, and improvements
kami_dev
Kami 2025-03-06 17:53:54 +01:00
parent 86ac83f34c
commit cce0f21ab0
14 changed files with 988 additions and 803 deletions

View File

@ -5,6 +5,7 @@ from discord.ext import commands, tasks
import importlib import importlib
import cmd_discord import cmd_discord
import json import json
import os
import globals import globals
@ -23,7 +24,6 @@ class DiscordBot(commands.Bot):
self.log = globals.log # Use the logging function from bots.py self.log = globals.log # Use the logging function from bots.py
self.db_conn = None # We'll set this later self.db_conn = None # We'll set this later
self.help_data = None # We'll set this later self.help_data = None # We'll set this later
self.load_commands()
globals.log("Discord bot initiated") globals.log("Discord bot initiated")
@ -45,46 +45,39 @@ class DiscordBot(commands.Bot):
""" """
self.db_conn = db_conn self.db_conn = db_conn
def load_commands(self): async def setup_hook(self):
""" # This is an async-ready function you can override in discord.py 2.0.
Load all commands from cmd_discord.py for filename in os.listdir("cmd_discord"):
""" if filename.endswith(".py") and filename != "__init__.py":
try: cog_name = f"cmd_discord.{filename[:-3]}"
importlib.reload(cmd_discord) # Reload the commands file await self.load_extension(cog_name) # now we can await it
cmd_discord.setup(self) # Ensure commands are registered
globals.log("Discord commands loaded successfully.")
# Load help info # Log which cogs got loaded
short_name = filename[:-3]
globals.log(f"Loaded Discord command cog '{short_name}'", "DEBUG")
globals.log("All Discord command cogs loaded successfully.", "INFO")
# Now that cogs are all loaded, run any help file initialization:
help_json_path = "dictionary/help_discord.json" help_json_path = "dictionary/help_discord.json"
modules.utility.initialize_help_data( modules.utility.initialize_help_data(
bot=self, bot=self,
help_json_path=help_json_path, help_json_path=help_json_path,
is_discord=True is_discord=True
) )
except Exception as e:
_result = f"Error loading Discord commands: {e}"
globals.log(_result, "ERROR", True)
@commands.command(name="cmd_reload") @commands.command(name="reload")
@commands.is_owner() @commands.is_owner()
async def cmd_reload(self, ctx: commands.Context): async def reload(ctx, cog_name: str):
"""Reloads a specific cog without restarting the bot."""
try: try:
importlib.reload(cmd_discord) # Reload the commands file await ctx.bot.unload_extension(f"cmd_discord.{cog_name}")
cmd_discord.setup(self) # Ensure commands are registered await ctx.bot.load_extension(f"cmd_discord.{cog_name}")
await ctx.reply(f"✅ Reloaded `{cog_name}` successfully!")
# Load help info globals.log(f"Successfully reloaded the command cog `{cog_name}`", "INFO")
help_json_path = "dictionary/help_discord.json"
modules.utility.initialize_help_data(
bot=self,
help_json_path=help_json_path,
is_discord=True
)
_result = "Commands reloaded successfully"
globals.log("Discord commands reloaded successfully.")
except Exception as e: except Exception as e:
_result = f"Error reloading Discord commands: {e}" await ctx.reply(f"❌ Error reloading `{cog_name}`: {e}")
globals.log(_result, "ERROR", True) globals.log(f"Failed to reload the command cog `{cog_name}`", "ERROR")
await ctx.reply(_result)
async def on_message(self, message): async def on_message(self, message):
if message.guild: if message.guild:
@ -95,17 +88,16 @@ class DiscordBot(commands.Bot):
channel_name = "Direct Message" channel_name = "Direct Message"
globals.log(f"Message detected by '{message.author.name}' in '{guild_name}' - #'{channel_name}'", "DEBUG") globals.log(f"Message detected by '{message.author.name}' in '{guild_name}' - #'{channel_name}'", "DEBUG")
globals.log(f"Message content: '{message.content}'", "DEBUG")
globals.log(f"Attempting UUI lookup on '{message.author.name}' ...", "DEBUG")
try: try:
# If it's a bot message, ignore or pass user_is_bot=True
is_bot = message.author.bot is_bot = message.author.bot
user_id = str(message.author.id) user_id = str(message.author.id)
user_name = message.author.name # no discriminator user_name = message.author.name
display_name = message.author.display_name display_name = message.author.display_name
platform_str = f"discord-{guild_name}"
channel_str = channel_name
# Track user activity first # Track user activity
modules.utility.track_user_activity( modules.utility.track_user_activity(
db_conn=self.db_conn, db_conn=self.db_conn,
platform="discord", platform="discord",
@ -115,10 +107,6 @@ class DiscordBot(commands.Bot):
user_is_bot=is_bot user_is_bot=is_bot
) )
# Let log_message() handle UUID lookup internally
platform_str = f"discord-{guild_name}"
channel_str = channel_name
attachments = ", ".join(a.url for a in message.attachments) if message.attachments else "" attachments = ", ".join(a.url for a in message.attachments) if message.attachments else ""
log_message( log_message(
@ -128,19 +116,152 @@ class DiscordBot(commands.Bot):
message_content=message.content or "", message_content=message.content or "",
platform=platform_str, platform=platform_str,
channel=channel_str, channel=channel_str,
attachments=attachments attachments=attachments,
platform_message_id=str(message.id) # Include Discord message ID
) )
except Exception as e: except Exception as e:
globals.log(f"... UUI lookup failed: {e}", "WARNING") globals.log(f"... UUI lookup failed: {e}", "WARNING")
pass pass
try: try:
# Pass message contents to commands processing
await self.process_commands(message) await self.process_commands(message)
globals.log(f"Command processing complete", "DEBUG") globals.log(f"Command processing complete", "DEBUG")
except Exception as e: except Exception as e:
globals.log(f"Command processing failed: {e}", "ERROR") globals.log(f"Command processing failed: {e}", "ERROR")
# async def on_reaction_add(self, reaction, user):
# if user.bot:
# return # Ignore bot reactions
# guild_name = reaction.message.guild.name if reaction.message.guild else "DM"
# channel_name = reaction.message.channel.name if hasattr(reaction.message.channel, "name") else "Direct Message"
# globals.log(f"Reaction added by '{user.name}' on message '{reaction.message.id}' in '{guild_name}' - #{channel_name}", "DEBUG")
# modules.utility.track_user_activity(
# db_conn=self.db_conn,
# platform="discord",
# user_id=str(user.id),
# username=user.name,
# display_name=user.display_name,
# user_is_bot=user.bot
# )
# platform_str = f"discord-{guild_name}"
# channel_str = channel_name
# log_message(
# db_conn=self.db_conn,
# identifier=str(user.id),
# identifier_type="discord_user_id",
# message_content=f"Reaction Added: {reaction.emoji} on Message ID: {reaction.message.id}",
# platform=platform_str,
# channel=channel_str
# )
# async def on_reaction_remove(self, reaction, user):
# if user.bot:
# return # Ignore bot reactions
# guild_name = reaction.message.guild.name if reaction.message.guild else "DM"
# channel_name = reaction.message.channel.name if hasattr(reaction.message.channel, "name") else "Direct Message"
# globals.log(f"Reaction removed by '{user.name}' on message '{reaction.message.id}' in '{guild_name}' - #{channel_name}", "DEBUG")
# modules.utility.track_user_activity(
# db_conn=self.db_conn,
# platform="discord",
# user_id=str(user.id),
# username=user.name,
# display_name=user.display_name,
# user_is_bot=user.bot
# )
# platform_str = f"discord-{guild_name}"
# channel_str = channel_name
# log_message(
# db_conn=self.db_conn,
# identifier=str(user.id),
# identifier_type="discord_user_id",
# message_content=f"Reaction Removed: {reaction.emoji} on Message ID: {reaction.message.id}",
# platform=platform_str,
# channel=channel_str
# )
# async def on_message_edit(self, before, after):
# if before.author.bot:
# return # Ignore bot edits
# guild_name = before.guild.name if before.guild else "DM"
# channel_name = before.channel.name if hasattr(before.channel, "name") else "Direct Message"
# globals.log(f"Message edited by '{before.author.name}' in '{guild_name}' - #{channel_name}", "DEBUG")
# globals.log(f"Before: {before.content}\nAfter: {after.content}", "DEBUG")
# modules.utility.track_user_activity(
# db_conn=self.db_conn,
# platform="discord",
# user_id=str(before.author.id),
# username=before.author.name,
# display_name=before.author.display_name,
# user_is_bot=before.author.bot
# )
# platform_str = f"discord-{guild_name}"
# channel_str = channel_name
# log_message(
# db_conn=self.db_conn,
# identifier=str(before.author.id),
# identifier_type="discord_user_id",
# message_content=f"Message Edited:\nBefore: {before.content}\nAfter: {after.content}",
# platform=platform_str,
# channel=channel_str
# )
# async def on_thread_create(self, thread):
# globals.log(f"Thread '{thread.name}' created in #{thread.parent.name}", "DEBUG")
# modules.utility.track_user_activity(
# db_conn=self.db_conn,
# platform="discord",
# user_id=str(thread.owner_id),
# username=thread.owner.name if thread.owner else "Unknown",
# display_name=thread.owner.display_name if thread.owner else "Unknown",
# user_is_bot=False
# )
# log_message(
# db_conn=self.db_conn,
# identifier=str(thread.owner_id),
# identifier_type="discord_user_id",
# message_content=f"Thread Created: {thread.name} in #{thread.parent.name}",
# platform=f"discord-{thread.guild.name}",
# channel=thread.parent.name
# )
# async def on_thread_update(self, before, after):
# globals.log(f"Thread updated: '{before.name}' -> '{after.name}'", "DEBUG")
# log_message(
# db_conn=self.db_conn,
# identifier=str(before.owner_id),
# identifier_type="discord_user_id",
# message_content=f"Thread Updated: '{before.name}' -> '{after.name}'",
# platform=f"discord-{before.guild.name}",
# channel=before.parent.name
# )
# async def on_thread_delete(self, thread):
# globals.log(f"Thread '{thread.name}' deleted", "DEBUG")
# log_message(
# db_conn=self.db_conn,
# identifier=str(thread.owner_id),
# identifier_type="discord_user_id",
# message_content=f"Thread Deleted: {thread.name}",
# platform=f"discord-{thread.guild.name}",
# channel=thread.parent.name
# )
def load_bot_settings(self): def load_bot_settings(self):
"""Loads bot activity settings from JSON file.""" """Loads bot activity settings from JSON file."""

View File

@ -46,27 +46,17 @@ class TwitchBot(commands.Bot):
async def event_message(self, message): async def event_message(self, message):
""" """
Called every time a Twitch message is received (chat message in a channel). Called every time a Twitch message is received (chat message in a channel).
We'll use this to track the user in our 'users' table.
""" """
# If it's the bot's own message, ignore
if message.echo: if message.echo:
return return
# Log the command if it's a command
if message.content.startswith("!"):
_cmd = message.content[1:] # Remove the leading "!"
_cmd_args = _cmd.split(" ")[1:]
_cmd = _cmd.split(" ", 1)[0]
globals.log(f"Command '{_cmd}' (Twitch) initiated by {message.author.name} in #{message.channel.name}", "DEBUG")
if len(_cmd_args) > 1: globals.log(f"!{_cmd} arguments: {_cmd_args}", "DEBUG")
try: try:
# Typically message.author is not None for normal chat messages
author = message.author author = message.author
if not author: # just in case if not author:
return return
is_bot = False # TODO Implement automatic bot account check is_bot = False
user_id = str(author.id) user_id = str(author.id)
user_name = author.name user_name = author.name
display_name = author.display_name or user_name display_name = author.display_name or user_name
@ -84,23 +74,20 @@ class TwitchBot(commands.Bot):
globals.log("... UUI lookup complete.", "DEBUG") globals.log("... UUI lookup complete.", "DEBUG")
user_data = lookup_user(db_conn=self.db_conn, identifier=str(message.author.id), identifier_type="twitch_user_id")
user_uuid = user_data["UUID"] if user_data else "UNKNOWN"
from modules.db import log_message
log_message( log_message(
db_conn=self.db_conn, db_conn=self.db_conn,
identifier=str(message.author.id), identifier=user_id,
identifier_type="twitch_user_id", identifier_type="twitch_user_id",
message_content=message.content or "", message_content=message.content or "",
platform="twitch", platform="twitch",
channel=message.channel.name, channel=message.channel.name,
attachments="" attachments="",
platform_message_id=str(message.id) # Include Twitch message ID
) )
except Exception as e: except Exception as e:
globals.log(f"... UUI lookup failed: {e}", "ERROR") globals.log(f"... UUI lookup failed: {e}", "ERROR")
# Pass message contents to commands processing
await self.handle_commands(message) await self.handle_commands(message)

View File

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

View File

@ -3,57 +3,44 @@
import discord import discord
from discord.ext import commands from discord.ext import commands
import datetime import datetime
import globals
from modules.permissions import has_custom_vc_permission from modules.permissions import has_custom_vc_permission
# IDs class CustomVCCog(commands.Cog):
LOBBY_VC_ID = 1345509388651069583 # The lobby voice channel def __init__(self, bot):
CUSTOM_VC_CATEGORY_ID = 1345509307503874149 # Where new custom VCs go self.bot = bot
self.settings_data = globals.load_settings_file("discord_guilds_config.json")
# Track data in memory guild_id = "896713616089309184" # Adjust based on your settings structure
CUSTOM_VC_INFO = {} # voice_chan_id -> {owner_id, locked, allowed_ids, denied_ids, ...} self.LOBBY_VC_ID = self.settings_data[guild_id]["customvc_settings"]["lobby_vc_id"]
USER_LAST_CREATED = {} # user_id -> datetime (for rate-limits) self.CUSTOM_VC_CATEGORY_ID = self.settings_data[guild_id]["customvc_settings"]["customvc_category_id"]
GLOBAL_CREATIONS = [] # list of datetime for global rate-limits self.USER_COOLDOWN_MINUTES = self.settings_data[guild_id]["customvc_settings"]["vc_creation_user_cooldown"]
CHANNEL_COUNTER = 0 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"]
# Rate-limits self.CUSTOM_VC_INFO = {}
USER_COOLDOWN_MINUTES = 5 self.USER_LAST_CREATED = {}
GLOBAL_CHANNELS_PER_MINUTE = 2 self.GLOBAL_CREATIONS = []
self.CHANNEL_COUNTER = 0
self.PENDING_DELETIONS = {}
# Auto-delete scheduling @commands.Cog.listener()
PENDING_DELETIONS = {} async def on_ready(self):
DELETE_DELAY_SECONDS = 10 """Handles checking existing voice channels on bot startup."""
await self.scan_existing_custom_vcs()
# Arbitrary max, so new channels get “VC x” or “Overflow x” @commands.Cog.listener()
MAX_CUSTOM_CHANNELS = 9 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)
def setup(bot: commands.Bot): async def customvc(self, ctx):
""" """
Called by your `load_commands()` in bot_discord.py Base !customvc command -> show help if no subcommand used.
"""
@bot.listen("on_ready")
async def after_bot_ready():
"""
When the bot starts, we scan leftover custom voice channels in the category:
- If it's the lobby => skip
- If it's empty => delete
- If it has users => pick first occupant as owner
Then mention them in that channel's built-in text chat (if supported).
"""
await scan_existing_custom_vcs(bot)
@bot.listen("on_voice_state_update")
async def handle_voice_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
# The logic for creation, auto-delete, etc.
await on_voice_state_update(bot, member, before, after)
@bot.group(name="customvc", invoke_without_command=True)
async def customvc_base(ctx: commands.Context):
"""
Base !customvc command -> show help if no subcommand used
""" """
if ctx.invoked_subcommand is None:
msg = ( msg = (
"**Custom VC Help**\n" "**Custom VC Help**\n"
"Join the lobby to create a new voice channel automatically.\n" "Join the lobby to create a new voice channel automatically.\n"
@ -80,64 +67,62 @@ def setup(bot: commands.Bot):
"- **settings** - show config\n" "- **settings** - show config\n"
" - `!customvc settings`\n" " - `!customvc settings`\n"
) )
await ctx.send(msg) await ctx.reply(msg)
else:
await self.bot.invoke(ctx) # This will ensure subcommands get processed.
# Subcommands: # Subcommands:
@customvc_base.command(name="name") @customvc.command(name="name")
async def name_subcommand(ctx: commands.Context, *, new_name: str): async def rename_channel(self, ctx, *, new_name: str):
await rename_channel(ctx, new_name) """Renames a custom VC."""
await self.rename_channel_logic(ctx, new_name)
@customvc_base.command(name="claim") @customvc.command(name="claim")
async def claim_subcommand(ctx: commands.Context): async def claim_channel(self, ctx):
await claim_channel(ctx) """Claims ownership of a VC if the owner is absent."""
await self.claim_channel_logic(ctx)
@customvc_base.command(name="lock") @customvc.command(name="lock")
async def lock_subcommand(ctx: commands.Context): async def lock_channel(self, ctx):
await lock_channel(ctx) """Locks a custom VC."""
await self.lock_channel_logic(ctx)
@customvc_base.command(name="allow") @customvc.command(name="allow")
async def allow_subcommand(ctx: commands.Context, *, user: str): async def allow_user(self, ctx, *, user: str):
await allow_user(ctx, user) """Allows a user to join a VC."""
await self.allow_user_logic(ctx, user)
@customvc_base.command(name="deny") @customvc.command(name="deny")
async def deny_subcommand(ctx: commands.Context, *, user: str): async def deny_user(self, ctx, *, user: str):
await deny_user(ctx, user) """Denies a user from joining a VC."""
await self.deny_user_logic(ctx, user)
@customvc_base.command(name="unlock") @customvc.command(name="unlock")
async def unlock_subcommand(ctx: commands.Context): async def unlock_channel(self, ctx):
await unlock_channel(ctx) """Unlocks a custom VC."""
await self.unlock_channel_logic(ctx)
@customvc_base.command(name="users") @customvc.command(name="settings")
async def users_subcommand(ctx: commands.Context, limit: int): async def show_settings(self, ctx):
await set_user_limit(ctx, limit) """Shows the settings of the current VC."""
await self.show_settings_logic(ctx)
@customvc_base.command(name="bitrate") @customvc.command(name="users")
async def bitrate_subcommand(ctx: commands.Context, kbps: int): async def set_users_limit(self, ctx):
await set_bitrate(ctx, kbps) """Assign a VC users limit"""
await self.set_user_limit_logic(ctx)
@customvc_base.command(name="status")
async def status_subcommand(ctx: commands.Context, *, status_str: str):
await set_status(ctx, status_str)
@customvc_base.command(name="op") #
async def op_subcommand(ctx: commands.Context, *, user: str): # Main voice update logic
await op_user(ctx, user) #
@customvc_base.command(name="settings")
async def settings_subcommand(ctx: commands.Context):
await show_settings(ctx)
#
# Main voice update logic
#
async def on_voice_state_update(bot: commands.Bot, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
global CHANNEL_COUNTER
async def on_voice_update(self, member, before, after):
if before.channel != after.channel: if before.channel != after.channel:
# 1) If user just joined the lobby # 1) If user just joined the lobby
if after.channel and after.channel.id == LOBBY_VC_ID: if after.channel and after.channel.id == self.LOBBY_VC_ID:
if not await can_create_vc(member): if not await self.can_create_vc(member):
# Rate-limit => forcibly disconnect # Rate-limit => forcibly disconnect
await member.move_to(None) await member.move_to(None)
# Also mention them in the ephemeral chat of LOBBY_VC_ID if we want (if possible) # Also mention them in the ephemeral chat of LOBBY_VC_ID if we want (if possible)
@ -148,21 +133,21 @@ async def on_voice_state_update(bot: commands.Bot, member: discord.Member, befor
return return
# 2) Create a new custom VC # 2) Create a new custom VC
CHANNEL_COUNTER += 1 self.CHANNEL_COUNTER += 1
vc_name = f"VC {CHANNEL_COUNTER}" vc_name = f"VC {self.CHANNEL_COUNTER}"
if CHANNEL_COUNTER > MAX_CUSTOM_CHANNELS: if self.CHANNEL_COUNTER > self.MAX_CUSTOM_CHANNELS:
vc_name = f"Overflow {CHANNEL_COUNTER}" vc_name = f"Overflow {self.CHANNEL_COUNTER}"
category = member.guild.get_channel(CUSTOM_VC_CATEGORY_ID) category = member.guild.get_channel(self.CUSTOM_VC_CATEGORY_ID)
if not category or not isinstance(category, discord.CategoryChannel): if not category or not isinstance(category, discord.CategoryChannel):
print("Could not find a valid custom VC category.") globals.log("Could not find a valid custom VC category.", "INFO")
return return
new_vc = await category.create_voice_channel(name=vc_name) new_vc = await category.create_voice_channel(name=vc_name)
await member.move_to(new_vc) await member.move_to(new_vc)
# Record memory # Record memory
CUSTOM_VC_INFO[new_vc.id] = { self.CUSTOM_VC_INFO[new_vc.id] = {
"owner_id": member.id, "owner_id": member.id,
"locked": False, "locked": False,
"allowed_ids": set(), "allowed_ids": set(),
@ -172,11 +157,11 @@ async def on_voice_state_update(bot: commands.Bot, member: discord.Member, befor
} }
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()
USER_LAST_CREATED[member.id] = now self.USER_LAST_CREATED[member.id] = now
GLOBAL_CREATIONS.append(now) self.GLOBAL_CREATIONS.append(now)
# prune old # prune old
cutoff = now - datetime.timedelta(seconds=60) cutoff = now - datetime.timedelta(seconds=60)
GLOBAL_CREATIONS[:] = [t for t in GLOBAL_CREATIONS if t > cutoff] self.GLOBAL_CREATIONS[:] = [t for t in self.GLOBAL_CREATIONS if t > cutoff]
# Announce in the new VC's ephemeral chat # Announce in the new VC's ephemeral chat
if hasattr(new_vc, "send"): if hasattr(new_vc, "send"):
@ -186,39 +171,39 @@ async def on_voice_state_update(bot: commands.Bot, member: discord.Member, befor
) )
# 3) If user left a custom VC -> maybe its now empty # 3) If user left a custom VC -> maybe its now empty
if before.channel and before.channel.id in CUSTOM_VC_INFO: if before.channel and before.channel.id in self.CUSTOM_VC_INFO:
old_vc = before.channel old_vc = before.channel
if len(old_vc.members) == 0: if len(old_vc.members) == 0:
schedule_deletion(old_vc) self.schedule_deletion(old_vc)
# If user joined a custom VC that was pending deletion, cancel it # If user joined a custom VC that was pending deletion, cancel it
if after.channel and after.channel.id in CUSTOM_VC_INFO: if after.channel and after.channel.id in self.CUSTOM_VC_INFO:
if after.channel.id in PENDING_DELETIONS: if after.channel.id in self.PENDING_DELETIONS:
PENDING_DELETIONS[after.channel.id].cancel() self.PENDING_DELETIONS[after.channel.id].cancel()
del PENDING_DELETIONS[after.channel.id] del self.PENDING_DELETIONS[after.channel.id]
def schedule_deletion(vc: discord.VoiceChannel): def schedule_deletion(self, vc: discord.VoiceChannel):
""" """
Schedules this custom VC for deletion in 10s if it stays empty. Schedules this custom VC for deletion in 10s if it stays empty.
""" """
import asyncio import asyncio
async def delete_task(): async def delete_task():
await asyncio.sleep(DELETE_DELAY_SECONDS) await asyncio.sleep(self.DELETE_DELAY_SECONDS)
if len(vc.members) == 0: if len(vc.members) == 0:
CUSTOM_VC_INFO.pop(vc.id, None) self.CUSTOM_VC_INFO.pop(vc.id, None)
try: try:
await vc.delete() await vc.delete()
except: except:
pass pass
PENDING_DELETIONS.pop(vc.id, None) self.PENDING_DELETIONS.pop(vc.id, None)
loop = vc.guild._state.loop loop = vc.guild._state.loop
task = loop.create_task(delete_task()) task = loop.create_task(delete_task())
PENDING_DELETIONS[vc.id] = task self.PENDING_DELETIONS[vc.id] = task
async def can_create_vc(member: discord.Member) -> bool: async def can_create_vc(self, member: discord.Member) -> bool:
""" """
Return False if user or global rate-limits are hit: Return False if user or global rate-limits are hit:
- user can only create 1 channel every 5 minutes - user can only create 1 channel every 5 minutes
@ -226,36 +211,36 @@ async def can_create_vc(member: discord.Member) -> bool:
""" """
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()
last_time = USER_LAST_CREATED.get(member.id) last_time = self.USER_LAST_CREATED.get(member.id)
if last_time: if last_time:
diff = (now - last_time).total_seconds() diff = (now - last_time).total_seconds()
if diff < (USER_COOLDOWN_MINUTES * 60): if diff < (self.USER_COOLDOWN_MINUTES * 60):
return False return False
# global limit # global limit
cutoff = now - datetime.timedelta(seconds=60) cutoff = now - datetime.timedelta(seconds=60)
recent = [t for t in GLOBAL_CREATIONS if t > cutoff] recent = [t for t in self.GLOBAL_CREATIONS if t > cutoff]
if len(recent) >= GLOBAL_CHANNELS_PER_MINUTE: if len(recent) >= self.GLOBAL_CHANNELS_PER_MINUTE:
return False return False
return True return True
async def scan_existing_custom_vcs(bot: commands.Bot): async def scan_existing_custom_vcs(self):
""" """
On startup: check the custom VC category for leftover channels: On startup: check the custom VC category for leftover channels:
- If channel.id == LOBBY_VC_ID -> skip (don't delete the main lobby). - If channel.id == LOBBY_VC_ID -> skip (don't delete the main lobby).
- If empty, delete immediately. - If empty, delete immediately.
- If non-empty, first occupant is new owner. Mention them in ephemeral chat if possible. - If non-empty, first occupant is new owner. Mention them in ephemeral chat if possible.
""" """
for g in bot.guilds: for g in self.bot.guilds:
cat = g.get_channel(CUSTOM_VC_CATEGORY_ID) cat = g.get_channel(self.CUSTOM_VC_CATEGORY_ID)
if not cat or not isinstance(cat, discord.CategoryChannel): if not cat or not isinstance(cat, discord.CategoryChannel):
continue continue
for ch in cat.voice_channels: for ch in cat.voice_channels:
# skip the LOBBY # skip the LOBBY
if ch.id == LOBBY_VC_ID: if ch.id == self.LOBBY_VC_ID:
continue continue
if len(ch.members) == 0: if len(ch.members) == 0:
@ -264,13 +249,12 @@ async def scan_existing_custom_vcs(bot: commands.Bot):
await ch.delete() await ch.delete()
except: except:
pass pass
print(f"Deleted empty leftover channel: {ch.name}") globals.log(f"Deleted empty leftover channel: {ch.name}", "INFO")
else: else:
# pick first occupant # pick first occupant
first = ch.members[0] first = ch.members[0]
global CHANNEL_COUNTER self.CHANNEL_COUNTER += 1
CHANNEL_COUNTER += 1 self.CUSTOM_VC_INFO[ch.id] = {
CUSTOM_VC_INFO[ch.id] = {
"owner_id": first.id, "owner_id": first.id,
"locked": False, "locked": False,
"allowed_ids": set(), "allowed_ids": set(),
@ -278,7 +262,7 @@ async def scan_existing_custom_vcs(bot: commands.Bot):
"user_limit": None, "user_limit": None,
"bitrate": None, "bitrate": None,
} }
print(f"Assigned {first.display_name} as owner of leftover VC: {ch.name}") globals.log(f"Assigned {first.display_name} as owner of leftover VC: {ch.name}", "INFO")
if hasattr(ch, "send"): if hasattr(ch, "send"):
try: try:
await ch.send( await ch.send(
@ -289,11 +273,11 @@ async def scan_existing_custom_vcs(bot: commands.Bot):
pass pass
# #
# The subcommand logic below, referencing CUSTOM_VC_INFO # The subcommand logic below, referencing CUSTOM_VC_INFO
# #
def get_custom_vc_for(ctx: commands.Context) -> discord.VoiceChannel: def get_custom_vc_for(self, ctx: commands.Context) -> discord.VoiceChannel:
""" """
Return the custom voice channel the command user is currently in (or None). Return the custom voice channel the command user is currently in (or None).
We'll look at ctx.author.voice. We'll look at ctx.author.voice.
@ -304,30 +288,30 @@ def get_custom_vc_for(ctx: commands.Context) -> discord.VoiceChannel:
if not vs or not vs.channel: if not vs or not vs.channel:
return None return None
vc = vs.channel vc = vs.channel
if vc.id not in CUSTOM_VC_INFO: if vc.id not in self.CUSTOM_VC_INFO:
return None return None
return vc return vc
def is_initiator_or_mod(ctx: commands.Context, vc_id: int) -> bool: def is_initiator_or_mod(self, ctx: commands.Context, vc_id: int) -> bool:
info = CUSTOM_VC_INFO.get(vc_id) info = self.CUSTOM_VC_INFO.get(vc_id)
if not info: if not info:
return False return False
return has_custom_vc_permission(ctx.author, info["owner_id"]) return has_custom_vc_permission(ctx.author, info["owner_id"])
async def rename_channel(ctx: commands.Context, new_name: str): async def rename_channel_logic(self, ctx: commands.Context, new_name: str):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("You are not in a custom voice channel.") return await ctx.send("You are not in a custom voice channel.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to rename.") return await ctx.send("No permission to rename.")
await vc.edit(name=new_name) await vc.edit(name=new_name)
await ctx.send(f"Renamed channel to **{new_name}**.") await ctx.send(f"Renamed channel to **{new_name}**.")
async def claim_channel(ctx: commands.Context): async def claim_channel_logic(self, ctx: commands.Context):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
info = CUSTOM_VC_INFO.get(vc.id) info = self.CUSTOM_VC_INFO.get(vc.id)
if not info: if not info:
return await ctx.send("No memory for this channel?") return await ctx.send("No memory for this channel?")
@ -338,48 +322,48 @@ async def claim_channel(ctx: commands.Context):
info["owner_id"] = ctx.author.id info["owner_id"] = ctx.author.id
await ctx.send("You are now the owner of this channel!") await ctx.send("You are now the owner of this channel!")
async def lock_channel(ctx: commands.Context): async def lock_channel_logic(self, ctx: commands.Context):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("You're not in a custom VC.") return await ctx.send("You're not in a custom VC.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to lock.") return await ctx.send("No permission to lock.")
info = CUSTOM_VC_INFO[vc.id] info = self.CUSTOM_VC_INFO[vc.id]
info["locked"] = True info["locked"] = True
overwrites = vc.overwrites or {} overwrites = vc.overwrites or {}
overwrites[ctx.guild.default_role] = discord.PermissionOverwrite(connect=False) overwrites[ctx.guild.default_role] = discord.PermissionOverwrite(connect=False)
await vc.edit(overwrites=overwrites) await vc.edit(overwrites=overwrites)
await ctx.send("Channel locked.") await ctx.send("Channel locked.")
async def allow_user(ctx: commands.Context, user: str): async def allow_user_logic(self, ctx: commands.Context, user: str):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to allow a user.") return await ctx.send("No permission to allow a user.")
mem = await resolve_member(ctx, user) mem = await self.resolve_member(ctx, user)
if not mem: if not mem:
return await ctx.send(f"Could not find user: {user}.") return await ctx.send(f"Could not find user: {user}.")
info = CUSTOM_VC_INFO[vc.id] info = self.CUSTOM_VC_INFO[vc.id]
info["allowed_ids"].add(mem.id) info["allowed_ids"].add(mem.id)
overwrites = vc.overwrites or {} overwrites = vc.overwrites or {}
overwrites[mem] = discord.PermissionOverwrite(connect=True) overwrites[mem] = discord.PermissionOverwrite(connect=True)
await vc.edit(overwrites=overwrites) await vc.edit(overwrites=overwrites)
await ctx.send(f"Allowed **{mem.display_name}** to join.") await ctx.send(f"Allowed **{mem.display_name}** to join.")
async def deny_user(ctx: commands.Context, user: str): async def deny_user_logic(self, ctx: commands.Context, user: str):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to deny user.") return await ctx.send("No permission to deny user.")
mem = await resolve_member(ctx, user) mem = await self.resolve_member(ctx, user)
if not mem: if not mem:
return await ctx.send(f"Could not find user: {user}.") return await ctx.send(f"Could not find user: {user}.")
info = CUSTOM_VC_INFO[vc.id] info = self.CUSTOM_VC_INFO[vc.id]
# check if they're mod or owner # check if they're mod or owner
if has_custom_vc_permission(mem, info["owner_id"]): if has_custom_vc_permission(mem, info["owner_id"]):
return await ctx.send("Cannot deny a moderator or the owner.") return await ctx.send("Cannot deny a moderator or the owner.")
@ -390,14 +374,14 @@ async def deny_user(ctx: commands.Context, user: str):
await vc.edit(overwrites=overwrites) await vc.edit(overwrites=overwrites)
await ctx.send(f"Denied {mem.display_name} from connecting.") await ctx.send(f"Denied {mem.display_name} from connecting.")
async def unlock_channel(ctx: commands.Context): async def unlock_channel_logic(self, ctx: commands.Context):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to unlock.") return await ctx.send("No permission to unlock.")
info = CUSTOM_VC_INFO[vc.id] info = self.CUSTOM_VC_INFO[vc.id]
info["locked"] = False info["locked"] = False
overwrites = vc.overwrites or {} overwrites = vc.overwrites or {}
overwrites[ctx.guild.default_role] = discord.PermissionOverwrite(connect=True) overwrites[ctx.guild.default_role] = discord.PermissionOverwrite(connect=True)
@ -410,66 +394,66 @@ async def unlock_channel(ctx: commands.Context):
await vc.edit(overwrites=overwrites) await vc.edit(overwrites=overwrites)
await ctx.send("Unlocked the channel. Denied users still cannot join.") await ctx.send("Unlocked the channel. Denied users still cannot join.")
async def set_user_limit(ctx: commands.Context, limit: int): async def set_user_limit_logic(self, ctx: commands.Context, limit: int):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
if limit < 2: if limit < 2:
return await ctx.send("Minimum limit is 2.") return await ctx.send("Minimum limit is 2.")
if limit > 99: if limit > 99:
return await ctx.send("Maximum limit is 99.") return await ctx.send("Maximum limit is 99.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to set user limit.") return await ctx.send("No permission to set user limit.")
info = CUSTOM_VC_INFO[vc.id] info = self.CUSTOM_VC_INFO[vc.id]
info["user_limit"] = limit info["user_limit"] = limit
await vc.edit(user_limit=limit) await vc.edit(user_limit=limit)
await ctx.send(f"User limit set to {limit}.") await ctx.send(f"User limit set to {limit}.")
async def set_bitrate(ctx: commands.Context, kbps: int): async def set_bitrate_logic(self, ctx: commands.Context, kbps: int):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to set bitrate.") return await ctx.send("No permission to set bitrate.")
info = CUSTOM_VC_INFO[vc.id] info = self.CUSTOM_VC_INFO[vc.id]
info["bitrate"] = kbps info["bitrate"] = kbps
if kbps < 8 or kbps > 128: if kbps < 8 or kbps > 128:
return await ctx.send(f"Invalid bitrate of {kbps} kbps! Must be a number between `8` and `128`! Default is 64 kbps") return await ctx.send(f"Invalid bitrate of {kbps} kbps! Must be a number between `8` and `128`! Default is 64 kbps")
await vc.edit(bitrate=kbps * 1000) await vc.edit(bitrate=kbps * 1000)
await ctx.send(f"Bitrate set to {kbps} kbps.") await ctx.send(f"Bitrate set to {kbps} kbps.")
async def set_status(ctx: commands.Context, status_str: str): async def set_status_logic(self, ctx: commands.Context, status_str: str):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to set channel status.") return await ctx.send("No permission to set channel status.")
info = CUSTOM_VC_INFO[vc.id] info = self.CUSTOM_VC_INFO[vc.id]
info["status"] = status_str info["status"] = status_str
await ctx.send(f"Channel status set to: **{status_str}** (placeholder).") await ctx.send(f"Channel status set to: **{status_str}** (placeholder).")
async def op_user(ctx: commands.Context, user: str): async def op_user_logic(self, ctx: commands.Context, user: str):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
if not is_initiator_or_mod(ctx, vc.id): if not self.is_initiator_or_mod(ctx, vc.id):
return await ctx.send("No permission to op someone.") return await ctx.send("No permission to op someone.")
mem = await resolve_member(ctx, user) mem = await self.resolve_member(ctx, user)
if not mem: if not mem:
return await ctx.send(f"Could not find user: {user}.") return await ctx.send(f"Could not find user: {user}.")
info = CUSTOM_VC_INFO[vc.id] info = self.CUSTOM_VC_INFO[vc.id]
if "ops" not in info: if "ops" not in info:
info["ops"] = set() info["ops"] = set()
info["ops"].add(mem.id) info["ops"].add(mem.id)
await ctx.send(f"Granted co-ownership to {mem.display_name}.") await ctx.send(f"Granted co-ownership to {mem.display_name}.")
async def show_settings(ctx: commands.Context): async def show_settings_logic(self, ctx: commands.Context):
vc = get_custom_vc_for(ctx) vc = self.get_custom_vc_for(ctx)
if not vc: if not vc:
return await ctx.send("Not in a custom VC.") return await ctx.send("Not in a custom VC.")
info = CUSTOM_VC_INFO.get(vc.id) info = self.CUSTOM_VC_INFO.get(vc.id)
if not info: if not info:
return await ctx.send("No data found for this channel?") return await ctx.send("No data found for this channel?")
@ -496,7 +480,7 @@ async def show_settings(ctx: commands.Context):
) )
await ctx.send(msg) await ctx.send(msg)
async def resolve_member(ctx: commands.Context, user_str: str) -> discord.Member: async def resolve_member(ctx: commands.Context, user_str: str) -> discord.Member:
""" """
Attempt to parse user mention/ID/partial name. Attempt to parse user mention/ID/partial name.
""" """
@ -513,3 +497,6 @@ async def resolve_member(ctx: commands.Context, user_str: str) -> discord.Member
if m.name.lower() == lower or m.display_name.lower() == lower: if m.name.lower() == lower or m.display_name.lower() == lower:
return m return m
return None return None
async def setup(bot):
await bot.add_cog(CustomVCCog(bot))

View File

@ -1,14 +1,23 @@
# cmd_discord/howl.py # cmd_discord/funfact.py
import discord
from discord.ext import commands from discord.ext import commands
import cmd_common.common_commands as cc import cmd_common.common_commands as cc
def setup(bot): 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):
""" """
Registers the '!howl' command for Discord. Fetches a fun fact based on provided keywords and replies to the user.
""" """
@bot.command(name='funfact', aliases=['fun-fact'])
async def funfact_command(ctx, *keywords):
# keywords is a tuple of strings from the command arguments.
fact = cc.get_fun_fact(list(keywords)) fact = cc.get_fun_fact(list(keywords))
# Reply to the invoking user.
await ctx.reply(fact) await ctx.reply(fact)
# Required for loading the cog dynamically
async def setup(bot):
await bot.add_cog(FunfactCog(bot))

View File

@ -1,37 +1,30 @@
# cmd_discord/howl.py # cmd_discord/help.py
from discord.ext import commands
import discord import discord
from discord.ext import commands
from discord import app_commands from discord import app_commands
from typing import Optional from typing import Optional
import cmd_common.common_commands as cc from modules.utility import handle_help_command
import globals import globals
# Retrieve primary guild info if needed (for logging or other purposes) class HelpCog(commands.Cog):
primary_guild = globals.constants.primary_discord_guild() # e.g., {"object": discord.Object(id=1234567890), "id": 1234567890} """Handles the !help and /help commands."""
def setup(bot): def __init__(self, bot):
""" self.bot = bot
Registers the '!help' command for Discord. self.primary_guild = globals.constants.primary_discord_guild()
"""
@bot.command(name="help")
async def cmd_help_text(ctx, *, command: str = ""):
"""
Get help information about commands.
Usage: @commands.command(name="help")
- !help async def cmd_help_text(self, ctx, *, command: str = ""):
-> Provides a list of all commands with brief descriptions. """Handles text-based help command."""
- !help <command> result = await handle_help_command(ctx, command, self.bot, is_discord=True)
-> Provides detailed help information for the specified command.
"""
result = await cc.handle_help_command(ctx, command, bot, is_discord=True)
await ctx.reply(result) await ctx.reply(result)
# ------------------------------------------------------------------------- @app_commands.command(name="help", description="Get information about commands")
# SLASH COMMAND: help async def cmd_help_slash(self, interaction: discord.Interaction, command: Optional[str] = ""):
# ------------------------------------------------------------------------- """Handles slash command for help."""
@bot.tree.command(name="help", description="Get information about commands", guild=primary_guild["object"]) result = await handle_help_command(interaction, command, self.bot, is_discord=True)
@app_commands.describe(command="The command to get help info about. Defaults to 'help'")
async def cmd_help_slash(interaction: discord.Interaction, command: Optional[str] = ""):
result = await cc.handle_help_command(interaction, command, bot, is_discord=True)
await interaction.response.send_message(result) await interaction.response.send_message(result)
async def setup(bot):
await bot.add_cog(HelpCog(bot))

View File

@ -1,18 +1,20 @@
# cmd_discord/howl.py # cmd_discord/howl.py
import discord
from discord.ext import commands from discord.ext import commands
import cmd_common.common_commands as cc import cmd_common.common_commands as cc
def setup(bot): class HowlCog(commands.Cog):
""" """Cog for the '!howl' command."""
Registers the '!howl' command for Discord.
""" def __init__(self, bot):
@bot.command(name="howl") self.bot = bot
async def cmd_howl_text(ctx):
""" @commands.command(name="howl")
Handle the '!howl' command. async def cmd_howl_text(self, ctx):
Usage: """Handles the '!howl' command."""
- !howl -> Attempts a howl.
- !howl stat <user> -> Looks up howling stats for a user.
"""
result = cc.handle_howl_command(ctx) result = cc.handle_howl_command(ctx)
await ctx.reply(result) await ctx.reply(result)
async def setup(bot):
await bot.add_cog(HowlCog(bot))

View File

@ -1,21 +1,22 @@
# cmd_discord/howl.py # cmd_discord/ping.py
import discord
from discord.ext import commands from discord.ext import commands
import cmd_common.common_commands as cc import cmd_common.common_commands as cc
def setup(bot): class PingCog(commands.Cog):
""" """Handles the '!ping' command."""
Registers the '!ping' command for Discord.
"""
@bot.command(name="ping")
async def cmd_ping_text(ctx):
"""
Check the bot's uptime and latency.
Usage: def __init__(self, bot):
- !ping self.bot = bot
-> Returns the bot's uptime along with its latency in milliseconds.
""" @commands.command(name="ping")
async def cmd_ping_text(self, ctx):
"""Checks bot's uptime and latency."""
result = cc.ping() result = cc.ping()
latency = round(float(bot.latency) * 1000) latency = round(self.bot.latency * 1000)
result += f" (*latency: {latency}ms*)" result += f" (*latency: {latency}ms*)"
await ctx.reply(result) await ctx.reply(result)
async def setup(bot):
await bot.add_cog(PingCog(bot))

View File

@ -1,41 +1,26 @@
# cmd_discord/howl.py # cmd_discord/quote.py
import discord
from discord.ext import commands from discord.ext import commands
import globals import globals
import cmd_common.common_commands as cc import cmd_common.common_commands as cc
def setup(bot): class QuoteCog(commands.Cog):
""" """Handles the '!quote' command."""
Registers the '!quote' command for Discord.
"""
@bot.command(name="quote")
async def cmd_quote_text(ctx, *, arg_str: str = ""):
"""
Handle the '!quote' command with multiple subcommands.
Usage: def __init__(self, bot):
- !quote self.bot = bot
-> Retrieves a random (non-removed) quote.
- !quote <number> @commands.command(name="quote")
-> Retrieves a specific quote by its ID. async def cmd_quote_text(self, ctx, *, arg_str: str = ""):
- !quote add <quote text> """Handles various quote-related subcommands."""
-> 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 latest
-> Retrieves the latest (most recent) non-removed quote.
"""
if not globals.init_db_conn: if not globals.init_db_conn:
await ctx.reply("Database is unavailable, sorry.") await ctx.reply("Database is unavailable, sorry.")
return return
args = arg_str.split() if arg_str else [] args = arg_str.split() if arg_str else []
globals.log(f"'quote' command initiated with arguments: {args}", "DEBUG") globals.log(f"'quote' command initiated with arguments: {args}", "DEBUG")
result = await cc.handle_quote_command( result = await cc.handle_quote_command(
db_conn=globals.init_db_conn, db_conn=globals.init_db_conn,
is_discord=True, is_discord=True,
@ -43,8 +28,12 @@ def setup(bot):
args=args, args=args,
game_name=None game_name=None
) )
globals.log(f"'quote' result: {result}", "DEBUG") globals.log(f"'quote' result: {result}", "DEBUG")
if hasattr(result, "to_dict"): if hasattr(result, "to_dict"):
await ctx.reply(embed=result) await ctx.reply(embed=result)
else: else:
await ctx.reply(result) await ctx.reply(result)
async def setup(bot):
await bot.add_cog(QuoteCog(bot))

View File

@ -532,5 +532,6 @@
"Cows are related to whales, as they both evolved from land-dwelling, even-toed ungulates, hence why a baby whale is called a 'calf'.", "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.", "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.", "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." "If Jenni were to bury Kami's dead body in the back of the garden, she wouldn't be able to get out of the hole without a ladder.",
"Aibophobia is the suggested name for a fear of words that can be reversed without changing the word. Aibophobia itself can be reversed."
] ]

View File

@ -61,6 +61,65 @@
"ping" "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": { "howl": {
"description": "Attempt a howl, measured 0-100%.\n(*Adventure Command*)", "description": "Attempt a howl, measured 0-100%.\n(*Adventure Command*)",
"subcommands": { "subcommands": {
@ -102,5 +161,4 @@
] ]
} }
} }
} }

View File

@ -4,6 +4,7 @@ import sys
import traceback import traceback
import discord import discord
import inspect import inspect
import os
# Store the start time globally. # Store the start time globally.
_bot_start_time = time.time() _bot_start_time = time.time()
@ -52,6 +53,35 @@ def load_config_file():
# Load configuration file # Load configuration file
config_data = load_config_file() config_data = load_config_file()
def load_settings_file(file: str):
"""
Load a settings file from the settings directory.
Args:
file (str): The name of the settings file, with or without the .json extension.
Returns:
dict: The configuration data loaded from the specified settings file.
"""
SETTINGS_PATH = "settings"
# Ensure the file has a .json extension
if not file.endswith(".json"):
file += ".json"
file_path = os.path.join(SETTINGS_PATH, file)
if not os.path.exists(file_path):
log(f"Unable to read the settings file {file}!", "FATAL")
try:
with open(file_path, "r", encoding="utf-8") as f:
config_data = json.load(f)
return config_data
except json.JSONDecodeError as e:
log(f"Error parsing {file}: {e}", "FATAL")
############################### ###############################
# Simple Logging System # Simple Logging System
############################### ###############################

View File

@ -3,6 +3,7 @@ import os
import re import re
import time, datetime import time, datetime
import sqlite3 import sqlite3
import uuid
import globals import globals
@ -514,40 +515,20 @@ def lookup_user(db_conn, identifier: str, identifier_type: str, target_identifie
return user_data return user_data
def ensure_chatlog_table(db_conn): def ensure_chatlog_table(db_conn):
""" """
Checks if 'chat_log' table exists. If not, creates it. Ensures the 'chat_log' table exists, updating the schema to use a UUID primary key
and an additional column for platform-specific message IDs.
The table layout:
MESSAGE_ID (PK, auto increment)
UUID (references users.UUID, if you want a foreign key, see note below)
MESSAGE_CONTENT (text)
PLATFORM (string, e.g. 'twitch' or discord server name)
CHANNEL (the twitch channel or discord channel name)
DATETIME (defaults to current timestamp)
ATTACHMENTS (text; store hyperlink(s) or empty)
For maximum compatibility, we won't enforce the foreign key at the DB level,
but you can add it if you want.
""" """
is_sqlite = "sqlite3" in str(type(db_conn)).lower() is_sqlite = "sqlite3" in str(type(db_conn)).lower()
# 1) Check if table exists # Check if table exists
if is_sqlite:
check_sql = """ check_sql = """
SELECT name SELECT name FROM sqlite_master WHERE type='table' AND name='chat_log'
FROM sqlite_master """ if is_sqlite else """
WHERE type='table' SELECT table_name FROM information_schema.tables
AND name='chat_log' WHERE table_name = 'chat_log' AND table_schema = DATABASE()
"""
else:
check_sql = """
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'chat_log'
AND table_schema = DATABASE()
""" """
rows = run_db_operation(db_conn, "read", check_sql) rows = run_db_operation(db_conn, "read", check_sql)
@ -555,33 +536,32 @@ def ensure_chatlog_table(db_conn):
globals.log("Table 'chat_log' already exists, skipping creation.", "DEBUG") globals.log("Table 'chat_log' already exists, skipping creation.", "DEBUG")
return return
# 2) Table doesn't exist => create it # Table does not exist, create it
globals.log("Table 'chat_log' does not exist; creating now...") globals.log("Table 'chat_log' does not exist; creating now...", "INFO")
if is_sqlite:
create_sql = """ create_sql = """
CREATE TABLE chat_log ( CREATE TABLE chat_log (
MESSAGE_ID INTEGER PRIMARY KEY AUTOINCREMENT, UUID TEXT PRIMARY KEY,
UUID TEXT, PLATFORM_MESSAGE_ID TEXT DEFAULT NULL,
USER_UUID TEXT,
MESSAGE_CONTENT TEXT, MESSAGE_CONTENT TEXT,
PLATFORM TEXT, PLATFORM TEXT,
CHANNEL TEXT, CHANNEL TEXT,
DATETIME TEXT DEFAULT CURRENT_TIMESTAMP, DATETIME TEXT DEFAULT CURRENT_TIMESTAMP,
ATTACHMENTS TEXT, ATTACHMENTS TEXT,
FOREIGN KEY (UUID) REFERENCES users(UUID) FOREIGN KEY (USER_UUID) REFERENCES users(UUID)
) )
""" """ if is_sqlite else """
else:
create_sql = """
CREATE TABLE chat_log ( CREATE TABLE chat_log (
MESSAGE_ID INT PRIMARY KEY AUTO_INCREMENT, UUID VARCHAR(36) PRIMARY KEY,
UUID VARCHAR(36), PLATFORM_MESSAGE_ID VARCHAR(100) DEFAULT NULL,
USER_UUID VARCHAR(36),
MESSAGE_CONTENT TEXT, MESSAGE_CONTENT TEXT,
PLATFORM VARCHAR(100), PLATFORM VARCHAR(100),
CHANNEL VARCHAR(100), CHANNEL VARCHAR(100),
DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP, DATETIME DATETIME DEFAULT CURRENT_TIMESTAMP,
ATTACHMENTS TEXT, ATTACHMENTS TEXT,
FOREIGN KEY (UUID) REFERENCES users(UUID) ON DELETE SET NULL FOREIGN KEY (USER_UUID) REFERENCES users(UUID) ON DELETE SET NULL
) )
""" """
@ -594,17 +574,30 @@ def ensure_chatlog_table(db_conn):
globals.log("Successfully created table 'chat_log'.", "INFO") globals.log("Successfully created table 'chat_log'.", "INFO")
def log_message(db_conn, identifier, identifier_type, message_content, platform, channel, attachments=None):
def log_message(db_conn, identifier, identifier_type, message_content, platform, channel, attachments=None, platform_message_id=None):
""" """
Logs a message in 'chat_log' with UUID fetched using the new Platform_Mapping structure. Logs a message in 'chat_log' with UUID fetched using the Platform_Mapping structure.
- Uses a UUID as the primary key for uniqueness across platforms.
- Stores platform-specific message IDs when provided.
- Logs a warning if a message ID is expected but not provided.
""" """
# Get UUID using the updated lookup_user
# Get UUID using lookup_user
user_data = lookup_user(db_conn, identifier, identifier_type) user_data = lookup_user(db_conn, identifier, identifier_type)
if not user_data: if not user_data:
globals.log(f"User not found for {identifier_type}='{identifier}'", "WARNING") globals.log(f"User not found for {identifier_type}='{identifier}'", "WARNING")
return return
user_uuid = user_data["UUID"] user_uuid = user_data["UUID"]
message_uuid = str(uuid.uuid4()) # Generate a new UUID for the entry
# Determine if a message ID is required for this platform
requires_message_id = platform.startswith("discord") or platform == "twitch"
if requires_message_id and not platform_message_id:
globals.log(f"Warning: Platform '{platform}' usually requires a message ID, but none was provided.", "WARNING")
if attachments is None or not "https://" in attachments: if attachments is None or not "https://" in attachments:
attachments = "" attachments = ""
@ -612,17 +605,19 @@ def log_message(db_conn, identifier, identifier_type, message_content, platform,
insert_sql = """ insert_sql = """
INSERT INTO chat_log ( INSERT INTO chat_log (
UUID, UUID,
PLATFORM_MESSAGE_ID,
USER_UUID,
MESSAGE_CONTENT, MESSAGE_CONTENT,
PLATFORM, PLATFORM,
CHANNEL, CHANNEL,
ATTACHMENTS ATTACHMENTS
) VALUES (?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?)
""" """
params = (user_uuid, message_content, platform, channel, attachments) params = (message_uuid, platform_message_id, user_uuid, message_content, platform, channel, attachments)
rowcount = run_db_operation(db_conn, "write", insert_sql, params) rowcount = run_db_operation(db_conn, "write", insert_sql, params)
if rowcount and rowcount > 0: if rowcount and rowcount > 0:
globals.log(f"Logged message for UUID={user_uuid}.", "DEBUG") globals.log(f"Logged message for UUID={user_uuid} with Message UUID={message_uuid}.", "DEBUG")
else: else:
globals.log("Failed to log message in 'chat_log'.", "ERROR") globals.log("Failed to log message in 'chat_log'.", "ERROR")

View File

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