feat: ServerWhitelist — Ultimate owner-only server management cog #3
4 changed files with 1012 additions and 1 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
13
serverwhitelist/__init__.py
Normal file
13
serverwhitelist/__init__.py
Normal 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
27
serverwhitelist/info.json
Normal 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": []
|
||||
}
|
||||
971
serverwhitelist/serverwhitelist.py
Normal file
971
serverwhitelist/serverwhitelist.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue