""" ServerWhitelist v3.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 `` — whitelist a server • ``[p]join remove `` — un-whitelist a server (optionally leave) • ``[p]join blacklist ``— blacklist a server (auto-leave & never rejoin) • ``[p]join unblacklist `` — 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 `` — detailed embed for one guild • ``[p]join search ``— search servers by name • ``[p]join stats`` — high-level overview Bulk Actions • ``[p]join leave `` — 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 `` — log join/leave/block events to a channel • ``[p]join log off`` — disable logging Attempt Tracking & Auto-Ban • ``[p]join attempts`` — view all servers with join attempts • ``[p]join attempts reset `` — reset attempts for a server • ``[p]join attempts resetall`` — reset all attempt counters • ``[p]join maxattempts `` — set the max attempts before auto-ban (default 5) Owner DM • Bot DMs the server owner before leaving non-whitelisted/locked servers • Customisable with ``[p]join setmessage `` / ``[p]join resetmessage`` Settings • ``[p]join settings`` — display current config at a glance Export • ``[p]join export`` — upload a .txt or .csv 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 DEFAULT_LEAVE_MESSAGE = ( "Hello! I'm sorry, your server is not in my whitelist. " "You can always try to request it to be added! Just ask the bot's owner! " "If you can, however, don't add this bot until permission is given, " "otherwise it will get banned and never allowed to be whitelisted! " "Have a great day!" ) # ═══════════════════════════════════════════════════════════════════ # 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( title="👋 Left Server (Browser)", description=f"Left **{name}** (`{guild_id}`) via server browser.", colour=0xE67E22, ) 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, attempt tracking & 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 max_attempts=5, # auto-ban threshold leave_message=None, # custom DM text (None → DEFAULT_LEAVE_MESSAGE) # Per-guild attempt tracking: { "guild_id_str": { "count": int, "last_attempt": iso, "owner_id": int, "name": str } } join_attempts={}, ) # ── 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(merged), ) # ── internal helpers ────────────────────────────────────── async def _log_event( self, description: str, *, title: str = "ServerWhitelist", colour: int = EMBED_COLOUR, fields: list[tuple[str, str, bool]] | None = None, thumbnail_url: str | None = None, footer: str | None = None, ) -> None: """Send a rich event embed 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: em = discord.Embed( title=title, description=description, colour=colour, timestamp=datetime.now(timezone.utc), ) if thumbnail_url: em.set_thumbnail(url=thumbnail_url) if fields: for name, value, inline in fields: em.add_field(name=name, value=value, inline=inline) em.set_footer(text=footer or "ServerWhitelist v3.0") await channel.send(embed=em) except discord.HTTPException: pass def _guild_detail_fields(self, guild: discord.Guild) -> list[tuple[str, str, bool]]: """Build a list of (name, value, inline) tuples with rich server info.""" owner = guild.owner owner_str = f"{owner} (`{owner.id}`)" if owner else "Unknown" 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 "" ) text_ch = len(guild.text_channels) voice_ch = 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 verification = str(guild.verification_level).replace("_", " ").title() humans = sum(1 for m in guild.members if not m.bot) if guild.chunked else "?" bots = sum(1 for m in guild.members if m.bot) if guild.chunked else "?" fields: list[tuple[str, str, bool]] = [ ("Server ID", f"`{guild.id}`", True), ("Owner", owner_str, True), ("Members", f"{guild.member_count:,} (👤 {humans} humans · 🤖 {bots} bots)", False), ("Channels", f"💬 {text_ch} text · 🔊 {voice_ch} voice · 📁 {categories} categories", False), ("Roles", str(roles), True), ("Emojis", str(emojis), True), ("Boosts", f"Level {boost_tier} ({boosts} boost{'s' if boosts != 1 else ''})", True), ("Verification", verification, True), ("Created", f"{created} ({created_rel})", False), ("Bot Joined", f"{joined} {joined_rel}".strip(), False), ] if guild.features: fields.append(( "Features", ", ".join(f"`{f}`" for f in sorted(guild.features)[:20]), False, )) return fields @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 # ── DM the server owner ─────────────────────────────────── async def _dm_owner( self, guild: discord.Guild, *, reason: str, extra: str | None = None, ) -> bool: """Attempt to DM the server owner before the bot leaves. Returns True if the DM was sent successfully, False otherwise. """ owner = guild.owner if owner is None: log.warning("Could not resolve owner for guild %s (%d).", guild.name, guild.id) return False leave_msg: str | None = await self.config.leave_message() text = leave_msg or DEFAULT_LEAVE_MESSAGE em = discord.Embed( title="⚠️ Server Not Whitelisted", description=text, colour=0xFFA500, timestamp=datetime.now(timezone.utc), ) em.add_field(name="Server", value=f"**{guild.name}** (`{guild.id}`)", inline=False) em.add_field(name="Reason", value=reason, inline=False) if extra: em.add_field(name="⚠️ Warning", value=extra, inline=False) em.set_footer(text="ServerWhitelist") if guild.icon: em.set_thumbnail(url=guild.icon.url) try: await owner.send(embed=em) log.info("Sent leave DM to owner %s (%d) of guild %s.", owner, owner.id, guild.name) return True except (discord.Forbidden, discord.HTTPException) as exc: log.warning("Could not DM owner %s (%d): %s", owner, owner.id, exc) return False # ── attempt tracking ────────────────────────────────────── async def _record_attempt(self, guild: discord.Guild) -> tuple[int, bool]: """Record a join attempt for a guild. Returns (current_count, was_auto_banned). """ max_attempts: int = await self.config.max_attempts() gid_str = str(guild.id) now_iso = datetime.now(timezone.utc).isoformat() async with self.config.join_attempts() as attempts: entry = attempts.get(gid_str, { "count": 0, "first_attempt": now_iso, "last_attempt": now_iso, "owner_id": guild.owner_id, "name": guild.name, }) entry["count"] = entry.get("count", 0) + 1 entry["last_attempt"] = now_iso entry["owner_id"] = guild.owner_id entry["name"] = guild.name if "first_attempt" not in entry: entry["first_attempt"] = now_iso attempts[gid_str] = entry count = entry["count"] auto_banned = False if count >= max_attempts: # Auto-ban: add to blacklist, remove from whitelist async with self.config.blacklist() as bl: if guild.id not in bl: bl.append(guild.id) auto_banned = True async with self.config.whitelist() as wl: if guild.id in wl: wl.remove(guild.id) if auto_banned: log.warning( "AUTO-BANNED guild %s (%d) after %d unauthorized join attempts.", guild.name, guild.id, count, ) return count, auto_banned # ── 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() max_attempts: int = await self.config.max_attempts() # Build common log fields for rich logging icon_url = guild.icon.url if guild.icon else None owner = guild.owner owner_str = f"{owner} (`{owner.id}`)" if owner else "Unknown" log_fields: list[tuple[str, str, bool]] = [ ("Server", f"**{guild.name}** (`{guild.id}`)", False), ("Owner", owner_str, True), ("Members", f"{guild.member_count:,}" if guild.member_count else "?", True), ("Created", discord.utils.format_dt(guild.created_at, style="R"), True), ] if guild.features: log_fields.append(( "Features", ", ".join(f"`{f}`" for f in sorted(guild.features)[:10]), False, )) # ── Blacklisted ────────────────────────────────────── if guild.id in blacklist: log.info("Leaving BLACKLISTED guild: %s (%d)", guild.name, guild.id) # Record the attempt anyway count, _ = await self._record_attempt(guild) await self._dm_owner( guild, reason="This server is *permanently blacklisted* and cannot use this bot.", extra="⛔ This server has been *banned*. Continued attempts will be ignored.", ) await self._log_event( title="🚫 Blocked — Blacklisted Server", description=f"Rejected join to **blacklisted** server **{guild.name}**.", colour=0xE74C3C, fields=log_fields + [("Attempts", str(count), True)], thumbnail_url=icon_url, ) await guild.leave() return # ── Locked mode ────────────────────────────────────── if locked: log.info("Leaving guild (LOCKED mode): %s (%d)", guild.name, guild.id) await self._dm_owner( guild, reason="The bot is currently in *lock mode* and not accepting any new servers.", ) await self._log_event( title="🔒 Blocked — Bot Locked", description=f"Rejected join to **{guild.name}** — bot is *locked*.", colour=0x95A5A6, fields=log_fields, thumbnail_url=icon_url, ) await guild.leave() return # ── Not whitelisted ────────────────────────────────── if guild.id not in whitelist: count, auto_banned = await self._record_attempt(guild) remaining = max(0, max_attempts - count) log.info( "Leaving non-whitelisted guild: %s (%d) — attempt %d/%d", guild.name, guild.id, count, max_attempts, ) # Build warning text for DM if auto_banned: extra = ( f"🚨 *This server has been automatically banned after " f"{count} unauthorized join attempt(s).* The bot will *never* " f"join this server again. Contact the bot owner if you believe " f"this is a mistake." ) reason = f"Not whitelisted — *auto-banned* after {count}/{max_attempts} attempts." elif remaining <= 2 and remaining > 0: extra = ( f"⚠️ *Warning:* This server has {count}/{max_attempts} attempts used. " f"Only *{remaining}* attempt(s) remain before an automatic permanent ban!" ) reason = f"Not whitelisted (attempt {count}/{max_attempts})." else: extra = ( f"ℹ️ Attempt {count}/{max_attempts}. " f"After {max_attempts} attempts the server will be *permanently banned*." ) if count > 1 else None reason = f"Not whitelisted (attempt {count}/{max_attempts})." await self._dm_owner(guild, reason=reason, extra=extra) # Log event with colour escalation if auto_banned: log_colour = 0xE74C3C # Red log_title = "🚨 Auto-Banned — Max Attempts Reached" log_desc = ( f"**{guild.name}** (`{guild.id}`) has been *automatically blacklisted* " f"after **{count}** unauthorized join attempts." ) elif remaining <= 2: log_colour = 0xE67E22 # Orange log_title = f"⚠️ Non-Whitelisted Join (Attempt {count}/{max_attempts})" log_desc = ( f"Rejected **{guild.name}** — only **{remaining}** attempt(s) remain " f"before auto-ban." ) else: log_colour = 0xF1C40F # Yellow log_title = f"⛔ Non-Whitelisted Join (Attempt {count}/{max_attempts})" log_desc = f"Rejected **{guild.name}** — not on the whitelist." await self._log_event( title=log_title, description=log_desc, colour=log_colour, fields=log_fields + [ ("Attempts", f"{count}/{max_attempts}", True), ("Remaining", str(remaining) if not auto_banned else "BANNED", True), ], thumbnail_url=icon_url, ) await guild.leave() return # ── Allowed — whitelisted ──────────────────────────── await self._log_event( title="✅ Joined Whitelisted Server", description=f"Successfully joined **{guild.name}** (`{guild.id}`).", colour=0x2ECC71, fields=log_fields, thumbnail_url=icon_url, ) @commands.Cog.listener() async def on_guild_remove(self, guild: discord.Guild) -> None: """Log when the bot leaves / is removed from a guild.""" icon_url = guild.icon.url if guild.icon else None owner = guild.owner owner_str = f"{owner} (`{owner.id}`)" if owner else "Unknown" await self._log_event( title="📤 Left / Removed From Server", description=f"No longer in **{guild.name}** (`{guild.id}`).", colour=0x95A5A6, fields=[ ("Server", f"**{guild.name}** (`{guild.id}`)", False), ("Owner", owner_str, True), ("Members", f"{guild.member_count:,}" if guild.member_count else "?", True), ], thumbnail_url=icon_url, ) # ═══════════════════════════════════════════════════════════ # 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. Also resets any accumulated join attempts for that server. Usage: ``[p]join `` """ 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) # Reset attempts on whitelist async with self.config.join_attempts() as attempts: attempts.pop(str(server_id), None) await ctx.send(f"✅ `{server_id}` has been added to the whitelist. Any previous join attempts have been cleared.") await self._log_event( title="✅ Server Whitelisted", description=f"`{server_id}` whitelisted by **{ctx.author}**.", colour=0x2ECC71, ) 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 `` """ 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( title="🗑️ Server Un-Whitelisted", description=f"`{server_id}` un-whitelisted by **{ctx.author}**.", colour=0xE67E22, ) 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 `` """ 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( title="🚫 Server Blacklisted", description=f"`{server_id}` blacklisted by **{ctx.author}**.", colour=0xE74C3C, ) 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. Also resets accumulated join attempts for that server. Usage: ``[p]join unblacklist `` """ 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) # Reset attempts on unblacklist async with self.config.join_attempts() as attempts: attempts.pop(str(server_id), None) await ctx.send(f"✅ `{server_id}` removed from the blacklist. Join attempts have been reset.") await self._log_event( title="✅ Server Un-Blacklisted", description=f"`{server_id}` un-blacklisted by **{ctx.author}**. Attempts reset.", colour=0x2ECC71, ) @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. Includes owner, channels, roles, emojis, boosts, verification, features, whitelist/blacklist status, and join attempts. Usage: ``[p]join info `` """ 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() join_attempts: dict = await self.config.join_attempts() 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") 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) # Detailed fields fields = self._guild_detail_fields(guild) for name, value, inline in fields: em.add_field(name=name, value=value, inline=inline) em.add_field(name="Status", value=" • ".join(status_parts), inline=False) # Attempt info attempt_data = join_attempts.get(str(server_id)) if attempt_data: max_att = await self.config.max_attempts() em.add_field( name="Join Attempts", value=( f"**{attempt_data['count']}** / {max_att}\n" f"First: `{attempt_data.get('first_attempt', '?')}`\n" f"Last: `{attempt_data.get('last_attempt', '?')}`" ), inline=False, ) if guild.description: em.add_field(name="Description", value=guild.description, 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 `` """ 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 `` """ 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 icon_url = guild.icon.url if guild.icon else None await guild.leave() await ctx.send(f"👋 Left **{name}** (`{server_id}`).") await self._log_event( title="👋 Left Server (Manual)", description=f"Left **{name}** (`{server_id}`) — manual leave by **{ctx.author}**.", colour=0xE67E22, thumbnail_url=icon_url, ) # ── 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}`) — {g.member_count:,} members" 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):\n" + "\n".join(f"• {n}" for n in left[:30]), colour=0x2ECC71, ), view=None, ) await self._log_event( title="🗑️ Purge Executed", description=f"**{ctx.author}** purged {len(left)} non-whitelisted server(s).", colour=0xE74C3C, fields=[("Servers Left", "\n".join(f"• {n}" for n in left[:20]) or "None", False)], ) 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( title="🔒 Bot Locked", description=f"Bot locked by **{ctx.author}** — all new joins will be rejected.", colour=0x95A5A6, ) @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( title="🔓 Bot Unlocked", description=f"Bot unlocked by **{ctx.author}** — whitelist rules restored.", colour=0x2ECC71, ) # ── 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. Includes server counts, member totals, attempt stats, and more. 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() max_attempts: int = await self.config.max_attempts() join_attempts: dict = await self.config.join_attempts() 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] # Attempt stats total_attempt_servers = len(join_attempts) total_attempt_count = sum(e.get("count", 0) for e in join_attempts.values()) auto_banned_count = sum(1 for e in join_attempts.values() if e.get("count", 0) >= max_attempts) 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=True, ) em.add_field(name="Max Attempts", value=str(max_attempts), inline=True) em.add_field( name="Attempt Tracking", value=( f"**{total_attempt_servers}** server(s) tracked\n" f"**{total_attempt_count}** total attempts\n" f"**{auto_banned_count}** auto-banned" ), 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() max_attempts: int = await self.config.max_attempts() leave_message: str | None = await self.config.leave_message() join_attempts: dict = await self.config.join_attempts() log_ch = self.bot.get_channel(log_channel) if log_channel else None msg_preview = (leave_message or DEFAULT_LEAVE_MESSAGE)[:120] + "…" if len(leave_message or DEFAULT_LEAVE_MESSAGE) > 120 else (leave_message or DEFAULT_LEAVE_MESSAGE) 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=True, ) em.add_field(name="Max Attempts", value=f"**{max_attempts}** (before auto-ban)", inline=True) em.add_field(name="Tracked Servers", value=str(len(join_attempts)), inline=True) em.add_field( name="Leave DM Message", value=f"```{msg_preview}```", inline=False, ) em.add_field( name="Quick Reference", value=( f"`{ctx.clean_prefix}join ` — whitelist a server\n" f"`{ctx.clean_prefix}join remove ` — un-whitelist & leave\n" f"`{ctx.clean_prefix}join blacklist ` — 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\n" f"`{ctx.clean_prefix}join attempts` — view join attempts\n" f"`{ctx.clean_prefix}join maxattempts ` — set auto-ban threshold\n" f"`{ctx.clean_prefix}join setmessage ` — custom leave DM\n" f"`{ctx.clean_prefix}join resetmessage` — reset DM to default" ), 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, join date, and owner. Usage: ``[p]join export`` """ whitelist: list[int] = await self.config.whitelist() blacklist: list[int] = await self.config.blacklist() join_attempts: dict = await self.config.join_attempts() lines: list[str] = [ f"{'ID':<22} {'Members':>8} {'Status':<14} {'Attempts':>8} {'Joined':<26} {'Owner':<30} Name", "─" * 140, ] 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" ) owner = str(g.owner) if g.owner else "Unknown" att = join_attempts.get(str(g.id), {}).get("count", 0) lines.append( f"{g.id:<22} {g.member_count or 0:>8,} {status:<14} {att:>8} {joined:<26} {owner:<30} {g.name}" ) # Also export non-joined servers from attempts tracked_ids = set(int(k) for k in join_attempts.keys()) current_ids = set(g.id for g in self.bot.guilds) non_joined = tracked_ids - current_ids if non_joined: lines.append("") lines.append("── Tracked Servers (Not Currently Joined) ──") lines.append(f"{'ID':<22} {'Attempts':>8} {'Last Attempt':<26} {'Owner ID':<22} Name") lines.append("─" * 100) for gid in sorted(non_joined): entry = join_attempts[str(gid)] lines.append( f"{gid:<22} {entry.get('count', 0):>8} " f"{entry.get('last_attempt', '?'):<26} " f"{entry.get('owner_id', '?')!s:<22} " f"{entry.get('name', 'Unknown')}" ) 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) # ═══════════════════════════════════════════════════════════ # Attempt Tracking Commands # ═══════════════════════════════════════════════════════════ @join_group.group(name="attempts", aliases=["att"], invoke_without_command=True) @commands.is_owner() async def join_attempts_group(self, ctx: commands.Context) -> None: """View all servers with recorded join attempts. Shows attempt count, last attempt time, and auto-ban status. Usage: ``[p]join attempts`` """ join_attempts: dict = await self.config.join_attempts() max_attempts: int = await self.config.max_attempts() blacklist: list[int] = await self.config.blacklist() if not join_attempts: await ctx.send("📊 No join attempts have been recorded yet.") return # Sort by count descending sorted_entries = sorted( join_attempts.items(), key=lambda kv: kv[1].get("count", 0), reverse=True, ) pages: list[discord.Embed] = [] for i in range(0, len(sorted_entries), PER_PAGE): chunk = sorted_entries[i : i + PER_PAGE] em = discord.Embed( title="📊 Join Attempt Tracker", colour=EMBED_COLOUR, ) for gid_str, entry in chunk: gid = int(gid_str) count = entry.get("count", 0) name = entry.get("name", "Unknown") owner_id = entry.get("owner_id", "?") first = entry.get("first_attempt", "?") last = entry.get("last_attempt", "?") is_banned = gid in blacklist status = "🚫 BANNED" if is_banned else ( f"⚠️ {count}/{max_attempts}" if count >= max_attempts - 2 else f"📋 {count}/{max_attempts}" ) em.add_field( name=f"{name} (`{gid}`)", value=( f"**Status:** {status}\n" f"**Attempts:** {count}\n" f"**Owner ID:** `{owner_id}`\n" f"**First:** `{first[:19] if isinstance(first, str) else first}`\n" f"**Last:** `{last[:19] if isinstance(last, str) else last}`" ), inline=False, ) em.set_footer(text=f"Total: {len(sorted_entries)} tracked server(s) • Max: {max_attempts} attempts") pages.append(em) view = PaginatedView(pages, author_id=ctx.author.id) await ctx.send(embed=pages[0], view=view) @join_attempts_group.command(name="reset") @commands.is_owner() async def join_attempts_reset(self, ctx: commands.Context, server_id: int) -> None: """Reset the join attempt counter for a specific server. This does NOT remove it from the blacklist — use ``unblacklist`` for that. Usage: ``[p]join attempts reset `` """ async with self.config.join_attempts() as attempts: if str(server_id) not in attempts: await ctx.send(f"⚠️ No attempts recorded for `{server_id}`.") return old = attempts.pop(str(server_id)) await ctx.send( f"✅ Reset attempts for `{server_id}` (was **{old.get('count', 0)}** attempts)." ) await self._log_event( title="🔄 Attempts Reset", description=f"Attempts for `{server_id}` (`{old.get('name', '?')}`) reset by **{ctx.author}** (was {old.get('count', 0)}).", colour=0x3498DB, ) @join_attempts_group.command(name="resetall") @commands.is_owner() async def join_attempts_resetall(self, ctx: commands.Context) -> None: """Reset ALL join attempt counters. Does NOT affect whitelist or blacklist. Usage: ``[p]join attempts resetall`` """ join_attempts: dict = await self.config.join_attempts() count = len(join_attempts) await self.config.join_attempts.set({}) await ctx.send(f"✅ Cleared attempt data for **{count}** server(s).") await self._log_event( title="🔄 All Attempts Reset", description=f"All attempt counters ({count} servers) cleared by **{ctx.author}**.", colour=0x3498DB, ) # ═══════════════════════════════════════════════════════════ # Max Attempts Configuration # ═══════════════════════════════════════════════════════════ @join_group.command(name="maxattempts", aliases=["setmax", "limit"]) @commands.is_owner() async def join_maxattempts(self, ctx: commands.Context, count: int) -> None: """Set the maximum number of join attempts before a server is auto-banned. Must be at least 1. Usage: ``[p]join maxattempts 5`` """ if count < 1: await ctx.send("⚠️ Max attempts must be at least **1**.") return old = await self.config.max_attempts() await self.config.max_attempts.set(count) await ctx.send(f"✅ Max attempts set to **{count}** (was {old}).") await self._log_event( title="⚙️ Max Attempts Updated", description=f"Max attempts changed from **{old}** → **{count}** by **{ctx.author}**.", colour=0x3498DB, ) # ═══════════════════════════════════════════════════════════ # Custom Leave DM Message # ═══════════════════════════════════════════════════════════ @join_group.command(name="setmessage", aliases=["setmsg", "dmtext"]) @commands.is_owner() async def join_setmessage(self, ctx: commands.Context, *, text: str) -> None: """Set a custom DM message sent to server owners when the bot leaves. The message is sent in a rich embed along with the server name, reason, and warnings. Usage: ``[p]join setmessage Hello! Your server isn't whitelisted...`` """ if len(text) > 1500: await ctx.send("⚠️ Message is too long (max 1,500 characters).") return await self.config.leave_message.set(text) await ctx.send( f"✅ Leave DM message updated!\n\n**Preview:**\n>>> {text[:500]}" ) @join_group.command(name="resetmessage", aliases=["resetmsg", "defaultmsg"]) @commands.is_owner() async def join_resetmessage(self, ctx: commands.Context) -> None: """Reset the leave DM message to the default. Usage: ``[p]join resetmessage`` """ await self.config.leave_message.set(None) await ctx.send( f"✅ Leave DM message reset to default:\n\n>>> {DEFAULT_LEAVE_MESSAGE}" )