feat: ServerWhitelist — Ultimate owner-only server management cog #3

Merged
everestmcarthur merged 1 commit from feat/serverwhitelist into main 2026-05-12 23:07:11 -04:00
4 changed files with 1012 additions and 1 deletions

View file

@ -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), 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).",
"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), SelfbotGuard (advanced selfbot detection), and ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export).",
"type": "REPO"
}

View file

@ -0,0 +1,13 @@
"""ServerWhitelist — Ultimate owner-only server management for Red-DiscordBot."""
from .serverwhitelist import ServerWhitelist
__red_end_user_data_statement__ = (
"This cog stores guild IDs in a global whitelist and blacklist. "
"No per-user data is collected or stored."
)
async def setup(bot):
cog = ServerWhitelist(bot)
await bot.add_cog(cog)

27
serverwhitelist/info.json Normal file
View file

@ -0,0 +1,27 @@
{
"author": [
"everestmcarthur"
],
"install_msg": "Thanks for installing **ServerWhitelist**!\n\nAll servers the bot is currently in have been automatically whitelisted.\n\n**Usage (bot owner only):**\n• `[p]join <server_id>` — Add a server to the whitelist so the bot can stay when invited.\n• `[p]join remove <server_id>` — Remove a server from the whitelist (the bot will leave it).\n• `[p]join list` — View every whitelisted server.\n\nAny server **not** on the whitelist will be left automatically when the bot joins.",
"name": "ServerWhitelist",
"disabled": false,
"short": "Restrict which servers the bot is allowed to stay in via an owner-only whitelist.",
"description": "ServerWhitelist lets the bot owner control exactly which Discord servers the bot is permitted to operate in. On first load every server the bot is already in is auto-whitelisted. The hybrid command [p]join <id> adds a new server ID to the list. When the bot joins a server that is not whitelisted it immediately leaves. Subcommands let the owner remove servers and view the full whitelist. Only the bot owner can use these commands.",
"tags": [
"whitelist",
"server",
"owner",
"security",
"join",
"management"
],
"type": "COG",
"end_user_data_statement": "This cog stores guild IDs in a global whitelist. No per-user data is collected or stored.",
"min_bot_version": "3.5.0",
"min_python_version": [
3,
9,
0
],
"requirements": []
}

View file

