diff --git a/info.json b/info.json index f2f3baf..1ebecea 100644 --- a/info.json +++ b/info.json @@ -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" } diff --git a/serverwhitelist/__init__.py b/serverwhitelist/__init__.py new file mode 100644 index 0000000..9ab48cd --- /dev/null +++ b/serverwhitelist/__init__.py @@ -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) diff --git a/serverwhitelist/info.json b/serverwhitelist/info.json new file mode 100644 index 0000000..6bbb05f --- /dev/null +++ b/serverwhitelist/info.json @@ -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 ` — Add a server to the whitelist so the bot can stay when invited.\n• `[p]join remove ` — 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 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": [] +} diff --git a/serverwhitelist/serverwhitelist.py b/serverwhitelist/serverwhitelist.py new file mode 100644 index 0000000..4c87719 --- /dev/null +++ b/serverwhitelist/serverwhitelist.py @@ -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 `` — 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 + +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 `` + """ + 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 `` + """ + 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 `` + """ + 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 `` + """ + 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 `` + """ + 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 `` + """ + 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 + 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 ` — 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" + ), + 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)