diff --git a/cmd_discord/customvc.py b/cmd_discord/customvc.py index 73b61ac..384113a 100644 --- a/cmd_discord/customvc.py +++ b/cmd_discord/customvc.py @@ -14,7 +14,7 @@ import discord -from discord.ext import commands +from discord.ext import commands, tasks import datetime import globals @@ -35,11 +35,112 @@ class CustomVCCog(commands.Cog): 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": , + "autoname": , + "locked": , + "allowed_ids": set(), + "denied_ids": set(), + "user_limit": , + "bitrate": , + "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.""" @@ -145,67 +246,157 @@ class CustomVCCog(commands.Cog): # 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: - # 1) If user just joined the lobby + 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): - # Rate-limit => forcibly disconnect + # forcibly disconnect await member.move_to(None) - # Also mention them in the ephemeral chat of LOBBY_VC_ID if we want (if possible) + 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"): - # We'll try to mention them - await lobby_chan.send(f"{member.mention}, you’ve exceeded custom VC creation limits. Try again later!") + await lobby_chan.send( + f"{member.mention}, you’ve exceeded custom VC creation limits. Try again later!" + ) return - # 2) Create a new custom VC - self.CHANNEL_COUNTER += 1 - vc_name = f"VC {self.CHANNEL_COUNTER}" - if self.CHANNEL_COUNTER > self.MAX_CUSTOM_CHANNELS: - vc_name = f"Overflow {self.CHANNEL_COUNTER}" - + # Identify category category = member.guild.get_channel(self.CUSTOM_VC_CATEGORY_ID) if not category or not isinstance(category, discord.CategoryChannel): - logger.info("Could not find a valid custom VC category.") + logger.error( + f"[CustomVC] Could not find valid category for guild {member.guild.id}. Aborting creation." + ) return - new_vc = await category.create_voice_channel(name=vc_name) + # 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) - # Record memory + 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 } - now = datetime.datetime.utcnow() - self.USER_LAST_CREATED[member.id] = now - self.GLOBAL_CREATIONS.append(now) - # prune old - cutoff = now - datetime.timedelta(seconds=60) - self.GLOBAL_CREATIONS[:] = [t for t in self.GLOBAL_CREATIONS if t > cutoff] - - # Announce in the new VC's ephemeral chat if hasattr(new_vc, "send"): await new_vc.send( - f"{member.name}, your custom voice channel is ready! " + f"{member.display_name}, your custom voice channel is ready! " "Type `!customvc` here for help with subcommands." ) - await self.update_channel_name(member, after.channel) - - # 3) If user left a custom VC -> maybe it’s now empty + # If 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) - # If user joined a custom VC that was pending deletion, cancel it + # 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() @@ -214,29 +405,32 @@ class CustomVCCog(commands.Cog): 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 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: - pass + 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 5 minutes - - globally only 2 channels per minute + - user can only create 1 channel every X minutes + - globally only Y channels per minute """ now = datetime.datetime.utcnow() @@ -244,12 +438,19 @@ class CustomVCCog(commands.Cog): 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 @@ -258,45 +459,54 @@ class CustomVCCog(commands.Cog): async def scan_existing_custom_vcs(self): """ On startup: check the custom VC category for leftover channels: - - If channel.id == LOBBY_VC_ID -> skip (don't delete the main lobby). - - If empty, delete immediately. - - If non-empty, first occupant is new owner. Mention them in ephemeral chat if possible. + - 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: - # skip the LOBBY if ch.id == self.LOBBY_VC_ID: continue - if len(ch.members) == 0: # safe to delete try: await ch.delete() - except: - pass - logger.info(f"Deleted empty leftover channel: {ch.name}") + 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 + # 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"Assigned {first.display_name} as owner of leftover VC: {ch.name}") + 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." + 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 @@ -337,20 +547,48 @@ class CustomVCCog(commands.Cog): await ctx.send(f"Renamed channel to **{new_name}**.") @customvc.command(name="autoname") - async def enable_autoname(self, ctx: commands.Context): - """Enables automatic channel naming based on the owner's game.""" + 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: - return await ctx.send("Not in a custom VC.") + await ctx.send("You are not in a custom voice channel.") + return if not self.is_initiator_or_mod(ctx, vc.id): - return await ctx.send("No permission to enable auto-naming.") + await ctx.send("No permission to toggle auto-naming.") + return info = self.CUSTOM_VC_INFO[vc.id] - info["autoname"] = True + 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) - await self.update_channel_name(ctx.author, vc) - await ctx.send("Auto-naming enabled! Your channel will update based on the game you're playing.") + + 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) @@ -510,24 +748,59 @@ class CustomVCCog(commands.Cog): info["status"] = status_str await ctx.send(f"Channel status set to: **{status_str}** (placeholder).") - async def update_channel_name(self, member: discord.Member, vc: discord.VoiceChannel): - """Dynamically renames VC based on the user's game or predefined rules.""" + 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 - game_name = None - if member.activities: - for activity in member.activities: - if isinstance(activity, discord.Game): - game_name = activity.name - break + 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 - if game_name: - new_name = f"{game_name}" + 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: - new_name = f"{member.display_name}'s Channel" + # 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)." + ) - await vc.edit(name=new_name, reason="Automatic VC Naming") async def op_user_logic(self, ctx: commands.Context, user: str): vc = self.get_custom_vc_for(ctx) @@ -552,6 +825,7 @@ class CustomVCCog(commands.Cog): 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)" @@ -566,6 +840,7 @@ class CustomVCCog(commands.Cog): 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" diff --git a/globals.py b/globals.py index c3e5cd4..2b19ce3 100644 --- a/globals.py +++ b/globals.py @@ -197,7 +197,7 @@ class Logger: Retrieves the calling function's name using `inspect`. """ try: - caller_frame = inspect.stack()[2] + caller_frame = inspect.stack()[3] return caller_frame.function except Exception: return "Unknown"