diff --git a/info.json b/info.json index 3321cee..f2f3baf 100644 --- a/info.json +++ b/info.json @@ -3,6 +3,6 @@ "install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.", "name": "EveCogs", "short": "A collection of advanced cogs for Red-DiscordBot.", - "description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), and NewHelpMenu (Components V2 help overhaul).", + "description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), and SelfbotGuard (advanced selfbot detection).", "type": "REPO" } diff --git a/voicemeister/__init__.py b/voicemeister/__init__.py new file mode 100644 index 0000000..1de4f39 --- /dev/null +++ b/voicemeister/__init__.py @@ -0,0 +1,14 @@ +"""VoiceMeister — Ultimate temporary voice channel management for Red-DiscordBot.""" + +from .voicemeister import VoiceMeister + +__red_end_user_data_statement__ = ( + "This cog stores Discord user IDs, guild IDs, and channel IDs for voice channel " + "ownership and settings. Per-user data includes channel ownership, ban/permit lists, " + "and usage cooldowns. Data is cleaned up when channels are deleted." +) + + +async def setup(bot): + cog = VoiceMeister(bot) + await bot.add_cog(cog) diff --git a/voicemeister/info.json b/voicemeister/info.json new file mode 100644 index 0000000..8289cb7 --- /dev/null +++ b/voicemeister/info.json @@ -0,0 +1,32 @@ +{ + "author": [ + "everestmcarthur" + ], + "install_msg": "Thanks for installing **VoiceMeister v1.0.0** — the ultimate temporary voice channel system!\n\n**Quick Start:**\n• `[p]voicemeister setup` — Interactive setup wizard\n• `[p]voicemeister creator add #channel` — Add a Join-to-Create channel\n• `[p]voicemeister panel #channel` — Send the control panel\n• `[p]vc` — User voice channel commands\n\n**Panel Controls:** Lock, Unlock, Hide, Unhide, Rename, User Limit, Kick, Ban, Permit, Reject, Claim, Transfer, Bitrate, Info, Ghost (hide + lock), and Delete.\n\n**Name Templates:** `{user}`, `{game}`, `{count}`, `{custom}`\n\nFull help: `[p]help VoiceMeister`", + "name": "VoiceMeister", + "disabled": false, + "short": "Ultimate temporary voice channels: Join-to-Create, full control panel with buttons, name templates, game detection, logging & more.", + "description": "VoiceMeister v1.0.0 is a comprehensive temporary voice channel management system. Features: Join-to-Create channels that auto-spawn personal voice channels, a persistent button control panel (Lock/Unlock, Hide/Unhide, Rename, User Limit, Kick, Ban, Permit, Reject, Claim, Transfer, Bitrate, Info, Ghost, Delete), name templates with {user}/{game}/{count}/{custom} variables, automatic game-activity channel renaming, auto-cleanup when channels empty, logging to a dedicated channel, blacklist/whitelist system, per-user channel limits, cooldowns, default settings per creator, category management, and full slash-compatible hybrid commands. All views are persistent and survive bot restarts.", + "tags": [ + "voice", + "voicemeister", + "temporary", + "jtc", + "join-to-create", + "voice-channels", + "panel", + "control", + "management", + "temp-voice", + "dynamic-voice" + ], + "type": "COG", + "end_user_data_statement": "This cog stores Discord user IDs, guild IDs, and channel IDs for voice channel ownership and settings. Per-user data includes channel ownership, ban/permit lists, and usage cooldowns. Data is cleaned up when channels are deleted.", + "min_bot_version": "3.5.0", + "min_python_version": [ + 3, + 9, + 0 + ], + "requirements": [] +} diff --git a/voicemeister/utils.py b/voicemeister/utils.py new file mode 100644 index 0000000..2113375 --- /dev/null +++ b/voicemeister/utils.py @@ -0,0 +1,193 @@ +"""VoiceMeister — shared utilities, embed builders, helpers.""" + +from __future__ import annotations + +import datetime +from typing import Optional + +import discord + + +# ── Colour palette ───────────────────────────────────────────────────────── +class Clr: + PRIMARY = discord.Colour(0x5865F2) # blurple + SUCCESS = discord.Colour(0x57F287) # green + WARNING = discord.Colour(0xFEE75C) # yellow + ERROR = discord.Colour(0xED4245) # red + INFO = discord.Colour(0x3498DB) # light blue + VOICE = discord.Colour(0x9B59B6) # purple — main theme + PANEL = discord.Colour(0x2F3136) # dark — panel background + + +# ── Quick embeds ─────────────────────────────────────────────────────────── +def ok_embed(desc: str, *, title: str | None = None) -> discord.Embed: + e = discord.Embed(description=f"✅ {desc}", colour=Clr.SUCCESS) + if title: + e.title = title + return e + + +def err_embed(desc: str, *, title: str | None = None) -> discord.Embed: + e = discord.Embed(description=f"❌ {desc}", colour=Clr.ERROR) + if title: + e.title = title + return e + + +def info_embed(desc: str, *, title: str | None = None) -> discord.Embed: + e = discord.Embed(description=desc, colour=Clr.INFO) + if title: + e.title = title + return e + + +def warn_embed(desc: str, *, title: str | None = None) -> discord.Embed: + e = discord.Embed(description=f"⚠️ {desc}", colour=Clr.WARNING) + if title: + e.title = title + return e + + +# ── Timestamp helpers ────────────────────────────────────────────────────── +def ts_now() -> int: + return int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + + +def ts_relative(ts: int) -> str: + return f"" + + +# ── Name template renderer ──────────────────────────────────────────────── +def render_name( + template: str, + *, + member: discord.Member, + count: int = 1, + custom: str = "", +) -> str: + """Render a channel name template with variable substitution.""" + game = None + for activity in member.activities: + if activity.type == discord.ActivityType.playing: + game = activity.name + break + + name = template.replace("{user}", member.display_name) + name = name.replace("{game}", game or "Chilling") + name = name.replace("{count}", str(count)) + name = name.replace("{custom}", custom or member.display_name) + + # Discord channel name limit is 100 chars + return name[:100] + + +# ── Permission helpers ───────────────────────────────────────────────────── +def is_channel_owner(member: discord.Member, owner_id: int) -> bool: + return member.id == owner_id + + +def can_manage(member: discord.Member, owner_id: int) -> bool: + """Check if member is channel owner or has Manage Channels.""" + return member.id == owner_id or member.guild_permissions.manage_channels + + +async def safe_send(dest, content=None, **kwargs) -> discord.Message | None: + try: + return await dest.send(content, **kwargs) + except (discord.HTTPException, discord.Forbidden): + return None + + +async def safe_dm(user: discord.User, content=None, **kwargs) -> discord.Message | None: + try: + return await user.send(content, **kwargs) + except (discord.HTTPException, discord.Forbidden): + return None + + +# ── Panel embed builder ─────────────────────────────────────────────────── +def build_panel_embed(guild: discord.Guild) -> discord.Embed: + """Build the main control panel embed.""" + embed = discord.Embed( + title="🎙️ VoiceMeister — Channel Control Panel", + description=( + "Click any button below to manage your temporary voice channel.\n" + "You must be *in* a VoiceMeister channel and be its *owner* (or have Manage Channels).\n\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + ), + colour=Clr.VOICE, + ) + + embed.add_field( + name="🔐 Access Controls", + value=( + "🔒 **Lock** — Prevent others from joining\n" + "🔓 **Unlock** — Allow anyone to join\n" + "👻 **Ghost** — Hide + Lock combined\n" + "👁️ **Reveal** — Unhide + Unlock combined\n" + "👤 **Hide** — Make invisible in channel list\n" + "👁️‍🗨️ **Unhide** — Make visible again" + ), + inline=True, + ) + + embed.add_field( + name="👥 User Management", + value=( + "➕ **Permit** — Allow a specific user\n" + "➖ **Reject** — Block a specific user\n" + "👢 **Kick** — Kick a user from channel\n" + "🔨 **Ban** — Permanently block a user\n" + "🔇 **Mute All** — Server-mute everyone\n" + "🔊 **Unmute All** — Remove server-mute" + ), + inline=True, + ) + + embed.add_field(name="\u200b", value="\u200b", inline=False) + + embed.add_field( + name="⚙️ Channel Settings", + value=( + "✏️ **Rename** — Change channel name\n" + "👥 **Limit** — Set user limit (0 = ∞)\n" + "📡 **Bitrate** — Change audio quality\n" + "🌍 **Region** — Set voice region" + ), + inline=True, + ) + + embed.add_field( + name="👑 Ownership", + value=( + "👑 **Claim** — Claim if owner left\n" + "🔄 **Transfer** — Give ownership to another\n" + "ℹ️ **Info** — View channel details\n" + "🗑️ **Delete** — Destroy the channel" + ), + inline=True, + ) + + embed.set_footer(text="VoiceMeister v1.0.0 • Buttons persist across restarts") + return embed + + +# ── Log embed builder ───────────────────────────────────────────────────── +def log_embed( + action: str, + *, + member: discord.Member, + channel: discord.VoiceChannel | None = None, + detail: str = "", +) -> discord.Embed: + """Build a log embed for voice actions.""" + embed = discord.Embed( + description=f"**{action}**\n{detail}" if detail else f"**{action}**", + colour=Clr.VOICE, + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author(name=str(member), icon_url=member.display_avatar.url) + if channel: + embed.add_field(name="Channel", value=channel.mention, inline=True) + embed.set_footer(text=f"User ID: {member.id}") + return embed diff --git a/voicemeister/views.py b/voicemeister/views.py new file mode 100644 index 0000000..4938da9 --- /dev/null +++ b/voicemeister/views.py @@ -0,0 +1,699 @@ +"""VoiceMeister — Persistent views, buttons, modals, and select menus.""" + +from __future__ import annotations + +import asyncio +from typing import Optional, TYPE_CHECKING + +import discord +from discord import ui + +from .utils import ( + Clr, ok_embed, err_embed, info_embed, warn_embed, + is_channel_owner, can_manage, ts_relative, ts_now, +) + +if TYPE_CHECKING: + from .voicemeister import VoiceMeister + + +# ══════════════════════════════════════════════════════════════════════════════ +# HELPER — get the cog + validate the interaction +# ══════════════════════════════════════════════════════════════════════════════ + +async def _get_context(interaction: discord.Interaction): + """Return (cog, member, voice_channel, owner_id) or send error and return None tuple.""" + cog: VoiceMeister | None = interaction.client.get_cog("VoiceMeister") + if cog is None: + await interaction.response.send_message( + embed=err_embed("VoiceMeister cog is not loaded."), ephemeral=True, + ) + return None, None, None, None + + member = interaction.user + if not isinstance(member, discord.Member): + await interaction.response.send_message( + embed=err_embed("Could not resolve you as a guild member."), ephemeral=True, + ) + return None, None, None, None + + vc = member.voice.channel if member.voice else None + if vc is None: + await interaction.response.send_message( + embed=err_embed("You must be **in a voice channel** to use this."), ephemeral=True, + ) + return None, None, None, None + + owner_id = cog.temp_channels.get(vc.id) + if owner_id is None: + await interaction.response.send_message( + embed=err_embed("Your current voice channel is **not** a VoiceMeister channel."), + ephemeral=True, + ) + return None, None, None, None + + return cog, member, vc, owner_id + + +async def _require_owner(interaction: discord.Interaction): + """Like _get_context but also requires ownership or Manage Channels.""" + cog, member, vc, owner_id = await _get_context(interaction) + if cog is None: + return None, None, None, None + + if not can_manage(member, owner_id): + await interaction.response.send_message( + embed=err_embed("Only the channel **owner** (or a moderator) can do this."), + ephemeral=True, + ) + return None, None, None, None + + return cog, member, vc, owner_id + + +# ══════════════════════════════════════════════════════════════════════════════ +# MODALS +# ══════════════════════════════════════════════════════════════════════════════ + +class RenameModal(ui.Modal, title="✏️ Rename Channel"): + new_name = ui.TextInput( + label="New channel name", + placeholder="e.g. Gaming Lounge", + max_length=100, + style=discord.TextStyle.short, + ) + + async def on_submit(self, interaction: discord.Interaction): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + + try: + await vc.edit(name=str(self.new_name), reason=f"VoiceMeister: renamed by {member}") + await interaction.response.send_message( + embed=ok_embed(f"Channel renamed to **{self.new_name}**."), ephemeral=True, + ) + await cog._log_action(vc.guild, "✏️ Channel Renamed", member=member, channel=vc, + detail=f"New name: `{self.new_name}`") + except discord.HTTPException as e: + await interaction.response.send_message( + embed=err_embed(f"Failed to rename: {e}"), ephemeral=True, + ) + + +class LimitModal(ui.Modal, title="👥 Set User Limit"): + limit = ui.TextInput( + label="User limit (0 = unlimited)", + placeholder="e.g. 5", + max_length=3, + style=discord.TextStyle.short, + ) + + async def on_submit(self, interaction: discord.Interaction): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + + try: + val = int(str(self.limit)) + if val < 0 or val > 99: + raise ValueError + except ValueError: + await interaction.response.send_message( + embed=err_embed("Enter a number between **0** and **99**."), ephemeral=True, + ) + return + + try: + await vc.edit(user_limit=val, reason=f"VoiceMeister: limit set by {member}") + label = f"**{val}**" if val > 0 else "**unlimited**" + await interaction.response.send_message( + embed=ok_embed(f"User limit set to {label}."), ephemeral=True, + ) + await cog._log_action(vc.guild, "👥 Limit Changed", member=member, channel=vc, + detail=f"New limit: {label}") + except discord.HTTPException as e: + await interaction.response.send_message( + embed=err_embed(f"Failed to set limit: {e}"), ephemeral=True, + ) + + +class BitrateModal(ui.Modal, title="📡 Set Bitrate"): + bitrate = ui.TextInput( + label="Bitrate in kbps (8–384)", + placeholder="e.g. 96", + max_length=3, + style=discord.TextStyle.short, + ) + + async def on_submit(self, interaction: discord.Interaction): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + + try: + val = int(str(self.bitrate)) + max_br = interaction.guild.bitrate_limit // 1000 + if val < 8 or val > max_br: + raise ValueError + except (ValueError, AttributeError): + max_br = interaction.guild.bitrate_limit // 1000 if interaction.guild else 96 + await interaction.response.send_message( + embed=err_embed(f"Enter a number between **8** and **{max_br}** kbps."), + ephemeral=True, + ) + return + + try: + await vc.edit(bitrate=val * 1000, reason=f"VoiceMeister: bitrate set by {member}") + await interaction.response.send_message( + embed=ok_embed(f"Bitrate set to **{val} kbps**."), ephemeral=True, + ) + await cog._log_action(vc.guild, "📡 Bitrate Changed", member=member, channel=vc, + detail=f"New bitrate: {val} kbps") + except discord.HTTPException as e: + await interaction.response.send_message( + embed=err_embed(f"Failed to set bitrate: {e}"), ephemeral=True, + ) + + +class RegionModal(ui.Modal, title="🌍 Set Voice Region"): + region = ui.TextInput( + label="Region (e.g. us-east, eu-west, auto)", + placeholder="auto", + max_length=30, + style=discord.TextStyle.short, + required=False, + ) + + async def on_submit(self, interaction: discord.Interaction): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + + region_str = str(self.region).strip().lower() if self.region else "" + rtc_region = None if region_str in ("auto", "") else region_str + + try: + await vc.edit(rtc_region=rtc_region, reason=f"VoiceMeister: region set by {member}") + label = rtc_region or "Automatic" + await interaction.response.send_message( + embed=ok_embed(f"Voice region set to **{label}**."), ephemeral=True, + ) + await cog._log_action(vc.guild, "🌍 Region Changed", member=member, channel=vc, + detail=f"New region: {label}") + except discord.HTTPException as e: + await interaction.response.send_message( + embed=err_embed(f"Failed to set region: {e}"), ephemeral=True, + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# USER SELECT MENUS (for Kick, Ban, Permit, Reject, Transfer) +# ══════════════════════════════════════════════════════════════════════════════ + +class UserActionView(ui.View): + """Ephemeral view with a user select for a specific action.""" + + def __init__(self, action: str): + super().__init__(timeout=60) + self.action = action + select = UserActionSelect(action=action) + self.add_item(select) + + +class UserActionSelect(ui.UserSelect): + def __init__(self, action: str): + labels = { + "kick": "Select user to kick", + "ban": "Select user to ban", + "permit": "Select user to permit", + "reject": "Select user to reject", + "transfer": "Select new owner", + } + super().__init__( + placeholder=labels.get(action, "Select a user"), + min_values=1, + max_values=1, + ) + self.action = action + + async def callback(self, interaction: discord.Interaction): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + + target = self.values[0] + if not isinstance(target, discord.Member): + target = interaction.guild.get_member(target.id) + if target is None: + await interaction.response.send_message( + embed=err_embed("Could not find that member."), ephemeral=True, + ) + return + + if target.id == member.id: + await interaction.response.send_message( + embed=err_embed("You can't target yourself."), ephemeral=True, + ) + return + + if self.action == "kick": + if target.voice and target.voice.channel == vc: + await target.move_to(None, reason=f"VoiceMeister: kicked by {member}") + await interaction.response.send_message( + embed=ok_embed(f"Kicked {target.mention} from the channel."), ephemeral=True, + ) + await cog._log_action(vc.guild, "👢 User Kicked", member=member, channel=vc, + detail=f"Target: {target} ({target.id})") + else: + await interaction.response.send_message( + embed=err_embed(f"{target.mention} is not in your channel."), ephemeral=True, + ) + + elif self.action == "ban": + # Set permission override to deny connect + await vc.set_permissions(target, connect=False, view_channel=False, + reason=f"VoiceMeister: banned by {member}") + # If they're in the channel, kick them + if target.voice and target.voice.channel == vc: + await target.move_to(None, reason=f"VoiceMeister: banned by {member}") + # Save to persistent ban list + await cog._add_channel_ban(vc.guild.id, vc.id, target.id) + await interaction.response.send_message( + embed=ok_embed(f"Banned {target.mention} from the channel."), ephemeral=True, + ) + await cog._log_action(vc.guild, "🔨 User Banned", member=member, channel=vc, + detail=f"Target: {target} ({target.id})") + + elif self.action == "permit": + await vc.set_permissions(target, connect=True, view_channel=True, + reason=f"VoiceMeister: permitted by {member}") + await interaction.response.send_message( + embed=ok_embed(f"Permitted {target.mention} to join the channel."), ephemeral=True, + ) + await cog._log_action(vc.guild, "➕ User Permitted", member=member, channel=vc, + detail=f"Target: {target} ({target.id})") + + elif self.action == "reject": + await vc.set_permissions(target, connect=False, + reason=f"VoiceMeister: rejected by {member}") + if target.voice and target.voice.channel == vc: + await target.move_to(None, reason=f"VoiceMeister: rejected by {member}") + await interaction.response.send_message( + embed=ok_embed(f"Rejected {target.mention} from the channel."), ephemeral=True, + ) + await cog._log_action(vc.guild, "➖ User Rejected", member=member, channel=vc, + detail=f"Target: {target} ({target.id})") + + elif self.action == "transfer": + if target.voice and target.voice.channel == vc: + cog.temp_channels[vc.id] = target.id + await cog._save_temp_channels() + await interaction.response.send_message( + embed=ok_embed(f"Ownership transferred to {target.mention}."), ephemeral=True, + ) + await cog._log_action(vc.guild, "🔄 Ownership Transferred", member=member, + channel=vc, detail=f"New owner: {target} ({target.id})") + else: + await interaction.response.send_message( + embed=err_embed(f"{target.mention} must be in the channel to receive ownership."), + ephemeral=True, + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# MAIN CONTROL PANEL — PERSISTENT VIEW +# ══════════════════════════════════════════════════════════════════════════════ + +class VoiceMeisterPanel(ui.View): + """The main persistent control panel. All buttons use custom_ids so the + view survives restarts. This is registered once in cog_load.""" + + def __init__(self): + super().__init__(timeout=None) + + # ── Row 1: Access Controls ───────────────────────────────────────────── + + @ui.button(label="Lock", emoji="🔒", style=discord.ButtonStyle.secondary, + custom_id="vm:lock", row=0) + async def lock_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await vc.set_permissions( + vc.guild.default_role, connect=False, + reason=f"VoiceMeister: locked by {member}", + ) + await interaction.response.send_message( + embed=ok_embed("Channel **locked** 🔒 — no one else can join."), ephemeral=True, + ) + await cog._log_action(vc.guild, "🔒 Channel Locked", member=member, channel=vc) + + @ui.button(label="Unlock", emoji="🔓", style=discord.ButtonStyle.secondary, + custom_id="vm:unlock", row=0) + async def unlock_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await vc.set_permissions( + vc.guild.default_role, connect=None, + reason=f"VoiceMeister: unlocked by {member}", + ) + await interaction.response.send_message( + embed=ok_embed("Channel **unlocked** 🔓 — anyone can join."), ephemeral=True, + ) + await cog._log_action(vc.guild, "🔓 Channel Unlocked", member=member, channel=vc) + + @ui.button(label="Ghost", emoji="👻", style=discord.ButtonStyle.secondary, + custom_id="vm:ghost", row=0) + async def ghost_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await vc.set_permissions( + vc.guild.default_role, connect=False, view_channel=False, + reason=f"VoiceMeister: ghosted by {member}", + ) + await interaction.response.send_message( + embed=ok_embed("Channel is now **ghosted** 👻 — hidden and locked."), ephemeral=True, + ) + await cog._log_action(vc.guild, "👻 Channel Ghosted", member=member, channel=vc) + + @ui.button(label="Reveal", emoji="👁️", style=discord.ButtonStyle.secondary, + custom_id="vm:reveal", row=0) + async def reveal_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await vc.set_permissions( + vc.guild.default_role, connect=None, view_channel=None, + reason=f"VoiceMeister: revealed by {member}", + ) + await interaction.response.send_message( + embed=ok_embed("Channel is now **revealed** 👁️ — visible and unlocked."), ephemeral=True, + ) + await cog._log_action(vc.guild, "👁️ Channel Revealed", member=member, channel=vc) + + @ui.button(label="Hide", emoji="👤", style=discord.ButtonStyle.secondary, + custom_id="vm:hide", row=0) + async def hide_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await vc.set_permissions( + vc.guild.default_role, view_channel=False, + reason=f"VoiceMeister: hidden by {member}", + ) + await interaction.response.send_message( + embed=ok_embed("Channel is now **hidden** 👤 — invisible to others."), ephemeral=True, + ) + await cog._log_action(vc.guild, "👤 Channel Hidden", member=member, channel=vc) + + # ── Row 2: User Management ───────────────────────────────────────────── + + @ui.button(label="Permit", emoji="➕", style=discord.ButtonStyle.success, + custom_id="vm:permit", row=1) + async def permit_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_message( + embed=info_embed("Select a user to **permit** into your channel:"), + view=UserActionView("permit"), ephemeral=True, + ) + + @ui.button(label="Reject", emoji="➖", style=discord.ButtonStyle.danger, + custom_id="vm:reject", row=1) + async def reject_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_message( + embed=info_embed("Select a user to **reject** from your channel:"), + view=UserActionView("reject"), ephemeral=True, + ) + + @ui.button(label="Kick", emoji="👢", style=discord.ButtonStyle.danger, + custom_id="vm:kick", row=1) + async def kick_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_message( + embed=info_embed("Select a user to **kick** from your channel:"), + view=UserActionView("kick"), ephemeral=True, + ) + + @ui.button(label="Ban", emoji="🔨", style=discord.ButtonStyle.danger, + custom_id="vm:ban", row=1) + async def ban_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_message( + embed=info_embed("Select a user to **ban** from your channel:"), + view=UserActionView("ban"), ephemeral=True, + ) + + @ui.button(label="Unhide", emoji="👁️‍🗨️", style=discord.ButtonStyle.secondary, + custom_id="vm:unhide", row=1) + async def unhide_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await vc.set_permissions( + vc.guild.default_role, view_channel=None, + reason=f"VoiceMeister: unhidden by {member}", + ) + await interaction.response.send_message( + embed=ok_embed("Channel is now **visible** 👁️‍🗨️."), ephemeral=True, + ) + await cog._log_action(vc.guild, "👁️‍🗨️ Channel Unhidden", member=member, channel=vc) + + # ── Row 3: Channel Settings ──────────────────────────────────────────── + + @ui.button(label="Rename", emoji="✏️", style=discord.ButtonStyle.primary, + custom_id="vm:rename", row=2) + async def rename_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_modal(RenameModal()) + + @ui.button(label="Limit", emoji="👥", style=discord.ButtonStyle.primary, + custom_id="vm:limit", row=2) + async def limit_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_modal(LimitModal()) + + @ui.button(label="Bitrate", emoji="📡", style=discord.ButtonStyle.primary, + custom_id="vm:bitrate", row=2) + async def bitrate_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_modal(BitrateModal()) + + @ui.button(label="Region", emoji="🌍", style=discord.ButtonStyle.primary, + custom_id="vm:region", row=2) + async def region_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_modal(RegionModal()) + + @ui.button(label="Mute All", emoji="🔇", style=discord.ButtonStyle.secondary, + custom_id="vm:muteall", row=2) + async def muteall_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + count = 0 + for m in vc.members: + if m.id != member.id and not m.voice.mute: + try: + await m.edit(mute=True, reason=f"VoiceMeister: muted by {member}") + count += 1 + except discord.HTTPException: + pass + await interaction.response.send_message( + embed=ok_embed(f"Server-muted **{count}** user(s) 🔇."), ephemeral=True, + ) + await cog._log_action(vc.guild, "🔇 All Muted", member=member, channel=vc, + detail=f"Muted {count} user(s)") + + # ── Row 4: Ownership & Meta ──────────────────────────────────────────── + + @ui.button(label="Claim", emoji="👑", style=discord.ButtonStyle.success, + custom_id="vm:claim", row=3) + async def claim_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _get_context(interaction) + if cog is None: + return + + # Check if owner is still in the channel + owner_in_channel = any(m.id == owner_id for m in vc.members) + if owner_in_channel and member.id != owner_id: + await interaction.response.send_message( + embed=err_embed("The channel owner is still in the channel. You can't claim it."), + ephemeral=True, + ) + return + + if member.id == owner_id: + await interaction.response.send_message( + embed=info_embed("You already own this channel."), ephemeral=True, + ) + return + + cog.temp_channels[vc.id] = member.id + await cog._save_temp_channels() + await interaction.response.send_message( + embed=ok_embed(f"You are now the **owner** of this channel 👑."), ephemeral=True, + ) + await cog._log_action(vc.guild, "👑 Channel Claimed", member=member, channel=vc) + + @ui.button(label="Transfer", emoji="🔄", style=discord.ButtonStyle.success, + custom_id="vm:transfer", row=3) + async def transfer_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + await interaction.response.send_message( + embed=info_embed("Select the user to **transfer ownership** to:"), + view=UserActionView("transfer"), ephemeral=True, + ) + + @ui.button(label="Info", emoji="ℹ️", style=discord.ButtonStyle.secondary, + custom_id="vm:info", row=3) + async def info_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _get_context(interaction) + if cog is None: + return + + owner = vc.guild.get_member(owner_id) + owner_str = owner.mention if owner else f"Unknown (`{owner_id}`)" + + # Channel state + overwrites = vc.overwrites_for(vc.guild.default_role) + locked = overwrites.connect is False + hidden = overwrites.view_channel is False + + status_parts = [] + if locked: + status_parts.append("🔒 Locked") + else: + status_parts.append("🔓 Unlocked") + if hidden: + status_parts.append("👤 Hidden") + else: + status_parts.append("👁️ Visible") + + embed = discord.Embed( + title=f"ℹ️ {vc.name}", + colour=Clr.VOICE, + ) + embed.add_field(name="👑 Owner", value=owner_str, inline=True) + embed.add_field(name="👥 Members", value=f"{len(vc.members)}/{vc.user_limit or '∞'}", + inline=True) + embed.add_field(name="📡 Bitrate", value=f"{vc.bitrate // 1000} kbps", inline=True) + embed.add_field(name="🔐 Status", value=" • ".join(status_parts), inline=False) + embed.add_field(name="🌍 Region", value=vc.rtc_region or "Automatic", inline=True) + embed.add_field(name="📅 Created", value=f"", + inline=True) + + # List permitted/rejected users + permitted = [] + rejected = [] + for target, overwrite in vc.overwrites.items(): + if isinstance(target, (discord.Member, discord.User)): + if target.id == owner_id: + continue + if overwrite.connect is True: + permitted.append(target.mention) + elif overwrite.connect is False: + rejected.append(target.mention) + + if permitted: + embed.add_field(name="✅ Permitted", value=", ".join(permitted[:10]), inline=False) + if rejected: + embed.add_field(name="🚫 Rejected/Banned", value=", ".join(rejected[:10]), inline=False) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + @ui.button(label="Unmute All", emoji="🔊", style=discord.ButtonStyle.secondary, + custom_id="vm:unmuteall", row=3) + async def unmuteall_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + count = 0 + for m in vc.members: + if m.voice.mute: + try: + await m.edit(mute=False, reason=f"VoiceMeister: unmuted by {member}") + count += 1 + except discord.HTTPException: + pass + await interaction.response.send_message( + embed=ok_embed(f"Unmuted **{count}** user(s) 🔊."), ephemeral=True, + ) + await cog._log_action(vc.guild, "🔊 All Unmuted", member=member, channel=vc, + detail=f"Unmuted {count} user(s)") + + # ── Row 4 continued ─────────────────────────────────────────────────── + + @ui.button(label="Delete", emoji="🗑️", style=discord.ButtonStyle.danger, + custom_id="vm:delete", row=4) + async def delete_btn(self, interaction: discord.Interaction, button: ui.Button): + cog, member, vc, owner_id = await _require_owner(interaction) + if cog is None: + return + + # Confirmation + confirm_view = DeleteConfirmView(member.id, vc.id) + await interaction.response.send_message( + embed=warn_embed(f"Are you sure you want to **delete** `{vc.name}`? This cannot be undone."), + view=confirm_view, ephemeral=True, + ) + + +class DeleteConfirmView(ui.View): + def __init__(self, author_id: int, channel_id: int): + super().__init__(timeout=30) + self.author_id = author_id + self.channel_id = channel_id + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.author_id: + await interaction.response.send_message("Not your confirmation.", ephemeral=True) + return False + return True + + @ui.button(label="Yes, Delete", style=discord.ButtonStyle.danger, emoji="🗑️") + async def confirm(self, interaction: discord.Interaction, button: ui.Button): + cog: VoiceMeister | None = interaction.client.get_cog("VoiceMeister") + channel = interaction.guild.get_channel(self.channel_id) + if channel and cog: + await cog._log_action(interaction.guild, "🗑️ Channel Deleted", + member=interaction.user, channel=channel) + cog.temp_channels.pop(channel.id, None) + await cog._save_temp_channels() + try: + await channel.delete(reason=f"VoiceMeister: deleted by {interaction.user}") + except discord.HTTPException: + pass + await interaction.response.edit_message( + embed=ok_embed("Channel deleted."), view=None, + ) + self.stop() + + @ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="❌") + async def cancel(self, interaction: discord.Interaction, button: ui.Button): + await interaction.response.edit_message( + embed=info_embed("Deletion cancelled."), view=None, + ) + self.stop() diff --git a/voicemeister/voicemeister.py b/voicemeister/voicemeister.py new file mode 100644 index 0000000..4b97f79 --- /dev/null +++ b/voicemeister/voicemeister.py @@ -0,0 +1,1278 @@ +"""VoiceMeister v1.0.0 — Ultimate temporary voice channel management. + +Features: Join-to-Create, persistent control panel with 20+ buttons, +name templates, game detection, logging, blacklist/whitelist, cooldowns. +""" + +from __future__ import annotations + +import asyncio +import datetime +import time +from typing import Dict, Optional, Set + +import discord +from redbot.core import Config, commands, checks +from redbot.core.bot import Red + +from .utils import ( + Clr, ok_embed, err_embed, info_embed, warn_embed, + render_name, build_panel_embed, log_embed, safe_send, ts_now, +) +from .views import VoiceMeisterPanel + + +class VoiceMeister(commands.Cog): + """🎙️ VoiceMeister — Ultimate temporary voice channel management.""" + + __version__ = "1.0.0" + __author__ = "EveCogs" + + # ══════════════════════════════════════════════════════════════════════════ + # INIT & CONFIG + # ══════════════════════════════════════════════════════════════════════════ + + GUILD_DEFAULTS = { + "creators": {}, # {channel_id_str: {category_id, name_template, user_limit, bitrate, ...}} + "log_channel": None, # channel ID for logging + "temp_channels": {}, # {channel_id_str: owner_id} + "channel_bans": {}, # {channel_id_str: [user_id, ...]} + "blacklisted_users": [], # user IDs blocked from creating + "whitelisted_roles": [], # role IDs that bypass blacklist + "max_channels_per_user": 1, + "cooldown_seconds": 10, + "auto_rename_game": False, + "default_name_template": "🔊 {user}'s Channel", + "default_user_limit": 0, + "default_bitrate": 64000, + } + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=7283649152, force_registration=True) + self.config.register_guild(**self.GUILD_DEFAULTS) + + # Runtime caches (populated on cog_load) + self.temp_channels: Dict[int, int] = {} # {vc_id: owner_id} + self.creator_channels: Dict[int, dict] = {} # {vc_id: settings} + self.cooldowns: Dict[int, float] = {} # {user_id: last_create_timestamp} + self._panel_view = VoiceMeisterPanel() + self._bg_tasks: list[asyncio.Task] = [] + + async def cog_load(self): + """Load persistent data and register the panel view.""" + self.bot.add_view(self._panel_view) + await self._load_all_guilds() + self._bg_tasks.append(asyncio.create_task(self._cleanup_loop())) + if any( + (await self.config.guild_from_id(gid).auto_rename_game()) + for gid in [g.id for g in self.bot.guilds] + ): + self._bg_tasks.append(asyncio.create_task(self._game_rename_loop())) + + async def cog_unload(self): + for task in self._bg_tasks: + task.cancel() + + # ══════════════════════════════════════════════════════════════════════════ + # DATA LOADING & SAVING + # ══════════════════════════════════════════════════════════════════════════ + + async def _load_all_guilds(self): + """Load temp_channels and creators from config into memory caches.""" + all_guilds = await self.config.all_guilds() + for guild_id, data in all_guilds.items(): + for ch_id_str, owner_id in data.get("temp_channels", {}).items(): + self.temp_channels[int(ch_id_str)] = owner_id + for ch_id_str, settings in data.get("creators", {}).items(): + self.creator_channels[int(ch_id_str)] = settings + + async def _save_temp_channels(self): + """Persist the temp_channels cache back to config.""" + # Group by guild + guild_map: Dict[int, Dict[str, int]] = {} + for ch_id, owner_id in self.temp_channels.items(): + channel = self.bot.get_channel(ch_id) + if channel and channel.guild: + gid = channel.guild.id + guild_map.setdefault(gid, {})[str(ch_id)] = owner_id + + # Also handle guilds that might have had all channels removed + all_guilds = await self.config.all_guilds() + for gid in all_guilds: + if gid not in guild_map: + await self.config.guild_from_id(gid).temp_channels.set({}) + else: + await self.config.guild_from_id(gid).temp_channels.set(guild_map[gid]) + + for gid, data in guild_map.items(): + await self.config.guild_from_id(gid).temp_channels.set(data) + + async def _add_channel_ban(self, guild_id: int, channel_id: int, user_id: int): + """Add a user to a channel's ban list in config.""" + async with self.config.guild_from_id(guild_id).channel_bans() as bans: + key = str(channel_id) + if key not in bans: + bans[key] = [] + if user_id not in bans[key]: + bans[key].append(user_id) + + # ══════════════════════════════════════════════════════════════════════════ + # LOGGING + # ══════════════════════════════════════════════════════════════════════════ + + async def _log_action( + self, + guild: discord.Guild, + action: str, + *, + member: discord.Member | discord.User, + channel: discord.VoiceChannel | None = None, + detail: str = "", + ): + """Send a log embed to the configured log channel.""" + log_ch_id = await self.config.guild(guild).log_channel() + if not log_ch_id: + return + log_ch = guild.get_channel(log_ch_id) + if log_ch is None: + return + try: + await log_ch.send(embed=log_embed(action, member=member, channel=channel, detail=detail)) + except discord.HTTPException: + pass + + # ══════════════════════════════════════════════════════════════════════════ + # BACKGROUND TASKS + # ══════════════════════════════════════════════════════════════════════════ + + async def _cleanup_loop(self): + """Periodically clean up orphaned temp channels (empty for >5s).""" + await self.bot.wait_until_ready() + while True: + try: + to_remove = [] + for ch_id, owner_id in list(self.temp_channels.items()): + channel = self.bot.get_channel(ch_id) + if channel is None: + to_remove.append(ch_id) + continue + if len(channel.members) == 0: + to_remove.append(ch_id) + try: + await channel.delete(reason="VoiceMeister: auto-cleanup (empty)") + except discord.HTTPException: + pass + + if to_remove: + for ch_id in to_remove: + self.temp_channels.pop(ch_id, None) + await self._save_temp_channels() + + except Exception: + pass + await asyncio.sleep(15) + + async def _game_rename_loop(self): + """Rename channels based on the owner's current game activity.""" + await self.bot.wait_until_ready() + while True: + try: + for ch_id, owner_id in list(self.temp_channels.items()): + channel = self.bot.get_channel(ch_id) + if channel is None or not channel.guild: + continue + + auto_rename = await self.config.guild(channel.guild).auto_rename_game() + if not auto_rename: + continue + + owner = channel.guild.get_member(owner_id) + if owner is None: + continue + + game = None + for activity in owner.activities: + if activity.type == discord.ActivityType.playing: + game = activity.name + break + + if game: + new_name = f"🎮 {game}" + else: + new_name = f"🔊 {owner.display_name}'s Channel" + + if channel.name != new_name: + try: + await channel.edit(name=new_name[:100], + reason="VoiceMeister: game activity rename") + except discord.HTTPException: + pass + + except Exception: + pass + await asyncio.sleep(30) + + # ══════════════════════════════════════════════════════════════════════════ + # VOICE STATE LISTENER — THE CORE ENGINE + # ══════════════════════════════════════════════════════════════════════════ + + @commands.Cog.listener() + async def on_voice_state_update( + self, + member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState, + ): + # ── User joined a creator channel → spawn temp channel ───────── + if after.channel and after.channel.id in self.creator_channels: + await self._handle_join_creator(member, after.channel) + + # ── User left a temp channel → maybe clean up ────────────────── + if before.channel and before.channel.id in self.temp_channels: + if len(before.channel.members) == 0: + ch_id = before.channel.id + self.temp_channels.pop(ch_id, None) + await self._save_temp_channels() + try: + await before.channel.delete(reason="VoiceMeister: empty channel cleanup") + except discord.HTTPException: + pass + await self._log_action( + member.guild, "🗑️ Auto-Deleted (Empty)", + member=member, channel=before.channel, + ) + + async def _handle_join_creator(self, member: discord.Member, creator: discord.VoiceChannel): + """Spawn a new temp voice channel for the member.""" + guild = member.guild + settings = self.creator_channels[creator.id] + + # ── Blacklist check ──────────────────────────────────────────── + blacklisted = await self.config.guild(guild).blacklisted_users() + if member.id in blacklisted: + whitelisted_roles = await self.config.guild(guild).whitelisted_roles() + if not any(r.id in whitelisted_roles for r in member.roles): + try: + await member.move_to(None, reason="VoiceMeister: blacklisted user") + except discord.HTTPException: + pass + return + + # ── Cooldown check ───────────────────────────────────────────── + cooldown = await self.config.guild(guild).cooldown_seconds() + now = time.time() + last = self.cooldowns.get(member.id, 0) + if now - last < cooldown: + try: + await member.move_to(None, reason="VoiceMeister: cooldown") + except discord.HTTPException: + pass + return + + # ── Max channels per user check ──────────────────────────────── + max_ch = await self.config.guild(guild).max_channels_per_user() + user_channels = sum(1 for oid in self.temp_channels.values() if oid == member.id) + if user_channels >= max_ch: + # Move them to their existing channel instead + for ch_id, oid in self.temp_channels.items(): + if oid == member.id: + existing = guild.get_channel(ch_id) + if existing: + try: + await member.move_to(existing, reason="VoiceMeister: max channels reached") + except discord.HTTPException: + pass + return + try: + await member.move_to(None, reason="VoiceMeister: max channels reached") + except discord.HTTPException: + pass + return + + self.cooldowns[member.id] = now + + # ── Resolve settings ─────────────────────────────────────────── + category_id = settings.get("category_id") + category = guild.get_channel(category_id) if category_id else creator.category + name_template = settings.get("name_template") or await self.config.guild(guild).default_name_template() + user_limit = settings.get("user_limit", 0) + if user_limit == -1: # -1 means "use guild default" + user_limit = await self.config.guild(guild).default_user_limit() + bitrate = settings.get("bitrate", 0) + if bitrate <= 0: + bitrate = await self.config.guild(guild).default_bitrate() + + # Clamp bitrate + bitrate = max(8000, min(bitrate, guild.bitrate_limit)) + + # Count existing channels for this creator + count = sum( + 1 for ch_id, _ in self.temp_channels.items() + if self.bot.get_channel(ch_id) and + self.bot.get_channel(ch_id).category == category + ) + 1 + + channel_name = render_name(name_template, member=member, count=count) + + # ── Create the channel ───────────────────────────────────────── + overwrites = { + guild.default_role: discord.PermissionOverwrite( + connect=True, + view_channel=True, + ), + member: discord.PermissionOverwrite( + connect=True, + view_channel=True, + manage_channels=True, + move_members=True, + mute_members=True, + deafen_members=True, + ), + guild.me: discord.PermissionOverwrite( + connect=True, + view_channel=True, + manage_channels=True, + move_members=True, + mute_members=True, + deafen_members=True, + ), + } + + try: + new_vc = await guild.create_voice_channel( + name=channel_name, + category=category, + user_limit=user_limit if user_limit > 0 else 0, + bitrate=bitrate, + overwrites=overwrites, + reason=f"VoiceMeister: created for {member}", + ) + except discord.HTTPException: + return + + # Track it + self.temp_channels[new_vc.id] = member.id + await self._save_temp_channels() + + # Move the member into the new channel + try: + await member.move_to(new_vc, reason="VoiceMeister: moving to new channel") + except discord.HTTPException: + # If we can't move them, clean up + self.temp_channels.pop(new_vc.id, None) + await self._save_temp_channels() + try: + await new_vc.delete(reason="VoiceMeister: failed to move user") + except discord.HTTPException: + pass + return + + await self._log_action( + guild, "🔊 Channel Created", + member=member, channel=new_vc, + detail=f"From creator: {creator.mention}", + ) + + # ══════════════════════════════════════════════════════════════════════════ + # ADMIN COMMANDS — voicemeister group + # ══════════════════════════════════════════════════════════════════════════ + + @commands.group(name="voicemeister", aliases=["vm"]) + @commands.guild_only() + async def voicemeister(self, ctx: commands.Context): + """🎙️ VoiceMeister — Temporary voice channel management.""" + if ctx.invoked_subcommand is None: + embed = discord.Embed( + title="🎙️ VoiceMeister v1.0.0", + description=( + "**The ultimate temporary voice channel system.**\n\n" + "Users join a **creator** channel → a personal voice channel spawns → " + "they control it via the **panel** or `/vc` commands.\n\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + ), + colour=Clr.VOICE, + ) + + # Count active channels + guild_channels = sum( + 1 for ch_id in self.temp_channels + if (ch := self.bot.get_channel(ch_id)) and ch.guild.id == ctx.guild.id + ) + guild_creators = sum( + 1 for ch_id in self.creator_channels + if (ch := self.bot.get_channel(ch_id)) and ch.guild.id == ctx.guild.id + ) + + embed.add_field(name="📊 Status", value=( + f"**Creators:** {guild_creators}\n" + f"**Active channels:** {guild_channels}\n" + f"**Version:** 1.0.0" + ), inline=False) + + embed.add_field(name="⚡ Quick Start", value=( + f"`{ctx.clean_prefix}vm setup` — Interactive wizard\n" + f"`{ctx.clean_prefix}vm creator add #channel` — Add creator\n" + f"`{ctx.clean_prefix}vm panel #channel` — Send panel\n" + f"`{ctx.clean_prefix}vm settings` — View settings" + ), inline=False) + + embed.add_field(name="🔗 User Commands", value=( + f"`{ctx.clean_prefix}vc lock/unlock/hide/unhide`\n" + f"`{ctx.clean_prefix}vc rename/limit/bitrate`\n" + f"`{ctx.clean_prefix}vc kick/ban/permit/reject`\n" + f"`{ctx.clean_prefix}vc claim/transfer/info/delete`" + ), inline=False) + + embed.set_footer(text="VoiceMeister by EveCogs") + await ctx.send(embed=embed) + + # ── Setup Wizard ─────────────────────────────────────────────────────── + + @voicemeister.command(name="setup") + @checks.admin_or_permissions(manage_guild=True) + async def vm_setup(self, ctx: commands.Context): + """Interactive setup wizard for VoiceMeister.""" + embed = discord.Embed( + title="🎙️ VoiceMeister Setup Wizard", + description=( + "I'll walk you through setting up VoiceMeister.\n\n" + "**Step 1:** I'll create a Join-to-Create voice channel.\n" + "**Step 2:** I'll create a category for temp channels.\n" + "**Step 3:** I'll send the control panel.\n\n" + "React with ✅ to begin or ❌ to cancel." + ), + colour=Clr.VOICE, + ) + msg = await ctx.send(embed=embed) + await msg.add_reaction("✅") + await msg.add_reaction("❌") + + def check(reaction, user): + return ( + user == ctx.author + and reaction.message.id == msg.id + and str(reaction.emoji) in ("✅", "❌") + ) + + try: + reaction, _ = await self.bot.wait_for("reaction_add", timeout=60, check=check) + except asyncio.TimeoutError: + await msg.edit(embed=err_embed("Setup timed out.")) + return + + if str(reaction.emoji) == "❌": + await msg.edit(embed=info_embed("Setup cancelled.")) + return + + # ── Step 1: Create category ──────────────────────────────────── + await msg.edit(embed=info_embed( + "**Step 1/3:** Creating the VoiceMeister category and creator channel...", + title="⏳ Setting up...", + )) + + try: + category = await ctx.guild.create_category( + name="🎙️ VoiceMeister", + reason="VoiceMeister setup wizard", + ) + creator_vc = await ctx.guild.create_voice_channel( + name="➕ Join to Create", + category=category, + reason="VoiceMeister setup wizard", + ) + except discord.HTTPException as e: + await msg.edit(embed=err_embed(f"Failed to create channels: {e}")) + return + + # Register the creator + creator_settings = { + "category_id": category.id, + "name_template": "🔊 {user}'s Channel", + "user_limit": 0, + "bitrate": 64000, + } + self.creator_channels[creator_vc.id] = creator_settings + async with self.config.guild(ctx.guild).creators() as creators: + creators[str(creator_vc.id)] = creator_settings + + # ── Step 2: Ask for panel channel ────────────────────────────── + await msg.edit(embed=info_embed( + f"✅ Created {category.mention} with {creator_vc.mention}.\n\n" + "**Step 2/3:** Where should I send the control panel?\n" + "Mention a text channel (e.g. #voice-panel) or type `here` for this channel.\n" + "Type `skip` to skip the panel.", + title="📍 Panel Channel", + )) + + def msg_check(m): + return m.author == ctx.author and m.channel == ctx.channel + + try: + response = await self.bot.wait_for("message", timeout=60, check=msg_check) + except asyncio.TimeoutError: + await msg.edit(embed=err_embed("Setup timed out.")) + return + + panel_channel = None + if response.content.lower() == "skip": + pass + elif response.content.lower() == "here": + panel_channel = ctx.channel + elif response.channel_mentions: + panel_channel = response.channel_mentions[0] + + try: + await response.delete() + except (discord.HTTPException, discord.Forbidden): + pass + + # ── Step 3: Send panel ───────────────────────────────────────── + if panel_channel: + panel_embed = build_panel_embed(ctx.guild) + try: + await panel_channel.send(embed=panel_embed, view=self._panel_view) + except discord.HTTPException as e: + await msg.edit(embed=warn_embed(f"Couldn't send panel: {e}. You can send it manually with `{ctx.clean_prefix}vm panel`.")) + return + + # ── Step 3: Ask for log channel ──────────────────────────────── + await msg.edit(embed=info_embed( + "**Step 3/3:** Where should I send logs?\n" + "Mention a text channel or type `skip` to disable logging.", + title="📋 Log Channel", + )) + + try: + response = await self.bot.wait_for("message", timeout=60, check=msg_check) + except asyncio.TimeoutError: + pass + else: + if response.channel_mentions: + log_ch = response.channel_mentions[0] + await self.config.guild(ctx.guild).log_channel.set(log_ch.id) + + try: + await response.delete() + except (discord.HTTPException, discord.Forbidden): + pass + + # ── Done ─────────────────────────────────────────────────────── + final = discord.Embed( + title="✅ VoiceMeister Setup Complete!", + description=( + f"**Creator channel:** {creator_vc.mention}\n" + f"**Category:** {category.mention}\n" + f"**Panel:** {'Sent to ' + panel_channel.mention if panel_channel else 'Skipped'}\n" + f"**Logging:** {'Enabled' if await self.config.guild(ctx.guild).log_channel() else 'Disabled'}\n\n" + f"Users can now join **{creator_vc.mention}** to get a personal voice channel!\n\n" + f"Use `{ctx.clean_prefix}vm settings` to customize defaults." + ), + colour=Clr.SUCCESS, + ) + await msg.edit(embed=final) + + # ── Creator Management ───────────────────────────────────────────────── + + @voicemeister.group(name="creator", aliases=["jtc"]) + @checks.admin_or_permissions(manage_guild=True) + async def vm_creator(self, ctx: commands.Context): + """Manage Join-to-Create channels.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @vm_creator.command(name="add") + async def vm_creator_add( + self, + ctx: commands.Context, + channel: discord.VoiceChannel, + category: Optional[discord.CategoryChannel] = None, + ): + """Add a voice channel as a Join-to-Create creator. + + If no category is given, temp channels spawn in the same category. + """ + if channel.id in self.creator_channels: + await ctx.send(embed=err_embed(f"{channel.mention} is already a creator channel.")) + return + + settings = { + "category_id": category.id if category else channel.category_id, + "name_template": await self.config.guild(ctx.guild).default_name_template(), + "user_limit": -1, # use guild default + "bitrate": 0, # use guild default + } + self.creator_channels[channel.id] = settings + async with self.config.guild(ctx.guild).creators() as creators: + creators[str(channel.id)] = settings + + cat_mention = category.mention if category else (channel.category.mention if channel.category else "same category") + await ctx.send(embed=ok_embed( + f"Added {channel.mention} as a creator channel.\n" + f"Temp channels will spawn in {cat_mention}." + )) + + @vm_creator.command(name="remove", aliases=["delete"]) + async def vm_creator_remove(self, ctx: commands.Context, channel: discord.VoiceChannel): + """Remove a Join-to-Create creator channel.""" + if channel.id not in self.creator_channels: + await ctx.send(embed=err_embed(f"{channel.mention} is not a creator channel.")) + return + + self.creator_channels.pop(channel.id, None) + async with self.config.guild(ctx.guild).creators() as creators: + creators.pop(str(channel.id), None) + + await ctx.send(embed=ok_embed(f"Removed {channel.mention} as a creator channel.")) + + @vm_creator.command(name="list") + async def vm_creator_list(self, ctx: commands.Context): + """List all Join-to-Create channels.""" + guild_creators = { + ch_id: s for ch_id, s in self.creator_channels.items() + if ctx.guild.get_channel(ch_id) + } + if not guild_creators: + await ctx.send(embed=info_embed("No creator channels configured.")) + return + + lines = [] + for ch_id, settings in guild_creators.items(): + ch = ctx.guild.get_channel(ch_id) + cat = ctx.guild.get_channel(settings.get("category_id")) + template = settings.get("name_template", "Default") + lines.append( + f"• {ch.mention} → Category: {cat.mention if cat else 'N/A'} | " + f"Template: `{template}`" + ) + + embed = discord.Embed( + title="🎙️ Creator Channels", + description="\n".join(lines), + colour=Clr.VOICE, + ) + await ctx.send(embed=embed) + + @vm_creator.command(name="template") + async def vm_creator_template( + self, + ctx: commands.Context, + channel: discord.VoiceChannel, + *, + template: str, + ): + """Set a name template for a specific creator. + + Variables: {user}, {game}, {count}, {custom} + """ + if channel.id not in self.creator_channels: + await ctx.send(embed=err_embed(f"{channel.mention} is not a creator channel.")) + return + + self.creator_channels[channel.id]["name_template"] = template + async with self.config.guild(ctx.guild).creators() as creators: + if str(channel.id) in creators: + creators[str(channel.id)]["name_template"] = template + + await ctx.send(embed=ok_embed(f"Name template for {channel.mention} set to: `{template}`")) + + @vm_creator.command(name="userlimit", aliases=["limit"]) + async def vm_creator_limit( + self, + ctx: commands.Context, + channel: discord.VoiceChannel, + limit: int, + ): + """Set default user limit for a creator (0 = unlimited, -1 = guild default).""" + if channel.id not in self.creator_channels: + await ctx.send(embed=err_embed(f"{channel.mention} is not a creator channel.")) + return + + self.creator_channels[channel.id]["user_limit"] = limit + async with self.config.guild(ctx.guild).creators() as creators: + if str(channel.id) in creators: + creators[str(channel.id)]["user_limit"] = limit + + label = str(limit) if limit > 0 else ("guild default" if limit == -1 else "unlimited") + await ctx.send(embed=ok_embed(f"User limit for {channel.mention} set to **{label}**.")) + + @vm_creator.command(name="bitrate") + async def vm_creator_bitrate( + self, + ctx: commands.Context, + channel: discord.VoiceChannel, + bitrate: int, + ): + """Set default bitrate (kbps) for a creator. 0 = guild default.""" + if channel.id not in self.creator_channels: + await ctx.send(embed=err_embed(f"{channel.mention} is not a creator channel.")) + return + + self.creator_channels[channel.id]["bitrate"] = bitrate * 1000 if bitrate > 0 else 0 + async with self.config.guild(ctx.guild).creators() as creators: + if str(channel.id) in creators: + creators[str(channel.id)]["bitrate"] = bitrate * 1000 if bitrate > 0 else 0 + + label = f"{bitrate} kbps" if bitrate > 0 else "guild default" + await ctx.send(embed=ok_embed(f"Bitrate for {channel.mention} set to **{label}**.")) + + # ── Panel ────────────────────────────────────────────────────────────── + + @voicemeister.command(name="panel") + @checks.admin_or_permissions(manage_guild=True) + async def vm_panel(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None): + """Send the control panel to a channel (defaults to current).""" + target = channel or ctx.channel + panel_embed = build_panel_embed(ctx.guild) + await target.send(embed=panel_embed, view=self._panel_view) + if target != ctx.channel: + await ctx.send(embed=ok_embed(f"Panel sent to {target.mention}.")) + + # ── Settings ─────────────────────────────────────────────────────────── + + @voicemeister.command(name="settings", aliases=["config"]) + @checks.admin_or_permissions(manage_guild=True) + async def vm_settings(self, ctx: commands.Context): + """View current VoiceMeister settings.""" + data = await self.config.guild(ctx.guild).all() + log_ch = ctx.guild.get_channel(data["log_channel"]) if data["log_channel"] else None + + embed = discord.Embed( + title="⚙️ VoiceMeister Settings", + colour=Clr.VOICE, + ) + embed.add_field(name="📝 Default Name Template", value=f"`{data['default_name_template']}`", + inline=False) + embed.add_field(name="👥 Default User Limit", + value=str(data["default_user_limit"]) if data["default_user_limit"] > 0 else "Unlimited", + inline=True) + embed.add_field(name="📡 Default Bitrate", + value=f"{data['default_bitrate'] // 1000} kbps", inline=True) + embed.add_field(name="📋 Log Channel", + value=log_ch.mention if log_ch else "Disabled", inline=True) + embed.add_field(name="⏱️ Cooldown", + value=f"{data['cooldown_seconds']}s", inline=True) + embed.add_field(name="🔢 Max Channels/User", + value=str(data["max_channels_per_user"]), inline=True) + embed.add_field(name="🎮 Game Auto-Rename", + value="Enabled" if data["auto_rename_game"] else "Disabled", inline=True) + embed.add_field(name="🚫 Blacklisted Users", + value=str(len(data["blacklisted_users"])), inline=True) + embed.add_field(name="✅ Whitelisted Roles", + value=str(len(data["whitelisted_roles"])), inline=True) + + await ctx.send(embed=embed) + + @voicemeister.command(name="logchannel", aliases=["log"]) + @checks.admin_or_permissions(manage_guild=True) + async def vm_logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None): + """Set the log channel. Omit channel to disable.""" + await self.config.guild(ctx.guild).log_channel.set(channel.id if channel else None) + if channel: + await ctx.send(embed=ok_embed(f"Log channel set to {channel.mention}.")) + else: + await ctx.send(embed=ok_embed("Logging disabled.")) + + @voicemeister.command(name="defaultname", aliases=["template"]) + @checks.admin_or_permissions(manage_guild=True) + async def vm_defaultname(self, ctx: commands.Context, *, template: str): + """Set the default name template for new channels. + + Variables: {user}, {game}, {count}, {custom} + Example: 🔊 {user}'s Lounge + """ + await self.config.guild(ctx.guild).default_name_template.set(template) + await ctx.send(embed=ok_embed(f"Default name template set to: `{template}`")) + + @voicemeister.command(name="defaultlimit") + @checks.admin_or_permissions(manage_guild=True) + async def vm_defaultlimit(self, ctx: commands.Context, limit: int): + """Set the default user limit (0 = unlimited).""" + await self.config.guild(ctx.guild).default_user_limit.set(max(0, min(limit, 99))) + label = str(limit) if limit > 0 else "unlimited" + await ctx.send(embed=ok_embed(f"Default user limit set to **{label}**.")) + + @voicemeister.command(name="defaultbitrate") + @checks.admin_or_permissions(manage_guild=True) + async def vm_defaultbitrate(self, ctx: commands.Context, bitrate: int): + """Set the default bitrate in kbps (8-384).""" + br = max(8, min(bitrate, 384)) * 1000 + await self.config.guild(ctx.guild).default_bitrate.set(br) + await ctx.send(embed=ok_embed(f"Default bitrate set to **{br // 1000} kbps**.")) + + @voicemeister.command(name="cooldown") + @checks.admin_or_permissions(manage_guild=True) + async def vm_cooldown(self, ctx: commands.Context, seconds: int): + """Set the cooldown between channel creations (in seconds).""" + await self.config.guild(ctx.guild).cooldown_seconds.set(max(0, seconds)) + await ctx.send(embed=ok_embed(f"Cooldown set to **{seconds}s**.")) + + @voicemeister.command(name="maxchannels", aliases=["maxch"]) + @checks.admin_or_permissions(manage_guild=True) + async def vm_maxchannels(self, ctx: commands.Context, limit: int): + """Set max simultaneous channels per user.""" + await self.config.guild(ctx.guild).max_channels_per_user.set(max(1, limit)) + await ctx.send(embed=ok_embed(f"Max channels per user set to **{limit}**.")) + + @voicemeister.command(name="gamerename", aliases=["gamedetect"]) + @checks.admin_or_permissions(manage_guild=True) + async def vm_gamerename(self, ctx: commands.Context, enabled: bool): + """Toggle automatic game-activity channel renaming.""" + await self.config.guild(ctx.guild).auto_rename_game.set(enabled) + status = "enabled" if enabled else "disabled" + await ctx.send(embed=ok_embed(f"Game auto-rename **{status}**.")) + + # ── Blacklist / Whitelist ────────────────────────────────────────────── + + @voicemeister.group(name="blacklist", aliases=["bl"]) + @checks.admin_or_permissions(manage_guild=True) + async def vm_blacklist(self, ctx: commands.Context): + """Manage the VoiceMeister blacklist.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @vm_blacklist.command(name="add") + async def vm_bl_add(self, ctx: commands.Context, user: discord.Member): + """Block a user from creating temp channels.""" + async with self.config.guild(ctx.guild).blacklisted_users() as bl: + if user.id not in bl: + bl.append(user.id) + await ctx.send(embed=ok_embed(f"Blacklisted {user.mention}.")) + + @vm_blacklist.command(name="remove") + async def vm_bl_remove(self, ctx: commands.Context, user: discord.Member): + """Remove a user from the blacklist.""" + async with self.config.guild(ctx.guild).blacklisted_users() as bl: + if user.id in bl: + bl.remove(user.id) + await ctx.send(embed=ok_embed(f"Removed {user.mention} from blacklist.")) + + @vm_blacklist.command(name="list") + async def vm_bl_list(self, ctx: commands.Context): + """View blacklisted users.""" + bl = await self.config.guild(ctx.guild).blacklisted_users() + if not bl: + await ctx.send(embed=info_embed("No blacklisted users.")) + return + mentions = [f"<@{uid}>" for uid in bl] + await ctx.send(embed=info_embed(", ".join(mentions), title="🚫 Blacklisted Users")) + + @voicemeister.group(name="whitelist", aliases=["wl"]) + @checks.admin_or_permissions(manage_guild=True) + async def vm_whitelist(self, ctx: commands.Context): + """Manage the VoiceMeister role whitelist (bypasses blacklist).""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @vm_whitelist.command(name="add") + async def vm_wl_add(self, ctx: commands.Context, role: discord.Role): + """Add a role to the whitelist.""" + async with self.config.guild(ctx.guild).whitelisted_roles() as wl: + if role.id not in wl: + wl.append(role.id) + await ctx.send(embed=ok_embed(f"Whitelisted {role.mention}.")) + + @vm_whitelist.command(name="remove") + async def vm_wl_remove(self, ctx: commands.Context, role: discord.Role): + """Remove a role from the whitelist.""" + async with self.config.guild(ctx.guild).whitelisted_roles() as wl: + if role.id in wl: + wl.remove(role.id) + await ctx.send(embed=ok_embed(f"Removed {role.mention} from whitelist.")) + + @vm_whitelist.command(name="list") + async def vm_wl_list(self, ctx: commands.Context): + """View whitelisted roles.""" + wl = await self.config.guild(ctx.guild).whitelisted_roles() + if not wl: + await ctx.send(embed=info_embed("No whitelisted roles.")) + return + mentions = [f"<@&{rid}>" for rid in wl] + await ctx.send(embed=info_embed(", ".join(mentions), title="✅ Whitelisted Roles")) + + # ── Reset ────────────────────────────────────────────────────────────── + + @voicemeister.command(name="resetall") + @checks.admin_or_permissions(administrator=True) + async def vm_resetall(self, ctx: commands.Context): + """Reset ALL VoiceMeister settings for this server. ⚠️ Destructive!""" + await ctx.send(embed=warn_embed( + "This will **delete all settings**, creator channels, and clean up temp channels.\n" + "Type `CONFIRM` within 15 seconds to proceed." + )) + + def check(m): + return m.author == ctx.author and m.channel == ctx.channel and m.content == "CONFIRM" + + try: + await self.bot.wait_for("message", timeout=15, check=check) + except asyncio.TimeoutError: + await ctx.send(embed=info_embed("Reset cancelled (timed out).")) + return + + # Delete all temp channels for this guild + for ch_id in list(self.temp_channels): + ch = self.bot.get_channel(ch_id) + if ch and ch.guild.id == ctx.guild.id: + self.temp_channels.pop(ch_id, None) + try: + await ch.delete(reason="VoiceMeister: reset all") + except discord.HTTPException: + pass + + # Clear creators + for ch_id in list(self.creator_channels): + ch = self.bot.get_channel(ch_id) + if ch and ch.guild.id == ctx.guild.id: + self.creator_channels.pop(ch_id, None) + + await self.config.guild(ctx.guild).clear() + await self._save_temp_channels() + await ctx.send(embed=ok_embed("All VoiceMeister data for this server has been reset.")) + + # ══════════════════════════════════════════════════════════════════════════ + # USER COMMANDS — vc group + # ══════════════════════════════════════════════════════════════════════════ + + @commands.group(name="vc") + @commands.guild_only() + async def vc(self, ctx: commands.Context): + """🔊 Control your VoiceMeister channel.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + async def _get_user_vc(self, ctx: commands.Context): + """Validate the user is in a VoiceMeister channel they own. Return (vc, owner_id) or None.""" + if not ctx.author.voice or not ctx.author.voice.channel: + await ctx.send(embed=err_embed("You must be in a voice channel.")) + return None, None + + vc = ctx.author.voice.channel + owner_id = self.temp_channels.get(vc.id) + if owner_id is None: + await ctx.send(embed=err_embed("You're not in a VoiceMeister channel.")) + return None, None + + if not (ctx.author.id == owner_id or ctx.author.guild_permissions.manage_channels): + await ctx.send(embed=err_embed("You don't own this channel.")) + return None, None + + return vc, owner_id + + @vc.command(name="lock") + async def vc_lock(self, ctx: commands.Context): + """Lock your channel — prevent others from joining.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.set_permissions(ctx.guild.default_role, connect=False, + reason=f"VoiceMeister: locked by {ctx.author}") + await ctx.send(embed=ok_embed("Channel **locked** 🔒.")) + await self._log_action(ctx.guild, "🔒 Locked", member=ctx.author, channel=vc) + + @vc.command(name="unlock") + async def vc_unlock(self, ctx: commands.Context): + """Unlock your channel — allow anyone to join.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.set_permissions(ctx.guild.default_role, connect=None, + reason=f"VoiceMeister: unlocked by {ctx.author}") + await ctx.send(embed=ok_embed("Channel **unlocked** 🔓.")) + await self._log_action(ctx.guild, "🔓 Unlocked", member=ctx.author, channel=vc) + + @vc.command(name="hide") + async def vc_hide(self, ctx: commands.Context): + """Hide your channel from the channel list.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.set_permissions(ctx.guild.default_role, view_channel=False, + reason=f"VoiceMeister: hidden by {ctx.author}") + await ctx.send(embed=ok_embed("Channel **hidden** 👤.")) + await self._log_action(ctx.guild, "👤 Hidden", member=ctx.author, channel=vc) + + @vc.command(name="unhide", aliases=["reveal", "show"]) + async def vc_unhide(self, ctx: commands.Context): + """Make your channel visible again.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.set_permissions(ctx.guild.default_role, view_channel=None, + reason=f"VoiceMeister: unhidden by {ctx.author}") + await ctx.send(embed=ok_embed("Channel **visible** 👁️.")) + await self._log_action(ctx.guild, "👁️ Unhidden", member=ctx.author, channel=vc) + + @vc.command(name="ghost") + async def vc_ghost(self, ctx: commands.Context): + """Ghost your channel — hide AND lock.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.set_permissions(ctx.guild.default_role, connect=False, view_channel=False, + reason=f"VoiceMeister: ghosted by {ctx.author}") + await ctx.send(embed=ok_embed("Channel **ghosted** 👻 — hidden and locked.")) + await self._log_action(ctx.guild, "👻 Ghosted", member=ctx.author, channel=vc) + + @vc.command(name="rename", aliases=["name"]) + async def vc_rename(self, ctx: commands.Context, *, name: str): + """Rename your channel.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.edit(name=name[:100], reason=f"VoiceMeister: renamed by {ctx.author}") + await ctx.send(embed=ok_embed(f"Channel renamed to **{name[:100]}**.")) + await self._log_action(ctx.guild, "✏️ Renamed", member=ctx.author, channel=vc, + detail=f"New name: `{name[:100]}`") + + @vc.command(name="limit", aliases=["userlimit"]) + async def vc_limit(self, ctx: commands.Context, limit: int): + """Set user limit (0 = unlimited).""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + limit = max(0, min(limit, 99)) + await vc.edit(user_limit=limit, reason=f"VoiceMeister: limit set by {ctx.author}") + label = f"**{limit}**" if limit > 0 else "**unlimited**" + await ctx.send(embed=ok_embed(f"User limit set to {label}.")) + await self._log_action(ctx.guild, "👥 Limit", member=ctx.author, channel=vc, + detail=f"New limit: {label}") + + @vc.command(name="bitrate") + async def vc_bitrate(self, ctx: commands.Context, kbps: int): + """Set bitrate in kbps (8-384).""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + max_br = ctx.guild.bitrate_limit // 1000 + kbps = max(8, min(kbps, max_br)) + await vc.edit(bitrate=kbps * 1000, reason=f"VoiceMeister: bitrate set by {ctx.author}") + await ctx.send(embed=ok_embed(f"Bitrate set to **{kbps} kbps**.")) + await self._log_action(ctx.guild, "📡 Bitrate", member=ctx.author, channel=vc, + detail=f"New bitrate: {kbps} kbps") + + @vc.command(name="permit", aliases=["allow", "trust"]) + async def vc_permit(self, ctx: commands.Context, user: discord.Member): + """Allow a specific user to join your channel.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.set_permissions(user, connect=True, view_channel=True, + reason=f"VoiceMeister: permitted by {ctx.author}") + await ctx.send(embed=ok_embed(f"Permitted {user.mention}.")) + await self._log_action(ctx.guild, "➕ Permitted", member=ctx.author, channel=vc, + detail=f"Target: {user} ({user.id})") + + @vc.command(name="reject", aliases=["deny", "block"]) + async def vc_reject(self, ctx: commands.Context, user: discord.Member): + """Block a specific user from joining your channel.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.set_permissions(user, connect=False, + reason=f"VoiceMeister: rejected by {ctx.author}") + if user.voice and user.voice.channel == vc: + await user.move_to(None, reason=f"VoiceMeister: rejected by {ctx.author}") + await ctx.send(embed=ok_embed(f"Rejected {user.mention}.")) + await self._log_action(ctx.guild, "➖ Rejected", member=ctx.author, channel=vc, + detail=f"Target: {user} ({user.id})") + + @vc.command(name="kick") + async def vc_kick(self, ctx: commands.Context, user: discord.Member): + """Kick a user from your channel.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + if user.voice and user.voice.channel == vc: + await user.move_to(None, reason=f"VoiceMeister: kicked by {ctx.author}") + await ctx.send(embed=ok_embed(f"Kicked {user.mention}.")) + await self._log_action(ctx.guild, "👢 Kicked", member=ctx.author, channel=vc, + detail=f"Target: {user} ({user.id})") + else: + await ctx.send(embed=err_embed(f"{user.mention} is not in your channel.")) + + @vc.command(name="ban") + async def vc_ban(self, ctx: commands.Context, user: discord.Member): + """Permanently ban a user from your channel.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + await vc.set_permissions(user, connect=False, view_channel=False, + reason=f"VoiceMeister: banned by {ctx.author}") + if user.voice and user.voice.channel == vc: + await user.move_to(None, reason=f"VoiceMeister: banned by {ctx.author}") + await self._add_channel_ban(ctx.guild.id, vc.id, user.id) + await ctx.send(embed=ok_embed(f"Banned {user.mention} from the channel.")) + await self._log_action(ctx.guild, "🔨 Banned", member=ctx.author, channel=vc, + detail=f"Target: {user} ({user.id})") + + @vc.command(name="claim") + async def vc_claim(self, ctx: commands.Context): + """Claim an ownerless channel (owner must have left).""" + if not ctx.author.voice or not ctx.author.voice.channel: + await ctx.send(embed=err_embed("You must be in a voice channel.")) + return + + vc = ctx.author.voice.channel + owner_id = self.temp_channels.get(vc.id) + if owner_id is None: + await ctx.send(embed=err_embed("This is not a VoiceMeister channel.")) + return + + if owner_id == ctx.author.id: + await ctx.send(embed=info_embed("You already own this channel.")) + return + + owner_in_channel = any(m.id == owner_id for m in vc.members) + if owner_in_channel: + await ctx.send(embed=err_embed("The owner is still in the channel.")) + return + + self.temp_channels[vc.id] = ctx.author.id + await self._save_temp_channels() + await ctx.send(embed=ok_embed("You are now the **owner** of this channel 👑.")) + await self._log_action(ctx.guild, "👑 Claimed", member=ctx.author, channel=vc) + + @vc.command(name="transfer") + async def vc_transfer(self, ctx: commands.Context, user: discord.Member): + """Transfer channel ownership to another user in the channel.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + + if user.voice is None or user.voice.channel != vc: + await ctx.send(embed=err_embed(f"{user.mention} must be in the channel.")) + return + + if user.id == ctx.author.id: + await ctx.send(embed=err_embed("You can't transfer to yourself.")) + return + + self.temp_channels[vc.id] = user.id + await self._save_temp_channels() + await ctx.send(embed=ok_embed(f"Ownership transferred to {user.mention} 👑.")) + await self._log_action(ctx.guild, "🔄 Transferred", member=ctx.author, channel=vc, + detail=f"New owner: {user} ({user.id})") + + @vc.command(name="info", aliases=["status"]) + async def vc_info(self, ctx: commands.Context): + """View info about your current VoiceMeister channel.""" + if not ctx.author.voice or not ctx.author.voice.channel: + await ctx.send(embed=err_embed("You must be in a voice channel.")) + return + + vc = ctx.author.voice.channel + owner_id = self.temp_channels.get(vc.id) + if owner_id is None: + await ctx.send(embed=err_embed("This is not a VoiceMeister channel.")) + return + + owner = ctx.guild.get_member(owner_id) + owner_str = owner.mention if owner else f"Unknown (`{owner_id}`)" + + overwrites = vc.overwrites_for(ctx.guild.default_role) + locked = overwrites.connect is False + hidden = overwrites.view_channel is False + + status_parts = [] + if locked: + status_parts.append("🔒 Locked") + else: + status_parts.append("🔓 Unlocked") + if hidden: + status_parts.append("👤 Hidden") + else: + status_parts.append("👁️ Visible") + + embed = discord.Embed(title=f"ℹ️ {vc.name}", colour=Clr.VOICE) + embed.add_field(name="👑 Owner", value=owner_str, inline=True) + embed.add_field(name="👥 Members", value=f"{len(vc.members)}/{vc.user_limit or '∞'}", + inline=True) + embed.add_field(name="📡 Bitrate", value=f"{vc.bitrate // 1000} kbps", inline=True) + embed.add_field(name="🔐 Status", value=" • ".join(status_parts), inline=False) + embed.add_field(name="🌍 Region", value=vc.rtc_region or "Automatic", inline=True) + embed.add_field(name="📅 Created", value=f"", + inline=True) + + permitted = [] + rejected = [] + for target, overwrite in vc.overwrites.items(): + if isinstance(target, (discord.Member, discord.User)): + if target.id == owner_id: + continue + if overwrite.connect is True: + permitted.append(target.mention) + elif overwrite.connect is False: + rejected.append(target.mention) + + if permitted: + embed.add_field(name="✅ Permitted", value=", ".join(permitted[:10]), inline=False) + if rejected: + embed.add_field(name="🚫 Rejected/Banned", value=", ".join(rejected[:10]), inline=False) + + await ctx.send(embed=embed) + + @vc.command(name="delete") + async def vc_delete(self, ctx: commands.Context): + """Delete your VoiceMeister channel.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + + await ctx.send(embed=warn_embed( + f"Are you sure you want to **delete** `{vc.name}`? Type `yes` within 10s." + )) + + def check(m): + return m.author == ctx.author and m.channel == ctx.channel and m.content.lower() == "yes" + + try: + await self.bot.wait_for("message", timeout=10, check=check) + except asyncio.TimeoutError: + await ctx.send(embed=info_embed("Deletion cancelled.")) + return + + await self._log_action(ctx.guild, "🗑️ Deleted", member=ctx.author, channel=vc) + self.temp_channels.pop(vc.id, None) + await self._save_temp_channels() + try: + await vc.delete(reason=f"VoiceMeister: deleted by {ctx.author}") + except discord.HTTPException: + pass + await ctx.send(embed=ok_embed("Channel deleted.")) + + @vc.command(name="muteall") + async def vc_muteall(self, ctx: commands.Context): + """Server-mute all users in your channel (except you).""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + count = 0 + for m in vc.members: + if m.id != ctx.author.id and not m.voice.mute: + try: + await m.edit(mute=True, reason=f"VoiceMeister: muted by {ctx.author}") + count += 1 + except discord.HTTPException: + pass + await ctx.send(embed=ok_embed(f"Server-muted **{count}** user(s) 🔇.")) + await self._log_action(ctx.guild, "🔇 Muted All", member=ctx.author, channel=vc, + detail=f"Muted {count} user(s)") + + @vc.command(name="unmuteall") + async def vc_unmuteall(self, ctx: commands.Context): + """Remove server-mute from all users in your channel.""" + vc, _ = await self._get_user_vc(ctx) + if not vc: + return + count = 0 + for m in vc.members: + if m.voice.mute: + try: + await m.edit(mute=False, reason=f"VoiceMeister: unmuted by {ctx.author}") + count += 1 + except discord.HTTPException: + pass + await ctx.send(embed=ok_embed(f"Unmuted **{count}** user(s) 🔊.")) + await self._log_action(ctx.guild, "🔊 Unmuted All", member=ctx.author, channel=vc, + detail=f"Unmuted {count} user(s)")