EveCogs/voicemeister/voicemeister.py
everestmcarthur 499c2bb291 feat: Add VoiceMeister v1.0.0 — Ultimate temporary voice channel management
- Join-to-Create system with auto-spawn temp voice channels
- Persistent 20+ button control panel (survives restarts)
- Name templates, game detection, auto-cleanup, logging
- Blacklist/whitelist, cooldowns, max-channels-per-user
- Interactive setup wizard + full [p]vc user commands
2026-05-08 13:23:03 +00:00

1278 lines
58 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"<t:{int(vc.created_at.timestamp())}:R>",
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)")