EveCogs/voicemeister/voicemeister.py

1285 lines
59 KiB
Python
Raw Normal View History

"""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 time
from typing import Dict, Optional
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()))
# Always start the game rename loop — it checks per-guild config each cycle
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
# Clear guilds that no longer have temp channels
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({})
# Save guilds that have temp channels
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
overwrite = vc.overwrites_for(ctx.guild.default_role)
overwrite.connect = False
await vc.set_permissions(ctx.guild.default_role, overwrite=overwrite,
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
overwrite = vc.overwrites_for(ctx.guild.default_role)
overwrite.connect = None
await vc.set_permissions(ctx.guild.default_role, overwrite=overwrite,
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
overwrite = vc.overwrites_for(ctx.guild.default_role)
overwrite.view_channel = False
await vc.set_permissions(ctx.guild.default_role, overwrite=overwrite,
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
overwrite = vc.overwrites_for(ctx.guild.default_role)
overwrite.view_channel = None
await vc.set_permissions(ctx.guild.default_role, overwrite=overwrite,
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
overwrite = vc.overwrites_for(ctx.guild.default_role)
overwrite.connect = False
overwrite.view_channel = False
await vc.set_permissions(ctx.guild.default_role, overwrite=overwrite,
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)")