@ -0,0 +1,971 @@
"""
ServerWhitelist v2.0 — Ultimate Server Management Cog for Red-DiscordBot
=========================================================================
Owner-only hybrid commands to control which Discord servers the bot may
operate in. Every feature uses Red's Config for persistence and discord.py
Views for interactive UI (paginated embeds, leave / blacklist buttons, etc.).
Feature set
-----------
Whitelist & Blacklist
• ``[p]join <id>`` — whitelist a server
• ``[p]join remove <id>`` — un-whitelist a server (optionally leave)
• ``[p]join blacklist <id>``— blacklist a server (auto-leave & never rejoin)
• ``[p]join unblacklist <id>`` — remove from blacklist
• ``[p]join whitelist`` — paginated whitelist with ❌ remove buttons
• ``[p]join blacklisted`` — paginated blacklist
Server Browser
• ``[p]join servers`` — paginated embed of ALL guilds with 🚪 Leave btns
• ``[p]join info <id>`` — detailed embed for one guild
• ``[p]join search <query>``— search servers by name
• ``[p]join stats`` — high-level overview
Bulk Actions
• ``[p]join leave <id>`` — leave a guild immediately
• ``[p]join purge`` — leave every non-whitelisted guild (confirm step)
Bot Lock
• ``[p]join lock`` — reject ALL new joins (even whitelisted)
• ``[p]join unlock`` — resume normal whitelist behaviour
Logging
• ``[p]join log <channel>`` — log join/leave/block events to a channel
• ``[p]join log off`` — disable logging
Settings
• ``[p]join settings`` — display current config at a glance
Export
• ``[p]join export`` — upload a .txt file of all guilds
"""
from __future__ import annotations
import io
import logging
from datetime import datetime, timezone
from typing import List, Optional, Sequence
import discord
from discord import Interaction
from redbot.core import Config, commands
from redbot.core.bot import Red
log = logging.getLogger("red.serverwhitelist")
EMBED_COLOUR = 0x2F3136
PER_PAGE = 8 # guilds per paginated page
# ═══════════════════════════════════════════════════════════════════
# Paginated View (reusable)
# ═══════════════════════════════════════════════════════════════════
class PaginatedView(discord.ui.View):
"""Generic paginator that cycles through a list of embeds."""
def __init__(
self,
pages: list[discord.Embed],
*,
author_id: int,
timeout: float = 180.0,
) -> None:
super().__init__(timeout=timeout)
self.pages = pages
self.author_id = author_id
self.current = 0
self._update_buttons()
# ── helpers ───────────────────────────────────────────────
def _update_buttons(self) -> None:
self.first_btn.disabled = self.current == 0
self.prev_btn.disabled = self.current == 0
self.next_btn.disabled = self.current >= len(self.pages) - 1
self.last_btn.disabled = self.current >= len(self.pages) - 1
self.page_indicator.label = f"{self.current + 1}/{len(self.pages)}"
async def interaction_check(self, interaction: Interaction) -> bool:
if interaction.user.id != self.author_id:
await interaction.response.send_message(
"Only the bot owner can use these controls.", ephemeral=True
)
return False
return True
async def _show(self, interaction: Interaction) -> None:
self._update_buttons()
await interaction.response.edit_message(
embed=self.pages[self.current], view=self
)
# ── buttons ──────────────────────────────────────────────
@discord.ui.button(emoji="⏮️", style=discord.ButtonStyle.secondary)
async def first_btn(self, interaction: Interaction, button: discord.ui.Button):
self.current = 0
await self._show(interaction)
@discord.ui.button(emoji="◀️", style=discord.ButtonStyle.secondary)
async def prev_btn(self, interaction: Interaction, button: discord.ui.Button):
self.current = max(0, self.current - 1)
await self._show(interaction)
@discord.ui.button(label="1/1", style=discord.ButtonStyle.primary, disabled=True)
async def page_indicator(self, interaction: Interaction, button: discord.ui.Button):
await interaction.response.defer()
@discord.ui.button(emoji="▶️", style=discord.ButtonStyle.secondary)
async def next_btn(self, interaction: Interaction, button: discord.ui.Button):
self.current = min(len(self.pages) - 1, self.current + 1)
await self._show(interaction)
@discord.ui.button(emoji="⏭️", style=discord.ButtonStyle.secondary)
async def last_btn(self, interaction: Interaction, button: discord.ui.Button):
self.current = len(self.pages) - 1
await self._show(interaction)
# ═══════════════════════════════════════════════════════════════════
# Leave-button View (attached to server list pages)
# ═══════════════════════════════════════════════════════════════════
class ServerLeaveSelect(discord.ui.Select):
"""Dropdown of guilds on the current page — pick one to leave."""
def __init__(self, guilds: list[discord.Guild], *, cog: "ServerWhitelist"):
self.cog = cog
options = [
discord.SelectOption(
label=g.name[:100],
value=str(g.id),
description=f"ID: {g.id} • {g.member_count} members",
)
for g in guilds[:25]
]
super().__init__(
placeholder="🚪 Select a server to leave…",
options=options,
min_values=1,
max_values=1,
)
async def callback(self, interaction: Interaction) -> None:
guild_id = int(self.values[0])
guild = self.cog.bot.get_guild(guild_id)
if guild is None:
await interaction.response.send_message(
"Bot is no longer in that server.", ephemeral=True
)
return
name = guild.name
await guild.leave()
await self.cog._log_event(f"👋 Left **{name}** (`{guild_id}`) via server browser.")
await interaction.response.send_message(
f"👋 Left **{name}** (`{guild_id}`).", ephemeral=True
)
class ServerPageView(discord.ui.View):
"""Paginated server list with a leave-select on each page."""
def __init__(
self,
pages: list[discord.Embed],
guild_pages: list[list[discord.Guild]],
*,
cog: "ServerWhitelist",
author_id: int,
timeout: float = 180.0,
) -> None:
super().__init__(timeout=timeout)
self.pages = pages
self.guild_pages = guild_pages
self.cog = cog
self.author_id = author_id
self.current = 0
self._rebuild_select()
self._update_buttons()
# ── helpers ───────────────────────────────────────────────
def _rebuild_select(self) -> None:
# Remove existing select if any
for item in self.children[:]:
if isinstance(item, ServerLeaveSelect):
self.remove_item(item)
if self.guild_pages[self.current]:
self.add_item(
ServerLeaveSelect(self.guild_pages[self.current], cog=self.cog)
)
def _update_buttons(self) -> None:
self.first_btn.disabled = self.current == 0
self.prev_btn.disabled = self.current == 0
self.next_btn.disabled = self.current >= len(self.pages) - 1
self.last_btn.disabled = self.current >= len(self.pages) - 1
self.page_indicator.label = f"{self.current + 1}/{len(self.pages)}"
async def interaction_check(self, interaction: Interaction) -> bool:
if interaction.user.id != self.author_id:
await interaction.response.send_message(
"Only the bot owner can use these controls.", ephemeral=True
)
return False
return True
async def _show(self, interaction: Interaction) -> None:
self._update_buttons()
self._rebuild_select()
await interaction.response.edit_message(
embed=self.pages[self.current], view=self
)
# ── buttons ──────────────────────────────────────────────
@discord.ui.button(emoji="⏮️", style=discord.ButtonStyle.secondary)
async def first_btn(self, interaction: Interaction, button: discord.ui.Button):
self.current = 0
await self._show(interaction)
@discord.ui.button(emoji="◀️", style=discord.ButtonStyle.secondary)
async def prev_btn(self, interaction: Interaction, button: discord.ui.Button):
self.current = max(0, self.current - 1)
await self._show(interaction)
@discord.ui.button(label="1/1", style=discord.ButtonStyle.primary, disabled=True)
async def page_indicator(self, interaction: Interaction, button: discord.ui.Button):
await interaction.response.defer()
@discord.ui.button(emoji="▶️", style=discord.ButtonStyle.secondary)
async def next_btn(self, interaction: Interaction, button: discord.ui.Button):
self.current = min(len(self.pages) - 1, self.current + 1)
await self._show(interaction)
@discord.ui.button(emoji="⏭️", style=discord.ButtonStyle.secondary)
async def last_btn(self, interaction: Interaction, button: discord.ui.Button):
self.current = len(self.pages) - 1
await self._show(interaction)
# ═══════════════════════════════════════════════════════════════════
# Confirm Purge View
# ═══════════════════════════════════════════════════════════════════
class ConfirmPurgeView(discord.ui.View):
"""Two-button confirmation for the purge command."""
def __init__(self, *, author_id: int, timeout: float = 30.0) -> None:
super().__init__(timeout=timeout)
self.author_id = author_id
self.result: Optional[bool] = None
async def interaction_check(self, interaction: Interaction) -> bool:
return interaction.user.id == self.author_id
@discord.ui.button(label="Yes, purge", style=discord.ButtonStyle.danger, emoji="🗑️")
async def confirm(self, interaction: Interaction, button: discord.ui.Button):
self.result = True
self.stop()
await interaction.response.defer()
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
async def cancel(self, interaction: Interaction, button: discord.ui.Button):
self.result = False
self.stop()
await interaction.response.defer()
# ═══════════════════════════════════════════════════════════════════
# Main Cog
# ═══════════════════════════════════════════════════════════════════
class ServerWhitelist(commands.Cog):
"""Ultimate server management — whitelist, blacklist, browse, leave, lock & more."""
def __init__(self, bot: Red) -> None:
self.bot = bot
self.config = Config.get_conf(
self,
identifier=827364510293,
force_registration=True,
)
self.config.register_global(
whitelist=[],
blacklist=[],
locked=False,
log_channel=None, # int or None
)
# ── lifecycle ─────────────────────────────────────────────
async def cog_load(self) -> None:
"""Auto-whitelist every guild the bot is currently in."""
current_ids = [g.id for g in self.bot.guilds]
async with self.config.whitelist() as wl:
merged = list(set(wl) | set(current_ids))
wl.clear()
wl.extend(merged)
log.info(
"ServerWhitelist loaded — %d current guild(s) merged into whitelist (%d total).",
len(current_ids),
len(set(wl) | set(current_ids)),
)
# ── internal helpers ──────────────────────────────────────
async def _log_event(self, message: str) -> None:
"""Send an event message to the configured log channel, if any."""
channel_id: Optional[int] = await self.config.log_channel()
if channel_id is None:
return
channel = self.bot.get_channel(channel_id)
if channel is None:
return
try:
await channel.send(
embed=discord.Embed(
description=message,
colour=EMBED_COLOUR,
timestamp=datetime.now(timezone.utc),
).set_footer(text="ServerWhitelist")
)
except discord.HTTPException:
pass
@staticmethod
def _guild_line(guild: discord.Guild) -> str:
"""Single-line summary for embed fields."""
owner = guild.owner or "Unknown"
created = discord.utils.format_dt(guild.created_at, style="R")
joined = discord.utils.format_dt(guild.me.joined_at, style="R") if guild.me and guild.me.joined_at else "?"
return (
f"👑 **Owner:** {owner}\n"
f"👥 **Members:** {guild.member_count:,}\n"
f"📅 **Created:** {created}\n"
f"📥 **Joined:** {joined}"
)
def _paginate_guilds(
self, guilds: Sequence[discord.Guild], *, title: str, colour: int = EMBED_COLOUR
) -> tuple[list[discord.Embed], list[list[discord.Guild]]]:
"""Build paginated embeds + parallel guild-page lists for the View."""
pages: list[discord.Embed] = []
guild_pages: list[list[discord.Guild]] = []
for i in range(0, max(1, len(guilds)), PER_PAGE):
chunk = list(guilds[i : i + PER_PAGE])
em = discord.Embed(title=title, colour=colour)
if not chunk:
em.description = "No servers to display."
for g in chunk:
em.add_field(
name=f"{g.name} (`{g.id}`)",
value=self._guild_line(g),
inline=False,
)
em.set_footer(
text=f"Total: {len(guilds)} server(s)"
)
pages.append(em)
guild_pages.append(chunk)
return pages, guild_pages
def _paginate_ids(
self, ids: list[int], *, title: str, colour: int = EMBED_COLOUR
) -> list[discord.Embed]:
"""Build paginated embeds for a plain list of IDs."""
pages: list[discord.Embed] = []
for i in range(0, max(1, len(ids)), PER_PAGE):
chunk = ids[i : i + PER_PAGE]
em = discord.Embed(title=title, colour=colour)
if not chunk:
em.description = "List is empty."
else:
lines: list[str] = []
for gid in chunk:
guild = self.bot.get_guild(gid)
name = guild.name if guild else "Unknown / Not Joined"
status = "🟢" if guild else "⚫"
members = f" • {guild.member_count:,} members" if guild else ""
lines.append(f"{status} `{gid}` — **{name}**{members}")
em.description = "\n".join(lines)
em.set_footer(text=f"Total: {len(ids)} server(s)")
pages.append(em)
return pages
# ── listeners ─────────────────────────────────────────────
@commands.Cog.listener()
async def on_guild_join(self, guild: discord.Guild) -> None:
"""Enforce whitelist / blacklist / lock on every new guild join."""
locked: bool = await self.config.locked()
blacklist: list[int] = await self.config.blacklist()
whitelist: list[int] = await self.config.whitelist()
# Blacklist always wins
if guild.id in blacklist:
log.info("Leaving BLACKLISTED guild: %s (%d)", guild.name, guild.id)
await self._log_event(
f"🚫 Blocked join to **blacklisted** server **{guild.name}** (`{guild.id}`)."
)
await guild.leave()
return
# Lock mode — reject everything new
if locked:
log.info("Leaving guild (LOCKED mode): %s (%d)", guild.name, guild.id)
await self._log_event(
f"🔒 Blocked join to **{guild.name}** (`{guild.id}`) — bot is *locked*."
)
await guild.leave()
return
# Normal whitelist check
if guild.id not in whitelist:
log.info("Leaving non-whitelisted guild: %s (%d)", guild.name, guild.id)
await self._log_event(
f"⛔ Left non-whitelisted server **{guild.name}** (`{guild.id}`)."
)
await guild.leave()
return
# Allowed — log it
await self._log_event(
f"✅ Joined whitelisted server **{guild.name}** (`{guild.id}`)."
)
@commands.Cog.listener()
async def on_guild_remove(self, guild: discord.Guild) -> None:
"""Log when the bot leaves / is removed from a guild."""
await self._log_event(
f"📤 Left/removed from **{guild.name}** (`{guild.id}`)."
)
# ═══════════════════════════════════════════════════════════
# Command Group
# ═══════════════════════════════════════════════════════════
@commands.hybrid_group(
name="join",
invoke_without_command=True,
fallback="add",
)
@commands.is_owner()
async def join_group(self, ctx: commands.Context, server_id: int) -> None:
"""Add a server to the whitelist.
The bot will stay in this server when invited.
Usage: ``[p]join <server_id>``
"""
blacklist: list[int] = await self.config.blacklist()
if server_id in blacklist:
await ctx.send(
f"⚠️ `{server_id}` is currently *blacklisted*. Remove it from "
f"the blacklist first with `{ctx.clean_prefix}join unblacklist {server_id}`."
)
return
async with self.config.whitelist() as wl:
if server_id in wl:
await ctx.send(f"✅ `{server_id}` is already whitelisted.")
return
wl.append(server_id)
await ctx.send(f"✅ `{server_id}` has been added to the whitelist.")
await self._log_event(f"✅ `{server_id}` whitelisted by **{ctx.author}**.")
log.info("Whitelisted guild %d (by %s).", server_id, ctx.author)
# ── remove ────────────────────────────────────────────────
@join_group.command(name="remove")
@commands.is_owner()
async def join_remove(self, ctx: commands.Context, server_id: int) -> None:
"""Remove a server from the whitelist.
If the bot is currently in that server it will leave immediately.
Usage: ``[p]join remove <server_id>``
"""
async with self.config.whitelist() as wl:
if server_id not in wl:
await ctx.send(f"⚠️ `{server_id}` is not on the whitelist.")
return
wl.remove(server_id)
await ctx.send(f"🗑️ `{server_id}` removed from the whitelist.")
await self._log_event(f"🗑️ `{server_id}` un-whitelisted by **{ctx.author}**.")
guild = self.bot.get_guild(server_id)
if guild is not None:
await guild.leave()
await ctx.send(f"👋 Left **{guild.name}**.")
# ── whitelist (list) ──────────────────────────────────────
@join_group.command(name="whitelist", aliases=["list", "wl"])
@commands.is_owner()
async def join_whitelist(self, ctx: commands.Context) -> None:
"""Show all whitelisted servers in a paginated embed.
Usage: ``[p]join whitelist``
"""
wl: list[int] = await self.config.whitelist()
pages = self._paginate_ids(sorted(wl), title="📋 Whitelisted Servers")
view = PaginatedView(pages, author_id=ctx.author.id)
await ctx.send(embed=pages[0], view=view)
# ── blacklist ─────────────────────────────────────────────
@join_group.command(name="blacklist", aliases=["bl", "block"])
@commands.is_owner()
async def join_blacklist(self, ctx: commands.Context, server_id: int) -> None:
"""Blacklist a server — the bot will leave and never rejoin.
Also removes the server from the whitelist if present.
Usage: ``[p]join blacklist <server_id>``
"""
async with self.config.blacklist() as bl:
if server_id in bl:
await ctx.send(f"⚠️ `{server_id}` is already blacklisted.")
return
bl.append(server_id)
# Remove from whitelist if present
async with self.config.whitelist() as wl:
if server_id in wl:
wl.remove(server_id)
await ctx.send(f"🚫 `{server_id}` has been blacklisted.")
await self._log_event(f"🚫 `{server_id}` blacklisted by **{ctx.author}**.")
guild = self.bot.get_guild(server_id)
if guild is not None:
name = guild.name
await guild.leave()
await ctx.send(f"👋 Left **{name}**.")
@join_group.command(name="unblacklist", aliases=["unbl", "unblock"])
@commands.is_owner()
async def join_unblacklist(self, ctx: commands.Context, server_id: int) -> None:
"""Remove a server from the blacklist.
Usage: ``[p]join unblacklist <server_id>``
"""
async with self.config.blacklist() as bl:
if server_id not in bl:
await ctx.send(f"⚠️ `{server_id}` is not on the blacklist.")
return
bl.remove(server_id)
await ctx.send(f"✅ `{server_id}` removed from the blacklist.")
await self._log_event(f"✅ `{server_id}` un-blacklisted by **{ctx.author}**.")
@join_group.command(name="blacklisted", aliases=["blist"])
@commands.is_owner()
async def join_blacklisted(self, ctx: commands.Context) -> None:
"""View all blacklisted servers.
Usage: ``[p]join blacklisted``
"""
bl: list[int] = await self.config.blacklist()
pages = self._paginate_ids(sorted(bl), title="🚫 Blacklisted Servers", colour=0xE74C3C)
view = PaginatedView(pages, author_id=ctx.author.id)
await ctx.send(embed=pages[0], view=view)
# ── servers (interactive browser) ─────────────────────────
@join_group.command(name="servers", aliases=["all", "browse"])
@commands.is_owner()
async def join_servers(self, ctx: commands.Context) -> None:
"""Browse all servers the bot is in with leave controls.
A dropdown on each page lets you leave any listed server instantly.
Usage: ``[p]join servers``
"""
guilds = sorted(self.bot.guilds, key=lambda g: g.member_count or 0, reverse=True)
if not guilds:
await ctx.send("The bot is not in any servers.")
return
pages, guild_pages = self._paginate_guilds(guilds, title="🌐 All Servers")
view = ServerPageView(
pages, guild_pages, cog=self, author_id=ctx.author.id
)
await ctx.send(embed=pages[0], view=view)
# ── info ──────────────────────────────────────────────────
@join_group.command(name="info")
@commands.is_owner()
async def join_info(self, ctx: commands.Context, server_id: int) -> None:
"""Show detailed information about a specific server.
Usage: ``[p]join info <server_id>``
"""
guild = self.bot.get_guild(server_id)
if guild is None:
await ctx.send(f"⚠️ Bot is not in a server with ID `{server_id}`.")
return
whitelist: list[int] = await self.config.whitelist()
blacklist: list[int] = await self.config.blacklist()
status_parts: list[str] = []
if guild.id in whitelist:
status_parts.append("✅ Whitelisted")
if guild.id in blacklist:
status_parts.append("🚫 Blacklisted")
if not status_parts:
status_parts.append("⚪ Not listed")
text_channels = len(guild.text_channels)
voice_channels = len(guild.voice_channels)
categories = len(guild.categories)
roles = len(guild.roles)
emojis = len(guild.emojis)
boosts = guild.premium_subscription_count or 0
boost_tier = guild.premium_tier
created = discord.utils.format_dt(guild.created_at, style="F")
created_rel = discord.utils.format_dt(guild.created_at, style="R")
joined = (
discord.utils.format_dt(guild.me.joined_at, style="F")
if guild.me and guild.me.joined_at
else "Unknown"
)
joined_rel = (
discord.utils.format_dt(guild.me.joined_at, style="R")
if guild.me and guild.me.joined_at
else ""
)
verification = str(guild.verification_level).replace("_", " ").title()
em = discord.Embed(title=guild.name, colour=EMBED_COLOUR)
if guild.icon:
em.set_thumbnail(url=guild.icon.url)
if guild.banner:
em.set_image(url=guild.banner.url)
em.add_field(name="ID", value=f"`{guild.id}`", inline=True)
em.add_field(name="Owner", value=str(guild.owner or "Unknown"), inline=True)
em.add_field(name="Status", value=" • ".join(status_parts), inline=True)
em.add_field(name="Members", value=f"{guild.member_count:,}", inline=True)
em.add_field(name="Roles", value=str(roles), inline=True)
em.add_field(name="Emojis", value=str(emojis), inline=True)
em.add_field(
name="Channels",
value=f"💬 {text_channels} text · 🔊 {voice_channels} voice · 📁 {categories} categories",
inline=False,
)
em.add_field(
name="Boosts",
value=f"Level {boost_tier} ({boosts} boost{'s' if boosts != 1 else ''})",
inline=True,
)
em.add_field(name="Verification", value=verification, inline=True)
em.add_field(name="Created", value=f"{created}\n{created_rel}", inline=False)
em.add_field(name="Bot Joined", value=f"{joined}\n{joined_rel}", inline=False)
if guild.features:
em.add_field(
name="Features",
value=", ".join(f"`{f}`" for f in sorted(guild.features)[:15]),
inline=False,
)
await ctx.send(embed=em)
# ── search ────────────────────────────────────────────────
@join_group.command(name="search", aliases=["find"])
@commands.is_owner()
async def join_search(self, ctx: commands.Context, *, query: str) -> None:
"""Search servers by name (case-insensitive).
Usage: ``[p]join search <query>``
"""
q = query.lower()
matches = [g for g in self.bot.guilds if q in g.name.lower()]
if not matches:
await ctx.send(f"No servers matched `{query}`.")
return
matches.sort(key=lambda g: g.member_count or 0, reverse=True)
pages, guild_pages = self._paginate_guilds(
matches, title=f"🔍 Search: \"{query}\" — {len(matches)} result(s)"
)
view = ServerPageView(
pages, guild_pages, cog=self, author_id=ctx.author.id
)
await ctx.send(embed=pages[0], view=view)
# ── leave ─────────────────────────────────────────────────
@join_group.command(name="leave")
@commands.is_owner()
async def join_leave(self, ctx: commands.Context, server_id: int) -> None:
"""Leave a server immediately by ID.
Does NOT remove it from the whitelist — use ``remove`` for that.
Usage: ``[p]join leave <server_id>``
"""
guild = self.bot.get_guild(server_id)
if guild is None:
await ctx.send(f"⚠️ Bot is not in a server with ID `{server_id}`.")
return
name = guild.name
await guild.leave()
await ctx.send(f"👋 Left **{name}** (`{server_id}`).")
await self._log_event(f"👋 Left **{name}** (`{server_id}`) — manual leave by **{ctx.author}**.")
# ── purge ─────────────────────────────────────────────────
@join_group.command(name="purge")
@commands.is_owner()
async def join_purge(self, ctx: commands.Context) -> None:
"""Leave ALL servers that are not whitelisted.
Requires confirmation before executing.
Usage: ``[p]join purge``
"""
whitelist: list[int] = await self.config.whitelist()
to_leave = [g for g in self.bot.guilds if g.id not in whitelist]
if not to_leave:
await ctx.send("✅ All current servers are whitelisted — nothing to purge.")
return
names = "\n".join(f"• **{g.name}** (`{g.id}`)" for g in to_leave[:20])
extra = f"\n…and {len(to_leave) - 20} more" if len(to_leave) > 20 else ""
em = discord.Embed(
title="⚠️ Confirm Purge",
description=(
f"The bot will leave **{len(to_leave)}** non-whitelisted server(s):\n\n"
f"{names}{extra}"
),
colour=0xE74C3C,
)
view = ConfirmPurgeView(author_id=ctx.author.id)
msg = await ctx.send(embed=em, view=view)
await view.wait()
if view.result is True:
left: list[str] = []
for g in to_leave:
try:
await g.leave()
left.append(g.name)
except discord.HTTPException:
pass
await msg.edit(
embed=discord.Embed(
title="🗑️ Purge Complete",
description=f"Left **{len(left)}** server(s).",
colour=0x2ECC71,
),
view=None,
)
await self._log_event(
f"🗑️ Purge by **{ctx.author}** — left {len(left)} server(s)."
)
else:
await msg.edit(
embed=discord.Embed(
title="Purge Cancelled",
description="No servers were left.",
colour=EMBED_COLOUR,
),
view=None,
)
# ── lock / unlock ─────────────────────────────────────────
@join_group.command(name="lock")
@commands.is_owner()
async def join_lock(self, ctx: commands.Context) -> None:
"""Lock the bot — reject ALL new server joins (even whitelisted).
Usage: ``[p]join lock``
"""
if await self.config.locked():
await ctx.send("🔒 Already locked.")
return
await self.config.locked.set(True)
await ctx.send("🔒 Bot is now **locked** — no new servers will be joined.")
await self._log_event(f"🔒 Bot locked by **{ctx.author}**.")
@join_group.command(name="unlock")
@commands.is_owner()
async def join_unlock(self, ctx: commands.Context) -> None:
"""Unlock the bot — resume normal whitelist behaviour.
Usage: ``[p]join unlock``
"""
if not await self.config.locked():
await ctx.send("🔓 Already unlocked.")
return
await self.config.locked.set(False)
await ctx.send("🔓 Bot is now **unlocked** — whitelist rules apply normally.")
await self._log_event(f"🔓 Bot unlocked by **{ctx.author}**.")
# ── log channel ───────────────────────────────────────────
@join_group.command(name="log")
@commands.is_owner()
async def join_log(
self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None
) -> None:
"""Set a channel for join/leave/block event logs.
Pass no argument or "off" to disable logging.
Usage: ``[p]join log #channel`` or ``[p]join log off``
"""
# Check if the raw argument is "off"
if channel is None:
await self.config.log_channel.set(None)
await ctx.send("📝 Event logging **disabled**.")
return
await self.config.log_channel.set(channel.id)
await ctx.send(f"📝 Events will be logged to {channel.mention}.")
# ── stats ─────────────────────────────────────────────────
@join_group.command(name="stats")
@commands.is_owner()
async def join_stats(self, ctx: commands.Context) -> None:
"""Show a high-level overview of the bot's server footprint.
Usage: ``[p]join stats``
"""
whitelist: list[int] = await self.config.whitelist()
blacklist: list[int] = await self.config.blacklist()
locked: bool = await self.config.locked()
log_channel: Optional[int] = await self.config.log_channel()
total_guilds = len(self.bot.guilds)
total_members = sum(g.member_count or 0 for g in self.bot.guilds)
largest = max(self.bot.guilds, key=lambda g: g.member_count or 0) if self.bot.guilds else None
smallest = min(self.bot.guilds, key=lambda g: g.member_count or 0) if self.bot.guilds else None
not_whitelisted = [g for g in self.bot.guilds if g.id not in whitelist]
em = discord.Embed(title="📊 Server Stats", colour=EMBED_COLOUR)
em.add_field(name="Servers", value=f"{total_guilds:,}", inline=True)
em.add_field(name="Total Members", value=f"{total_members:,}", inline=True)
em.add_field(name="Whitelisted", value=str(len(whitelist)), inline=True)
em.add_field(name="Blacklisted", value=str(len(blacklist)), inline=True)
em.add_field(name="Not Whitelisted (in bot)", value=str(len(not_whitelisted)), inline=True)
em.add_field(
name="Lock Status",
value="🔒 Locked" if locked else "🔓 Unlocked",
inline=True,
)
if largest:
em.add_field(
name="Largest Server",
value=f"**{largest.name}** ({largest.member_count:,})",
inline=False,
)
if smallest and smallest != largest:
em.add_field(
name="Smallest Server",
value=f"**{smallest.name}** ({smallest.member_count:,})",
inline=False,
)
log_ch = self.bot.get_channel(log_channel) if log_channel else None
em.add_field(
name="Log Channel",
value=log_ch.mention if log_ch else "Disabled",
inline=False,
)
await ctx.send(embed=em)
# ── settings ──────────────────────────────────────────────
@join_group.command(name="settings", aliases=["config"])
@commands.is_owner()
async def join_settings(self, ctx: commands.Context) -> None:
"""Display current ServerWhitelist configuration.
Usage: ``[p]join settings``
"""
whitelist: list[int] = await self.config.whitelist()
blacklist: list[int] = await self.config.blacklist()
locked: bool = await self.config.locked()
log_channel: Optional[int] = await self.config.log_channel()
log_ch = self.bot.get_channel(log_channel) if log_channel else None
em = discord.Embed(title="⚙️ ServerWhitelist Settings", colour=EMBED_COLOUR)
em.add_field(name="Whitelisted", value=str(len(whitelist)), inline=True)
em.add_field(name="Blacklisted", value=str(len(blacklist)), inline=True)
em.add_field(name="Current Servers", value=str(len(self.bot.guilds)), inline=True)
em.add_field(
name="Lock Mode",
value="🔒 **ON** — all new joins rejected" if locked else "🔓 OFF — whitelist rules apply",
inline=False,
)
em.add_field(
name="Log Channel",
value=log_ch.mention if log_ch else "Not set (`[p]join log #channel`)",
inline=False,
)
em.add_field(
name="Quick Reference",
value=(
f"`{ctx.clean_prefix}join <id>` — whitelist a server\n"
f"`{ctx.clean_prefix}join remove <id>` — un-whitelist & leave\n"
f"`{ctx.clean_prefix}join blacklist <id>` — blacklist & leave\n"
f"`{ctx.clean_prefix}join servers` — browse all servers\n"
f"`{ctx.clean_prefix}join purge` — leave all non-whitelisted\n"
f"`{ctx.clean_prefix}join lock/unlock` — toggle lock mode"
),
inline=False,
)
await ctx.send(embed=em)
# ── export ────────────────────────────────────────────────
@join_group.command(name="export")
@commands.is_owner()
async def join_export(self, ctx: commands.Context) -> None:
"""Export the full server list as a .txt file.
Includes ID, name, member count, whitelist/blacklist status, and join date.
Usage: ``[p]join export``
"""
whitelist: list[int] = await self.config.whitelist()
blacklist: list[int] = await self.config.blacklist()
lines: list[str] = [
f"{'ID':<22} {'Members':>8} {'Status':<14} {'Joined':<26} Name",
"─" * 100,
]
for g in sorted(self.bot.guilds, key=lambda g: g.member_count or 0, reverse=True):
status_parts = []
if g.id in whitelist:
status_parts.append("WL")
if g.id in blacklist:
status_parts.append("BL")
status = ",".join(status_parts) if status_parts else "—"
joined = (
g.me.joined_at.strftime("%Y-%m-%d %H:%M UTC")
if g.me and g.me.joined_at
else "Unknown"
)
lines.append(
f"{g.id:<22} {g.member_count or 0:>8,} {status:<14} {joined:<26} {g.name}"
)
content = "\n".join(lines)
buf = io.BytesIO(content.encode())
file = discord.File(buf, filename="server_export.txt")
await ctx.send("📄 Here's your server export:", file=file)