diff --git a/serverwhitelist/__init__.py b/serverwhitelist/__init__.py index 7d22cf0..9185205 100644 --- a/serverwhitelist/__init__.py +++ b/serverwhitelist/__init__.py @@ -1,10 +1,11 @@ -"""ServerWhitelist v3.0 — Ultimate owner-only server management for Red-DiscordBot.""" +"""ServerWhitelist v4.0 — The ultimate owner-only server management cog for Red-DiscordBot.""" from .serverwhitelist import ServerWhitelist __red_end_user_data_statement__ = ( - "This cog stores guild IDs in a global whitelist, blacklist, and " - "join-attempt tracker. No per-user data is collected or stored." + "This cog stores guild IDs in a global whitelist, blacklist, join-attempt " + "tracker, notes, tags, and whitelist request queue. No per-user data is " + "collected or stored beyond requester IDs in the request queue." ) diff --git a/serverwhitelist/info.json b/serverwhitelist/info.json index ea1ae73..90b4cc4 100644 --- a/serverwhitelist/info.json +++ b/serverwhitelist/info.json @@ -2,11 +2,11 @@ "author": [ "everestmcarthur" ], - "install_msg": "Thanks for installing **ServerWhitelist v3.0**!\n\nAll servers the bot is currently in have been automatically whitelisted.\n\n**Core Commands (owner only):**\n• `[p]join ` — Whitelist a server\n• `[p]join remove ` — Un-whitelist & leave\n• `[p]join blacklist ` — Blacklist & auto-leave\n• `[p]join servers` — Browse all servers with leave controls\n• `[p]join info ` — Detailed server info\n• `[p]join purge` — Leave all non-whitelisted servers\n• `[p]join lock / unlock` — Toggle lock mode\n• `[p]join log #channel` — Set event log channel\n\n**New in v3.0:**\n• 🛡️ **Owner DM** — Owners get a DM explaining why the bot left\n• ⛔ **Auto-Ban** — After 5 unauthorized join attempts, the server is permanently blacklisted\n• 📊 **Attempt Tracking** — `[p]join attempts` to view/manage\n• ⚙️ **Configurable Limits** — `[p]join maxattempts `\n• 💬 **Custom Leave Message** — `[p]join setmessage `\n• 📝 **Rich Logging** — Detailed embeds with server info, icons, and colour-coded severity", + "install_msg": "Thanks for installing **ServerWhitelist v4.0** — The Ultimate Server Management Cog!\n\nAll servers the bot is currently in have been automatically whitelisted.\n\n**Core Commands (owner only):**\n• `[p]join ` — Whitelist a server\n• `[p]join remove/blacklist/unblacklist `\n• `[p]join servers` — Browse with leave controls\n• `[p]join info/search/stats`\n• `[p]join purge/leave/lock/unlock`\n• `[p]join log #channel` — Event logging\n\n**New in v4.0:**\n• 📩 **Whitelist Requests** — Server owners can request via DM button; you approve/deny\n• 📝 **Notes & Tags** — Annotate and categorize servers\n• 🔍 **Invite Audit** — See who added the bot via audit log\n• ⏰ **Temp Whitelist** — `[p]join temp 7d` for time-limited access\n• 📏 **Member Requirements** — Min/max member thresholds\n• 🔑 **Trusted Inviters** — Auto-whitelist from specific users\n• 🔔 **Owner Alerts** — DM notifications for all events\n• 💾 **Backup & Restore** — Full config export/import\n• 🛡️ Owner DM, auto-ban, attempt tracking, rich logging (from v3)", "name": "ServerWhitelist", "disabled": false, - "short": "Restrict which servers the bot is allowed to stay in via an owner-only whitelist, with auto-ban, owner DMs, and rich logging.", - "description": "ServerWhitelist v3.0 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. When the bot joins a non-whitelisted server, it DMs the server owner explaining why it's leaving and tracks the attempt. After a configurable number of attempts (default 5), the server is automatically and permanently blacklisted. Features include paginated server browsing with leave controls, detailed server info embeds, search, bulk purge, lock mode, blacklist management, rich event logging with colour-coded severity, attempt tracking with reset commands, customisable leave DM messages, and full export.", + "short": "The ultimate cog to control which servers the bot can be in — whitelist, blacklist, requests, temp access, trusted inviters, notes, tags, alerts, backup & more.", + "description": "ServerWhitelist v4.0 is the definitive server management cog for Red-DiscordBot. It gives bot owners complete control over which Discord servers the bot operates in. Features include: whitelist/blacklist with paginated views, interactive server browser with leave dropdowns, detailed server info embeds, search, bulk purge, lock mode, rich colour-coded event logging, attempt tracking with auto-ban after configurable threshold, owner DM with customizable message, whitelist request system with approve/deny buttons, server notes and tags for organization, invite audit via Discord audit logs, temporary time-limited whitelist, min/max member requirements, trusted inviters for auto-whitelist, owner DM alerts for all events, full config backup and restore, and comprehensive export. Every action is logged and auditable.", "tags": [ "whitelist", "blacklist", @@ -17,10 +17,15 @@ "management", "auto-ban", "logging", - "protection" + "protection", + "requests", + "notes", + "tags", + "backup", + "trusted" ], "type": "COG", - "end_user_data_statement": "This cog stores guild IDs in a global whitelist, blacklist, and join-attempt tracker. No per-user data is collected or stored.", + "end_user_data_statement": "This cog stores guild IDs in a global whitelist, blacklist, join-attempt tracker, notes, tags, and whitelist request queue. No per-user data is collected or stored beyond requester IDs in the request queue.", "min_bot_version": "3.5.0", "min_python_version": [ 3, diff --git a/serverwhitelist/serverwhitelist.py b/serverwhitelist/serverwhitelist.py index 6b161e5..83868fe 100644 --- a/serverwhitelist/serverwhitelist.py +++ b/serverwhitelist/serverwhitelist.py @@ -1,61 +1,96 @@ """ -ServerWhitelist v3.0 — Ultimate Server Management Cog for Red-DiscordBot -========================================================================= +ServerWhitelist v4.0 — The 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.). +operate in. Uses Red's Config for persistence and discord.py Views for +interactive UI (paginated embeds, dropdowns, approval 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 `` — whitelist a server + • ``[p]join remove `` — un-whitelist (optionally leave) + • ``[p]join blacklist `` — blacklist (auto-leave & never rejoin) • ``[p]join unblacklist `` — remove from blacklist - • ``[p]join whitelist`` — paginated whitelist with ❌ remove buttons - • ``[p]join blacklisted`` — paginated blacklist + • ``[p]join whitelist`` — paginated whitelist + • ``[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 + • ``[p]join servers`` — paginated with 🚪 leave dropdown + • ``[p]join info `` — detailed embed + • ``[p]join search `` — search 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) + • ``[p]join leave `` — leave immediately + • ``[p]join purge`` — leave all non-whitelisted (confirm) Bot Lock - • ``[p]join lock`` — reject ALL new joins (even whitelisted) - • ``[p]join unlock`` — resume normal whitelist behaviour + • ``[p]join lock / unlock`` — reject ALL new joins Logging - • ``[p]join log `` — log join/leave/block events to a channel - • ``[p]join log off`` — disable logging + • ``[p]join log `` — rich event logging + • ``[p]join log off`` — disable 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) + • ``[p]join attempts`` — view tracked servers + • ``[p]join attempts reset`` — reset one / all + • ``[p]join maxattempts `` — set threshold (default 5) Owner DM - • Bot DMs the server owner before leaving non-whitelisted/locked servers - • Customisable with ``[p]join setmessage `` / ``[p]join resetmessage`` + • DMs server owner before leaving + • ``[p]join setmessage / resetmessage`` -Settings - • ``[p]join settings`` — display current config at a glance +Whitelist Request System (NEW v4) + • Server owners can request whitelist via DM button + • Bot owner gets approve/deny embed with full server info + • ``[p]join requests`` — view pending requests + • ``[p]join requests approve/deny `` — manual approve/deny -Export - • ``[p]join export`` — upload a .txt or .csv file of all guilds +Server Notes & Tags (NEW v4) + • ``[p]join note `` — attach notes + • ``[p]join note `` — view note + • ``[p]join tag `` — categorize (partner, testing, etc.) + • ``[p]join untag `` — remove tag + • ``[p]join tags`` — view all tags + +Invite Audit (NEW v4) + • Checks audit log on join to find who invited the bot + +Temporary Whitelist (NEW v4) + • ``[p]join temp `` — whitelist for X time + • Auto-removes & leaves when expired + +Server Requirements (NEW v4) + • ``[p]join minmembers `` — reject small servers + • ``[p]join maxmembers `` — reject huge servers + +Trusted Inviters (NEW v4) + • ``[p]join trust `` — auto-whitelist on invite + • ``[p]join untrust `` + • ``[p]join trusted`` — view list + +Owner Alerts (NEW v4) + • ``[p]join alerts on/off`` — DM bot owner on events + +Backup & Restore (NEW v4) + • ``[p]join backup`` — export full config JSON + • ``[p]join restore`` — import config from attached file + +Settings & Export + • ``[p]join settings`` — full config overview + • ``[p]join export`` — .txt server dump """ from __future__ import annotations +import asyncio import io +import json import logging -from datetime import datetime, timezone +import re +from datetime import datetime, timedelta, timezone from typing import List, Optional, Sequence import discord @@ -67,6 +102,7 @@ log = logging.getLogger("red.serverwhitelist") EMBED_COLOUR = 0x2F3136 PER_PAGE = 8 # guilds per paginated page +VERSION = "4.0" DEFAULT_LEAVE_MESSAGE = ( "Hello! I'm sorry, your server is not in my whitelist. " @@ -76,6 +112,26 @@ DEFAULT_LEAVE_MESSAGE = ( "Have a great day!" ) +DURATION_RE = re.compile( + r"(?:(\d+)\s*d(?:ays?)?)?\s*" + r"(?:(\d+)\s*h(?:ours?)?)?\s*" + r"(?:(\d+)\s*m(?:in(?:utes?)?)?)?\s*$", + re.IGNORECASE, +) + + +def parse_duration(text: str) -> Optional[timedelta]: + """Parse a human-friendly duration like '7d', '12h', '2d 6h 30m'.""" + m = DURATION_RE.match(text.strip()) + if m is None: + return None + days = int(m.group(1) or 0) + hours = int(m.group(2) or 0) + minutes = int(m.group(3) or 0) + if days == 0 and hours == 0 and minutes == 0: + return None + return timedelta(days=days, hours=hours, minutes=minutes) + # ═══════════════════════════════════════════════════════════════════ # Paginated View (reusable) @@ -97,8 +153,6 @@ class PaginatedView(discord.ui.View): 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 @@ -120,8 +174,6 @@ class PaginatedView(discord.ui.View): 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 @@ -148,12 +200,10 @@ class PaginatedView(discord.ui.View): # ═══════════════════════════════════════════════════════════════════ -# Leave-button View (attached to server list pages) +# Server Leave Select View # ═══════════════════════════════════════════════════════════════════ 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 = [ @@ -164,20 +214,13 @@ class ServerLeaveSelect(discord.ui.Select): ) for g in guilds[:25] ] - super().__init__( - placeholder="🚪 Select a server to leave…", - options=options, - min_values=1, - max_values=1, - ) + 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 - ) + await interaction.response.send_message("Bot is no longer in that server.", ephemeral=True) return name = guild.name await guild.leave() @@ -186,23 +229,11 @@ class ServerLeaveSelect(discord.ui.Select): description=f"Left **{name}** (`{guild_id}`) via server browser.", colour=0xE67E22, ) - await interaction.response.send_message( - f"👋 Left **{name}** (`{guild_id}`).", ephemeral=True - ) + 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: + def __init__(self, pages, guild_pages, *, cog, author_id, timeout=180.0): super().__init__(timeout=timeout) self.pages = pages self.guild_pages = guild_pages @@ -212,148 +243,376 @@ class ServerPageView(discord.ui.View): self._rebuild_select() self._update_buttons() - # ── helpers ─────────────────────────────────────────────── - - def _rebuild_select(self) -> None: - # Remove existing select if any + def _rebuild_select(self): 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) - ) + self.add_item(ServerLeaveSelect(self.guild_pages[self.current], cog=self.cog)) - def _update_buttons(self) -> None: + def _update_buttons(self): 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: + async def interaction_check(self, interaction): if interaction.user.id != self.author_id: - await interaction.response.send_message( - "Only the bot owner can use these controls.", ephemeral=True - ) + 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: + async def _show(self, interaction): self._update_buttons() self._rebuild_select() - await interaction.response.edit_message( - embed=self.pages[self.current], view=self - ) - - # ── buttons ────────────────────────────────────────────── + await interaction.response.edit_message(embed=self.pages[self.current], view=self) @discord.ui.button(emoji="⏮️", style=discord.ButtonStyle.secondary) - async def first_btn(self, interaction: Interaction, button: discord.ui.Button): + async def first_btn(self, interaction, 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): + async def prev_btn(self, interaction, 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): + async def page_indicator(self, interaction, button): await interaction.response.defer() @discord.ui.button(emoji="▶️", style=discord.ButtonStyle.secondary) - async def next_btn(self, interaction: Interaction, button: discord.ui.Button): + async def next_btn(self, interaction, 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): + async def last_btn(self, interaction, button): self.current = len(self.pages) - 1 await self._show(interaction) # ═══════════════════════════════════════════════════════════════════ -# Confirm Purge View +# Confirm View (reusable) # ═══════════════════════════════════════════════════════════════════ -class ConfirmPurgeView(discord.ui.View): - """Two-button confirmation for the purge command.""" - - def __init__(self, *, author_id: int, timeout: float = 30.0) -> None: +class ConfirmView(discord.ui.View): + def __init__(self, *, author_id: int, timeout: float = 30.0): super().__init__(timeout=timeout) self.author_id = author_id self.result: Optional[bool] = None - async def interaction_check(self, interaction: Interaction) -> bool: + async def interaction_check(self, interaction): 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): + @discord.ui.button(label="Confirm", style=discord.ButtonStyle.danger, emoji="✅") + async def confirm(self, interaction, 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): + async def cancel(self, interaction, button): self.result = False self.stop() await interaction.response.defer() +# ═══════════════════════════════════════════════════════════════════ +# Whitelist Request View (sent in owner DM) +# ═══════════════════════════════════════════════════════════════════ + +class WhitelistRequestView(discord.ui.View): + """Button sent to the server owner's DM so they can request whitelisting.""" + + def __init__(self, *, cog: "ServerWhitelist", guild_id: int, guild_name: str): + # Persistent view — no timeout + super().__init__(timeout=None) + self.cog = cog + self.guild_id = guild_id + self.guild_name = guild_name + + @discord.ui.button( + label="📩 Request Whitelist", + style=discord.ButtonStyle.success, + custom_id="swl_request_whitelist", + ) + async def request_btn(self, interaction: Interaction, button: discord.ui.Button): + # Check if already requested + requests: dict = await self.cog.config.whitelist_requests() + gid_str = str(self.guild_id) + if gid_str in requests: + await interaction.response.send_message( + "📨 A whitelist request for this server is already pending! Please be patient.", + ephemeral=True, + ) + return + + # Record the request + now_iso = datetime.now(timezone.utc).isoformat() + async with self.cog.config.whitelist_requests() as reqs: + reqs[gid_str] = { + "name": self.guild_name, + "requester_id": interaction.user.id, + "requester_name": str(interaction.user), + "requested_at": now_iso, + } + + await interaction.response.send_message( + "✅ Your whitelist request has been sent to the bot owner! " + "You'll be notified if it's approved.", + ephemeral=True, + ) + + # Notify bot owner + await self.cog._notify_owner_of_request( + guild_id=self.guild_id, + guild_name=self.guild_name, + requester=interaction.user, + ) + + # Disable the button + button.disabled = True + button.label = "📨 Request Sent" + button.style = discord.ButtonStyle.secondary + try: + await interaction.message.edit(view=self) + except discord.HTTPException: + pass + + +# ═══════════════════════════════════════════════════════════════════ +# Approval View (sent to bot owner) +# ═══════════════════════════════════════════════════════════════════ + +class ApprovalView(discord.ui.View): + """Approve / Deny buttons sent to the bot owner's DM for whitelist requests.""" + + def __init__(self, *, cog: "ServerWhitelist", guild_id: int): + super().__init__(timeout=None) + self.cog = cog + self.guild_id = guild_id + + @discord.ui.button(label="✅ Approve", style=discord.ButtonStyle.success, custom_id="swl_approve") + async def approve_btn(self, interaction: Interaction, button: discord.ui.Button): + gid_str = str(self.guild_id) + + # Add to whitelist + async with self.cog.config.whitelist() as wl: + if self.guild_id not in wl: + wl.append(self.guild_id) + + # Reset attempts + async with self.cog.config.join_attempts() as attempts: + attempts.pop(gid_str, None) + + # Remove from requests + request_data = None + async with self.cog.config.whitelist_requests() as reqs: + request_data = reqs.pop(gid_str, None) + + # Notify requester + if request_data: + try: + requester = await self.cog.bot.fetch_user(request_data["requester_id"]) + em = discord.Embed( + title="✅ Whitelist Request Approved!", + description=( + f"Your whitelist request for **{request_data['name']}** (`{self.guild_id}`) " + f"has been *approved*! You can now add the bot to your server." + ), + colour=0x2ECC71, + timestamp=datetime.now(timezone.utc), + ) + await requester.send(embed=em) + except (discord.HTTPException, discord.NotFound): + pass + + await interaction.response.send_message( + f"✅ `{self.guild_id}` has been whitelisted! The requester has been notified.", + ephemeral=True, + ) + + # Update the message to show it was approved + for item in self.children: + item.disabled = True + button.label = "✅ Approved" + try: + await interaction.message.edit(view=self) + except discord.HTTPException: + pass + + await self.cog._log_event( + title="✅ Whitelist Request Approved", + description=f"`{self.guild_id}` approved via request system.", + colour=0x2ECC71, + ) + + @discord.ui.button(label="❌ Deny", style=discord.ButtonStyle.danger, custom_id="swl_deny") + async def deny_btn(self, interaction: Interaction, button: discord.ui.Button): + gid_str = str(self.guild_id) + + # Remove from requests + request_data = None + async with self.cog.config.whitelist_requests() as reqs: + request_data = reqs.pop(gid_str, None) + + # Notify requester + if request_data: + try: + requester = await self.cog.bot.fetch_user(request_data["requester_id"]) + em = discord.Embed( + title="❌ Whitelist Request Denied", + description=( + f"Your whitelist request for **{request_data['name']}** (`{self.guild_id}`) " + f"has been *denied*." + ), + colour=0xE74C3C, + timestamp=datetime.now(timezone.utc), + ) + await requester.send(embed=em) + except (discord.HTTPException, discord.NotFound): + pass + + await interaction.response.send_message( + f"❌ Request for `{self.guild_id}` denied. The requester has been notified.", + ephemeral=True, + ) + + for item in self.children: + item.disabled = True + button.label = "❌ Denied" + try: + await interaction.message.edit(view=self) + except discord.HTTPException: + pass + + await self.cog._log_event( + title="❌ Whitelist Request Denied", + description=f"`{self.guild_id}` denied via request system.", + colour=0xE74C3C, + ) + + # ═══════════════════════════════════════════════════════════════════ # Main Cog # ═══════════════════════════════════════════════════════════════════ class ServerWhitelist(commands.Cog): - """Ultimate server management — whitelist, blacklist, browse, leave, lock, attempt tracking & more.""" + """The ultimate server management cog — whitelist, blacklist, browse, leave, + lock, attempt tracking, request system, notes, tags, temp whitelist, + trusted inviters, invite audit, owner alerts, backup/restore & more.""" def __init__(self, bot: Red) -> None: self.bot = bot - self.config = Config.get_conf( - self, - identifier=827364510293, - force_registration=True, - ) + self.config = Config.get_conf(self, identifier=827364510293, force_registration=True) self.config.register_global( + # Core 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 } } + log_channel=None, + # Attempts + max_attempts=5, join_attempts={}, + # DM + leave_message=None, + # Server requirements + min_members=0, + max_members=0, # 0 = no limit + # Trusted inviters + trusted_inviters=[], + # Owner alerts + owner_alerts=False, + # Temp whitelist: { "guild_id_str": { "expires": iso, "added_by": str } } + temp_whitelist={}, + # Notes: { "guild_id_str": "note text" } + server_notes={}, + # Tags: { "guild_id_str": ["tag1", "tag2"] } + server_tags={}, + # Whitelist requests: { "guild_id_str": { "name": str, "requester_id": int, ... } } + whitelist_requests={}, ) + self._temp_task: Optional[asyncio.Task] = 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(merged), + "ServerWhitelist v%s loaded — %d guilds merged (%d total WL).", + VERSION, len(current_ids), len(merged), ) + # Start temp whitelist expiry checker + self._temp_task = self.bot.loop.create_task(self._temp_whitelist_loop()) - # ── internal helpers ────────────────────────────────────── + async def cog_unload(self) -> None: + if self._temp_task and not self._temp_task.done(): + self._temp_task.cancel() + + # ── temp whitelist background loop ──────────────────────── + + async def _temp_whitelist_loop(self) -> None: + """Check every 60 seconds for expired temp-whitelisted servers.""" + await self.bot.wait_until_ready() + while True: + try: + now = datetime.now(timezone.utc) + expired: list[tuple[int, dict]] = [] + + async with self.config.temp_whitelist() as tw: + for gid_str, data in list(tw.items()): + expires = datetime.fromisoformat(data["expires"]) + if now >= expires: + expired.append((int(gid_str), data)) + del tw[gid_str] + + for gid, data in expired: + # Remove from whitelist + async with self.config.whitelist() as wl: + if gid in wl: + wl.remove(gid) + + guild = self.bot.get_guild(gid) + name = guild.name if guild else data.get("name", str(gid)) + + await self._log_event( + title="⏰ Temp Whitelist Expired", + description=f"**{name}** (`{gid}`) temp whitelist has expired. Removed from whitelist.", + colour=0xE67E22, + ) + await self._alert_owner( + f"⏰ Temp whitelist expired for **{name}** (`{gid}`)." + ) + + if guild is not None: + await self._dm_owner( + guild, + reason="Your temporary whitelist period has expired.", + ) + await guild.leave() + + except asyncio.CancelledError: + return + except Exception as exc: + log.error("Temp whitelist loop error: %s", exc, exc_info=True) + + await asyncio.sleep(60) + + # ═══════════════════════════════════════════════════════════ + # 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, + 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 @@ -362,9 +621,7 @@ class ServerWhitelist(commands.Cog): return try: em = discord.Embed( - title=title, - description=description, - colour=colour, + title=title, description=description, colour=colour, timestamp=datetime.now(timezone.utc), ) if thumbnail_url: @@ -372,26 +629,72 @@ class ServerWhitelist(commands.Cog): 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") + em.set_footer(text=footer or f"ServerWhitelist v{VERSION}") await channel.send(embed=em) except discord.HTTPException: pass + async def _alert_owner(self, message: str) -> None: + """DM the bot owner if owner alerts are enabled.""" + if not await self.config.owner_alerts(): + return + owner_ids = self.bot.owner_ids or set() + if self.bot.owner_id: + owner_ids = owner_ids | {self.bot.owner_id} + for oid in owner_ids: + try: + user = await self.bot.fetch_user(oid) + em = discord.Embed( + title="🔔 ServerWhitelist Alert", + description=message, + colour=EMBED_COLOUR, + timestamp=datetime.now(timezone.utc), + ) + await user.send(embed=em) + except (discord.HTTPException, discord.NotFound): + pass + + async def _notify_owner_of_request( + self, *, guild_id: int, guild_name: str, requester: discord.User, + ) -> None: + """Send the bot owner a DM with approve/deny buttons for a whitelist request.""" + owner_ids = self.bot.owner_ids or set() + if self.bot.owner_id: + owner_ids = owner_ids | {self.bot.owner_id} + + em = discord.Embed( + title="📩 Whitelist Request", + description=( + f"**{requester}** (`{requester.id}`) is requesting whitelist for:\n\n" + f"**Server:** {guild_name}\n" + f"**Server ID:** `{guild_id}`" + ), + colour=0x3498DB, + timestamp=datetime.now(timezone.utc), + ) + em.set_footer(text="Use the buttons below or [p]join requests to manage.") + + view = ApprovalView(cog=self, guild_id=guild_id) + + for oid in owner_ids: + try: + user = await self.bot.fetch_user(oid) + await user.send(embed=em, view=view) + except (discord.HTTPException, discord.NotFound): + 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" + 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 "" + if guild.me and guild.me.joined_at else "" ) text_ch = len(guild.text_channels) voice_ch = len(guild.voice_channels) @@ -417,16 +720,11 @@ class ServerWhitelist(commands.Cog): ("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, - )) + 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 "?" @@ -437,42 +735,29 @@ class ServerWhitelist(commands.Cog): 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]] = [] + def _paginate_guilds(self, guilds, *, title, colour=EMBED_COLOUR): + pages, guild_pages = [], [] for i in range(0, max(1, len(guilds)), PER_PAGE): - chunk = list(guilds[i : i + 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)" - ) + 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] = [] + def _paginate_ids(self, ids, *, title, colour=EMBED_COLOUR): + pages = [] for i in range(0, max(1, len(ids)), PER_PAGE): - chunk = ids[i : i + 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] = [] + lines = [] for gid in chunk: guild = self.bot.get_guild(gid) name = guild.name if guild else "Unknown / Not Joined" @@ -487,19 +772,11 @@ class ServerWhitelist(commands.Cog): # ── DM the server owner ─────────────────────────────────── async def _dm_owner( - self, - guild: discord.Guild, - *, - reason: str, - extra: str | None = None, + self, guild: discord.Guild, *, reason: str, extra: str | None = None, + include_request_button: bool = False, ) -> 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() @@ -515,36 +792,50 @@ class ServerWhitelist(commands.Cog): 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") + em.set_footer(text=f"ServerWhitelist v{VERSION}") if guild.icon: em.set_thumbnail(url=guild.icon.url) + view = None + if include_request_button: + blacklist = await self.config.blacklist() + if guild.id not in blacklist: + view = WhitelistRequestView(cog=self, guild_id=guild.id, guild_name=guild.name) + try: - await owner.send(embed=em) - log.info("Sent leave DM to owner %s (%d) of guild %s.", owner, owner.id, guild.name) + await owner.send(embed=em, view=view) return True - except (discord.Forbidden, discord.HTTPException) as exc: - log.warning("Could not DM owner %s (%d): %s", owner, owner.id, exc) + except (discord.Forbidden, discord.HTTPException): return False + # ── invite audit ────────────────────────────────────────── + + async def _audit_inviter(self, guild: discord.Guild) -> Optional[str]: + """Try to find who invited the bot via the guild's audit log.""" + try: + me = guild.me + if me is None: + return None + async for entry in guild.audit_logs( + action=discord.AuditLogAction.bot_add, limit=10 + ): + if entry.target and entry.target.id == me.id: + return f"{entry.user} (`{entry.user.id}`)" if entry.user else None + except (discord.Forbidden, discord.HTTPException): + pass + return None + # ── 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, + "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 @@ -557,7 +848,6 @@ class ServerWhitelist(commands.Cog): 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) @@ -566,77 +856,118 @@ class ServerWhitelist(commands.Cog): 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, - ) + log.warning("AUTO-BANNED guild %s (%d) after %d attempts.", guild.name, guild.id, count) return count, auto_banned - # ── listeners ───────────────────────────────────────────── + # ═══════════════════════════════════════════════════════════ + # 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() + min_members: int = await self.config.min_members() + max_members: int = await self.config.max_members() + trusted: list[int] = await self.config.trusted_inviters() - # 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" + + # Audit who invited + inviter_str = await self._audit_inviter(guild) + 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 inviter_str: + log_fields.append(("Invited By", inviter_str, False)) if guild.features: - log_fields.append(( - "Features", - ", ".join(f"`{f}`" for f in sorted(guild.features)[:10]), - False, - )) + log_fields.append(("Features", ", ".join(f"`{f}`" for f in sorted(guild.features)[:10]), False)) + + # ── Check if a trusted inviter added the bot ───────── + if inviter_str and trusted: + # Extract user ID from inviter_str + try: + inviter_id_match = re.search(r"\(`(\d+)`\)", inviter_str) + if inviter_id_match: + inviter_id = int(inviter_id_match.group(1)) + if inviter_id in trusted and guild.id not in whitelist: + # Auto-whitelist + async with self.config.whitelist() as wl: + wl.append(guild.id) + whitelist = await self.config.whitelist() # refresh + await self._log_event( + title="🔑 Auto-Whitelisted (Trusted Inviter)", + description=( + f"**{guild.name}** auto-whitelisted because " + f"trusted inviter {inviter_str} added the bot." + ), + colour=0x2ECC71, fields=log_fields, thumbnail_url=icon_url, + ) + await self._alert_owner( + f"🔑 **{guild.name}** (`{guild.id}`) was auto-whitelisted " + f"by trusted inviter {inviter_str}." + ) + except (ValueError, AttributeError): + pass # ── 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 self._dm_owner(guild, reason="This server is *permanently blacklisted*.", + extra="⛔ This server has been *banned*. Continued attempts will be ignored.") + await self._log_event(title="🚫 Blocked — Blacklisted", colour=0xE74C3C, + description=f"Rejected **blacklisted** server **{guild.name}**.", + fields=log_fields + [("Attempts", str(count), True)], thumbnail_url=icon_url) + await self._alert_owner(f"🚫 Blacklisted server **{guild.name}** (`{guild.id}`) tried to add the bot (attempt {count}).") await guild.leave() return - # ── Locked mode ────────────────────────────────────── + # ── Locked ─────────────────────────────────────────── 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*.") + await self._log_event(title="🔒 Blocked — Locked", colour=0x95A5A6, + description=f"Rejected **{guild.name}** — bot is locked.", + fields=log_fields, thumbnail_url=icon_url) + await self._alert_owner(f"🔒 **{guild.name}** (`{guild.id}`) tried to join while bot is locked.") + await guild.leave() + return + # ── Member requirements ────────────────────────────── + member_count = guild.member_count or 0 + if min_members > 0 and member_count < min_members and guild.id not in whitelist: await self._dm_owner( - guild, - reason="The bot is currently in *lock mode* and not accepting any new servers.", + guild, reason=f"This server has {member_count:,} members, below the minimum of {min_members:,}.", + include_request_button=True, ) 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, + title="📏 Blocked — Below Min Members", + description=f"**{guild.name}** has {member_count:,} members (min: {min_members:,}).", + colour=0xE67E22, fields=log_fields, thumbnail_url=icon_url, ) + await self._alert_owner(f"📏 **{guild.name}** (`{guild.id}`) rejected — {member_count:,} members (min {min_members:,}).") + await guild.leave() + return + + if max_members > 0 and member_count > max_members and guild.id not in whitelist: + await self._dm_owner( + guild, reason=f"This server has {member_count:,} members, above the maximum of {max_members:,}.", + include_request_button=True, + ) + await self._log_event( + title="📏 Blocked — Above Max Members", + description=f"**{guild.name}** has {member_count:,} members (max: {max_members:,}).", + colour=0xE67E22, fields=log_fields, thumbnail_url=icon_url, + ) + await self._alert_owner(f"📏 **{guild.name}** (`{guild.id}`) rejected — {member_count:,} members (max {max_members:,}).") await guild.leave() return @@ -645,12 +976,6 @@ class ServerWhitelist(commands.Cog): 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 " @@ -659,71 +984,65 @@ class ServerWhitelist(commands.Cog): f"this is a mistake." ) reason = f"Not whitelisted — *auto-banned* after {count}/{max_attempts} attempts." + include_req = False 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})." + include_req = True 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})." + include_req = True - await self._dm_owner(guild, reason=reason, extra=extra) + await self._dm_owner(guild, reason=reason, extra=extra, include_request_button=include_req) - # 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." - ) + log_colour, log_title = 0xE74C3C, "🚨 Auto-Banned — Max Attempts" + log_desc = f"**{guild.name}** auto-blacklisted after **{count}** 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." - ) + log_colour = 0xE67E22 + log_title = f"⚠️ Non-Whitelisted (Attempt {count}/{max_attempts})" + log_desc = f"Rejected **{guild.name}** — **{remaining}** attempt(s) remain." else: - log_colour = 0xF1C40F # Yellow - log_title = f"⛔ Non-Whitelisted Join (Attempt {count}/{max_attempts})" + log_colour = 0xF1C40F + log_title = f"⛔ Non-Whitelisted (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, + 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, + ], thumbnail_url=icon_url, + ) + await self._alert_owner( + f"{'🚨 AUTO-BANNED' if auto_banned else '⛔ Rejected'}: **{guild.name}** " + f"(`{guild.id}`) — attempt {count}/{max_attempts}." ) await guild.leave() return - # ── Allowed — whitelisted ──────────────────────────── + # ── Allowed ────────────────────────────────────────── 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, + description=f"Joined **{guild.name}** (`{guild.id}`).", + colour=0x2ECC71, fields=log_fields, thumbnail_url=icon_url, ) + await self._alert_owner(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.""" 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", + title="📤 Left / Removed", description=f"No longer in **{guild.name}** (`{guild.id}`).", colour=0x95A5A6, fields=[ @@ -733,187 +1052,158 @@ class ServerWhitelist(commands.Cog): ], thumbnail_url=icon_url, ) + await self._alert_owner(f"📤 Left/removed from **{guild.name}** (`{guild.id}`).") # ═══════════════════════════════════════════════════════════ # Command Group # ═══════════════════════════════════════════════════════════ - @commands.hybrid_group( - name="join", - invoke_without_command=True, - fallback="add", - ) + @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() + """Add a server to the whitelist.""" + blacklist = 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}`." - ) + await ctx.send(f"⚠️ `{server_id}` is blacklisted. Use `{ctx.clean_prefix}join unblacklist {server_id}` first.") 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) + async with self.config.join_attempts() as att: + att.pop(str(server_id), None) + # Remove from requests if present + async with self.config.whitelist_requests() as reqs: + reqs.pop(str(server_id), None) + await ctx.send(f"✅ `{server_id}` whitelisted. Attempts cleared.") + await self._log_event(title="✅ Whitelisted", description=f"`{server_id}` whitelisted by **{ctx.author}**.", colour=0x2ECC71) # ── 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 `` - """ + """Un-whitelist a server (and leave if currently in it).""" async with self.config.whitelist() as wl: if server_id not in wl: - await ctx.send(f"⚠️ `{server_id}` is not on the whitelist.") + await ctx.send(f"⚠️ `{server_id}` is not whitelisted.") 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, - ) - + # Also remove temp entry + async with self.config.temp_whitelist() as tw: + tw.pop(str(server_id), None) + await ctx.send(f"🗑️ `{server_id}` removed from whitelist.") + await self._log_event(title="🗑️ 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: + if guild: await guild.leave() await ctx.send(f"👋 Left **{guild.name}**.") - # ── whitelist (list) ────────────────────────────────────── + # ── whitelist (view) ────────────────────────────────────── @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. + """View all whitelisted servers.""" + wl = await self.config.whitelist() + notes = await self.config.server_notes() + tags = await self.config.server_tags() + temp = await self.config.temp_whitelist() + + pages = [] + ids = sorted(wl) + for i in range(0, max(1, len(ids)), PER_PAGE): + chunk = ids[i:i + PER_PAGE] + em = discord.Embed(title="📋 Whitelisted Servers", colour=EMBED_COLOUR) + if not chunk: + em.description = "List is empty." + else: + lines = [] + 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 "" + line = f"{status} `{gid}` — **{name}**{members}" + # Add tags + gtags = tags.get(str(gid), []) + if gtags: + line += " " + " ".join(f"`{t}`" for t in gtags) + # Add temp indicator + if str(gid) in temp: + exp = temp[str(gid)].get("expires", "?") + line += f" ⏰ expires `{exp[:16]}`" + # Add note preview + note = notes.get(str(gid), "") + if note: + line += f"\n 📝 _{note[:60]}{'…' if len(note) > 60 else ''}_" + lines.append(line) + em.description = "\n".join(lines) + em.set_footer(text=f"Total: {len(ids)} server(s)") + pages.append(em) - 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 ───────────────────────────────────────────── + # ── blacklist commands ──────────────────────────────────── @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 `` - """ + """Blacklist a server — leave and never rejoin.""" 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, - ) - + async with self.config.temp_whitelist() as tw: + tw.pop(str(server_id), None) + await ctx.send(f"🚫 `{server_id}` blacklisted.") + await self._log_event(title="🚫 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 + if guild: await guild.leave() - await ctx.send(f"👋 Left **{name}**.") + await ctx.send(f"👋 Left **{guild.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 `` - """ + """Remove a server from the blacklist. Resets attempts.""" async with self.config.blacklist() as bl: if server_id not in bl: - await ctx.send(f"⚠️ `{server_id}` is not on the blacklist.") + await ctx.send(f"⚠️ `{server_id}` is not blacklisted.") 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, - ) + async with self.config.join_attempts() as att: + att.pop(str(server_id), None) + await ctx.send(f"✅ `{server_id}` un-blacklisted. Attempts reset.") + await self._log_event(title="✅ Un-Blacklisted", description=f"`{server_id}` un-blacklisted by **{ctx.author}**.", 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() + """View all blacklisted servers.""" + bl = 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) ───────────────────────── + # ── servers 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`` - """ + """Browse all servers with leave controls.""" 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.") + await ctx.send("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 - ) + view = ServerPageView(pages, guild_pages, cog=self, author_id=ctx.author.id) await ctx.send(embed=pages[0], view=view) # ── info ────────────────────────────────────────────────── @@ -921,23 +1211,20 @@ class ServerWhitelist(commands.Cog): @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 `` - """ + """Detailed info about a server (with notes, tags, attempts).""" 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}`.") + await ctx.send(f"⚠️ Not in `{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() + whitelist = await self.config.whitelist() + blacklist = await self.config.blacklist() + join_attempts = await self.config.join_attempts() + notes = await self.config.server_notes() + tags = await self.config.server_tags() + temp = await self.config.temp_whitelist() - status_parts: list[str] = [] + status_parts = [] if guild.id in whitelist: status_parts.append("✅ Whitelisted") if guild.id in blacklist: @@ -951,23 +1238,40 @@ class ServerWhitelist(commands.Cog): if guild.banner: em.set_image(url=guild.banner.url) - # Detailed fields - fields = self._guild_detail_fields(guild) - for name, value, inline in fields: + for name, value, inline in self._guild_detail_fields(guild): 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: + # Tags + gtags = tags.get(str(server_id), []) + if gtags: + em.add_field(name="🏷️ Tags", value=" ".join(f"`{t}`" for t in gtags), inline=False) + + # Note + note = notes.get(str(server_id), "") + if note: + em.add_field(name="📝 Note", value=note, inline=False) + + # Temp whitelist + temp_data = temp.get(str(server_id)) + if temp_data: + em.add_field( + name="⏰ Temp Whitelist", + value=f"Expires: `{temp_data['expires'][:19]}`\nAdded by: `{temp_data.get('added_by', '?')}`", + inline=False, + ) + + # Attempts + att_data = join_attempts.get(str(server_id)) + if att_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', '?')}`" + f"**{att_data['count']}** / {max_att}\n" + f"First: `{att_data.get('first_attempt', '?')[:19]}`\n" + f"Last: `{att_data.get('last_attempt', '?')[:19]}`" ), inline=False, ) @@ -982,23 +1286,17 @@ class ServerWhitelist(commands.Cog): @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 `` - """ + """Search servers by name.""" q = query.lower() - matches = [g for g in self.bot.guilds if q in g.name.lower()] + matches = sorted( + [g for g in self.bot.guilds if q in g.name.lower()], + key=lambda g: g.member_count or 0, reverse=True, + ) 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 - ) + pages, guild_pages = self._paginate_guilds(matches, title=f'🔍 "{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 ───────────────────────────────────────────────── @@ -1006,220 +1304,129 @@ class ServerWhitelist(commands.Cog): @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 `` - """ + """Leave a server by ID (doesn't un-whitelist).""" 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}`.") + await ctx.send(f"⚠️ Not in `{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, - ) + await self._log_event(title="👋 Left (Manual)", description=f"Left **{name}** by **{ctx.author}**.", colour=0xE67E22) # ── 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() + """Leave ALL non-whitelisted servers (with confirmation).""" + whitelist = 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.") + await ctx.send("✅ All servers are whitelisted.") return - names = "\n".join(f"• **{g.name}** (`{g.id}`) — {g.member_count:,} members" for g in to_leave[:20]) + names = "\n".join(f"• **{g.name}** (`{g.id}`) — {g.member_count:,}" 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, - ) + em = discord.Embed(title="⚠️ Confirm Purge", description=f"Leave **{len(to_leave)}** server(s)?\n\n{names}{extra}", colour=0xE74C3C) - view = ConfirmPurgeView(author_id=ctx.author.id) + view = ConfirmView(author_id=ctx.author.id) msg = await ctx.send(embed=em, view=view) await view.wait() - if view.result is True: - left: list[str] = [] + if view.result: + left = [] 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)], - ) + await msg.edit(embed=discord.Embed(title="🗑️ Purge Complete", description=f"Left **{len(left)}** server(s).", colour=0x2ECC71), view=None) + await self._log_event(title="🗑️ Purge", description=f"**{ctx.author}** purged {len(left)} server(s).", colour=0xE74C3C) else: - await msg.edit( - embed=discord.Embed( - title="Purge Cancelled", - description="No servers were left.", - colour=EMBED_COLOUR, - ), - view=None, - ) + await msg.edit(embed=discord.Embed(title="Cancelled", 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`` - """ + """Lock — reject ALL new joins.""" 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, - ) + await ctx.send("🔒 **Locked** — no new joins accepted.") + await self._log_event(title="🔒 Locked", description=f"Locked by **{ctx.author}**.", 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`` - """ + """Unlock — resume whitelist rules.""" 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, - ) + await ctx.send("🔓 **Unlocked** — whitelist rules active.") + await self._log_event(title="🔓 Unlocked", description=f"Unlocked by **{ctx.author}**.", 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" + async def join_log(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None) -> None: + """Set event log channel. No argument = disable.""" if channel is None: await self.config.log_channel.set(None) - await ctx.send("📝 Event logging **disabled**.") + await ctx.send("📝 Logging **disabled**.") return - await self.config.log_channel.set(channel.id) - await ctx.send(f"📝 Events will be logged to {channel.mention}.") + await ctx.send(f"📝 Logging 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. + """High-level server overview.""" + whitelist = await self.config.whitelist() + blacklist = await self.config.blacklist() + locked = await self.config.locked() + log_channel = await self.config.log_channel() + max_attempts = await self.config.max_attempts() + join_attempts = await self.config.join_attempts() + trusted = await self.config.trusted_inviters() + temp = await self.config.temp_whitelist() + alerts = await self.config.owner_alerts() + min_mem = await self.config.min_members() + max_mem = await self.config.max_members() + requests = await self.config.whitelist_requests() - 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) + total = len(self.bot.guilds) + 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) + att_servers = len(join_attempts) + att_total = sum(e.get("count", 0) for e in join_attempts.values()) + auto_banned = sum(1 for e in join_attempts.values() if e.get("count", 0) >= max_attempts) + log_ch = self.bot.get_channel(log_channel) if log_channel else None 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="Servers", value=f"{total:,}", inline=True) + em.add_field(name="Total Members", value=f"{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, - ) + em.add_field(name="Temp Whitelisted", value=str(len(temp)), inline=True) + em.add_field(name="Pending Requests", value=str(len(requests)), inline=True) + em.add_field(name="Lock", value="🔒 Locked" if locked else "🔓 Open", inline=True) + em.add_field(name="Alerts", value="🔔 On" if alerts else "🔕 Off", inline=True) + em.add_field(name="Trusted Inviters", value=str(len(trusted)), 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="Largest", value=f"**{largest.name}** ({largest.member_count:,})", inline=False) + 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, - ) + em.add_field(name="Member Limits", value=f"Min: {min_mem or '—'} · Max: {max_mem or '—'}", inline=True) + em.add_field(name="Attempt Tracking", value=f"**{att_servers}** tracked · **{att_total}** attempts · **{auto_banned}** auto-banned", inline=False) await ctx.send(embed=em) # ── settings ────────────────────────────────────────────── @@ -1227,55 +1434,53 @@ class ServerWhitelist(commands.Cog): @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() + """Display full configuration.""" + whitelist = await self.config.whitelist() + blacklist = await self.config.blacklist() + locked = await self.config.locked() + log_channel = await self.config.log_channel() + max_attempts = await self.config.max_attempts() + leave_message = await self.config.leave_message() + join_attempts = await self.config.join_attempts() + trusted = await self.config.trusted_inviters() + alerts = await self.config.owner_alerts() + min_mem = await self.config.min_members() + max_mem = await self.config.max_members() + temp = await self.config.temp_whitelist() + requests = await self.config.whitelist_requests() 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) + msg_text = leave_message or DEFAULT_LEAVE_MESSAGE + msg_preview = msg_text[:120] + "…" if len(msg_text) > 120 else msg_text - em = discord.Embed(title="⚙️ ServerWhitelist Settings", colour=EMBED_COLOUR) + em = discord.Embed(title=f"⚙️ ServerWhitelist v{VERSION} 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", value="🔒 ON" if locked else "🔓 OFF", inline=True) + em.add_field(name="Log Channel", value=log_ch.mention if log_ch else "Not set", inline=True) + em.add_field(name="Max Attempts", value=str(max_attempts), inline=True) + em.add_field(name="Member Limits", value=f"Min: {min_mem or 'None'} · Max: {max_mem or 'None'}", inline=False) + em.add_field(name="Trusted Inviters", value=str(len(trusted)), inline=True) + em.add_field(name="Owner Alerts", value="🔔 On" if alerts else "🔕 Off", inline=True) + em.add_field(name="Temp Whitelisted", value=str(len(temp)), inline=True) + em.add_field(name="Pending Requests", value=str(len(requests)), inline=True) + em.add_field(name="Tracked Attempts", value=str(len(join_attempts)), inline=True) + em.add_field(name="Leave DM", value=f"```{msg_preview}```", inline=False) 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", + name="Commands", 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" + f"`{ctx.clean_prefix}join ` — whitelist\n" + f"`{ctx.clean_prefix}join remove/blacklist/unblacklist `\n" + f"`{ctx.clean_prefix}join temp ` — temp whitelist\n" + f"`{ctx.clean_prefix}join servers/info/search/stats`\n" + f"`{ctx.clean_prefix}join purge/leave/lock/unlock`\n" + f"`{ctx.clean_prefix}join trust/untrust/trusted`\n" + f"`{ctx.clean_prefix}join note/tag/untag/tags`\n" + f"`{ctx.clean_prefix}join attempts/maxattempts/requests`\n" + f"`{ctx.clean_prefix}join alerts/minmembers/maxmembers`\n" + f"`{ctx.clean_prefix}join setmessage/resetmessage`\n" + f"`{ctx.clean_prefix}join backup/restore/export`" ), inline=False, ) @@ -1286,121 +1491,78 @@ class ServerWhitelist(commands.Cog): @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. + """Export server list as .txt.""" + whitelist = await self.config.whitelist() + blacklist = await self.config.blacklist() + join_attempts = await self.config.join_attempts() + notes = await self.config.server_notes() + tags = await self.config.server_tags() + temp = await self.config.temp_whitelist() - 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, + lines = [ + f"{'ID':<22} {'Members':>8} {'Status':<14} {'Att':>4} {'Tags':<20} {'Joined':<26} {'Owner':<30} Name", + "─" * 160, ] 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" + sp = [] + if g.id in whitelist: sp.append("WL") + if g.id in blacklist: sp.append("BL") + if str(g.id) in temp: sp.append("TEMP") + status = ",".join(sp) or "—" + joined = g.me.joined_at.strftime("%Y-%m-%d %H:%M UTC") if g.me and g.me.joined_at else "?" + owner = str(g.owner) if g.owner else "?" 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}" - ) + gtags = ",".join(tags.get(str(g.id), [])) or "—" + lines.append(f"{g.id:<22} {g.member_count or 0:>8,} {status:<14} {att:>4} {gtags:<20} {joined:<26} {owner:<30} {g.name}") + note = notes.get(str(g.id), "") + if note: + lines.append(f"{'':>22} 📝 {note[:100]}") - # 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 + # Non-joined tracked servers + tracked = set(int(k) for k in join_attempts.keys()) + current = set(g.id for g in self.bot.guilds) + non_joined = tracked - current 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) + lines += ["", "── Tracked (Not Joined) ──", f"{'ID':<22} {'Att':>4} {'Last Attempt':<26} {'Owner ID':<22} Name", "─" * 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')}" - ) + e = join_attempts[str(gid)] + lines.append(f"{gid:<22} {e.get('count',0):>4} {e.get('last_attempt','?'):<26} {str(e.get('owner_id','?')):<22} {e.get('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) + buf = io.BytesIO("\n".join(lines).encode()) + await ctx.send("📄 Server export:", file=discord.File(buf, filename="server_export.txt")) # ═══════════════════════════════════════════════════════════ - # Attempt Tracking Commands + # Attempt Tracking # ═══════════════════════════════════════════════════════════ @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() + """View join attempt tracker.""" + join_attempts = await self.config.join_attempts() + max_attempts = await self.config.max_attempts() + blacklist = await self.config.blacklist() if not join_attempts: - await ctx.send("📊 No join attempts have been recorded yet.") + await ctx.send("📊 No attempts recorded.") 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] = [] + sorted_entries = sorted(join_attempts.items(), key=lambda kv: kv[1].get("count", 0), reverse=True) + pages = [] 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, - ) + 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}" - ) - + 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}`" - ), + name=f"{entry.get('name', '?')} (`{gid}`)", + value=f"**Status:** {status}\n**Attempts:** {count}\n**Owner:** `{entry.get('owner_id', '?')}`\n**First:** `{str(entry.get('first_attempt','?'))[:19]}`\n**Last:** `{str(entry.get('last_attempt','?'))[:19]}`", inline=False, ) - em.set_footer(text=f"Total: {len(sorted_entries)} tracked server(s) • Max: {max_attempts} attempts") + em.set_footer(text=f"Total: {len(sorted_entries)} • Max: {max_attempts}") pages.append(em) view = PaginatedView(pages, author_id=ctx.author.id) @@ -1409,102 +1571,458 @@ class ServerWhitelist(commands.Cog): @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}`.") + """Reset attempts for one server.""" + async with self.config.join_attempts() as att: + if str(server_id) not in att: + await ctx.send(f"⚠️ No attempts 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, - ) + old = att.pop(str(server_id)) + await ctx.send(f"✅ Reset `{server_id}` (was **{old.get('count', 0)}**).") @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) + """Reset ALL attempt counters.""" + att = await self.config.join_attempts() + count = len(att) 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 - # ═══════════════════════════════════════════════════════════ + await ctx.send(f"✅ Cleared **{count}** server(s).") @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`` - """ + """Set max attempts before auto-ban (min 1).""" if count < 1: - await ctx.send("⚠️ Max attempts must be at least **1**.") + await ctx.send("⚠️ Must be ≥ 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 ctx.send(f"✅ Max attempts: **{old}** → **{count}**.") + + # ═══════════════════════════════════════════════════════════ + # Custom Leave DM + # ═══════════════════════════════════════════════════════════ + + @join_group.command(name="setmessage", aliases=["setmsg"]) + @commands.is_owner() + async def join_setmessage(self, ctx: commands.Context, *, text: str) -> None: + """Set custom leave DM text.""" + if len(text) > 1500: + await ctx.send("⚠️ Max 1,500 chars.") + return + await self.config.leave_message.set(text) + await ctx.send(f"✅ Updated!\n\n>>> {text[:500]}") + + @join_group.command(name="resetmessage", aliases=["resetmsg"]) + @commands.is_owner() + async def join_resetmessage(self, ctx: commands.Context) -> None: + """Reset leave DM to default.""" + await self.config.leave_message.set(None) + await ctx.send(f"✅ Reset to default:\n\n>>> {DEFAULT_LEAVE_MESSAGE}") + + # ═══════════════════════════════════════════════════════════ + # Whitelist Requests (NEW v4) + # ═══════════════════════════════════════════════════════════ + + @join_group.group(name="requests", aliases=["req"], invoke_without_command=True) + @commands.is_owner() + async def join_requests(self, ctx: commands.Context) -> None: + """View pending whitelist requests.""" + requests = await self.config.whitelist_requests() + if not requests: + await ctx.send("📭 No pending requests.") + return + + pages = [] + items = sorted(requests.items(), key=lambda kv: kv[1].get("requested_at", "")) + for i in range(0, len(items), PER_PAGE): + chunk = items[i:i + PER_PAGE] + em = discord.Embed(title="📩 Pending Whitelist Requests", colour=0x3498DB) + for gid_str, data in chunk: + em.add_field( + name=f"{data.get('name', '?')} (`{gid_str}`)", + value=( + f"**Requester:** {data.get('requester_name', '?')} (`{data.get('requester_id', '?')}`)\n" + f"**Requested:** `{str(data.get('requested_at', '?'))[:19]}`" + ), + inline=False, + ) + em.set_footer(text=f"Use [p]join requests approve/deny | Total: {len(items)}") + pages.append(em) + + view = PaginatedView(pages, author_id=ctx.author.id) + await ctx.send(embed=pages[0], view=view) + + @join_requests.command(name="approve") + @commands.is_owner() + async def join_requests_approve(self, ctx: commands.Context, server_id: int) -> None: + """Approve a whitelist request.""" + gid_str = str(server_id) + request_data = None + async with self.config.whitelist_requests() as reqs: + request_data = reqs.pop(gid_str, None) + if not request_data: + await ctx.send(f"⚠️ No pending request for `{server_id}`.") + return + async with self.config.whitelist() as wl: + if server_id not in wl: + wl.append(server_id) + async with self.config.join_attempts() as att: + att.pop(gid_str, None) + # Notify requester + try: + user = await self.bot.fetch_user(request_data["requester_id"]) + em = discord.Embed(title="✅ Request Approved!", description=f"**{request_data['name']}** (`{server_id}`) is now whitelisted!", colour=0x2ECC71) + await user.send(embed=em) + except (discord.HTTPException, discord.NotFound): + pass + await ctx.send(f"✅ `{server_id}` approved and whitelisted!") + await self._log_event(title="✅ Request Approved", description=f"`{server_id}` approved by **{ctx.author}**.", colour=0x2ECC71) + + @join_requests.command(name="deny") + @commands.is_owner() + async def join_requests_deny(self, ctx: commands.Context, server_id: int) -> None: + """Deny a whitelist request.""" + gid_str = str(server_id) + request_data = None + async with self.config.whitelist_requests() as reqs: + request_data = reqs.pop(gid_str, None) + if not request_data: + await ctx.send(f"⚠️ No pending request for `{server_id}`.") + return + try: + user = await self.bot.fetch_user(request_data["requester_id"]) + em = discord.Embed(title="❌ Request Denied", description=f"**{request_data['name']}** (`{server_id}`) was denied.", colour=0xE74C3C) + await user.send(embed=em) + except (discord.HTTPException, discord.NotFound): + pass + await ctx.send(f"❌ `{server_id}` denied.") + await self._log_event(title="❌ Request Denied", description=f"`{server_id}` denied by **{ctx.author}**.", colour=0xE74C3C) + + # ═══════════════════════════════════════════════════════════ + # Server Notes & Tags (NEW v4) + # ═══════════════════════════════════════════════════════════ + + @join_group.command(name="note") + @commands.is_owner() + async def join_note(self, ctx: commands.Context, server_id: int, *, text: Optional[str] = None) -> None: + """Set or view a note for a server. No text = view.""" + gid_str = str(server_id) + if text is None: + notes = await self.config.server_notes() + note = notes.get(gid_str, "") + if note: + await ctx.send(f"📝 **Note for `{server_id}`:**\n{note}") + else: + await ctx.send(f"No note for `{server_id}`. Use `{ctx.clean_prefix}join note {server_id} ` to add one.") + return + if text.lower() == "clear": + async with self.config.server_notes() as notes: + notes.pop(gid_str, None) + await ctx.send(f"✅ Note cleared for `{server_id}`.") + return + async with self.config.server_notes() as notes: + notes[gid_str] = text[:500] + await ctx.send(f"✅ Note saved for `{server_id}`:\n📝 {text[:500]}") + + @join_group.command(name="tag") + @commands.is_owner() + async def join_tag(self, ctx: commands.Context, server_id: int, *, tag: str) -> None: + """Add a tag to a server (e.g. partner, testing, personal).""" + tag = tag.lower().strip().replace(" ", "-")[:30] + gid_str = str(server_id) + async with self.config.server_tags() as tags: + gtags = tags.get(gid_str, []) + if tag in gtags: + await ctx.send(f"⚠️ `{server_id}` already has tag `{tag}`.") + return + gtags.append(tag) + tags[gid_str] = gtags + await ctx.send(f"🏷️ Tag `{tag}` added to `{server_id}`.") + + @join_group.command(name="untag") + @commands.is_owner() + async def join_untag(self, ctx: commands.Context, server_id: int, *, tag: str) -> None: + """Remove a tag from a server.""" + tag = tag.lower().strip().replace(" ", "-")[:30] + gid_str = str(server_id) + async with self.config.server_tags() as tags: + gtags = tags.get(gid_str, []) + if tag not in gtags: + await ctx.send(f"⚠️ `{server_id}` doesn't have tag `{tag}`.") + return + gtags.remove(tag) + tags[gid_str] = gtags + await ctx.send(f"🏷️ Tag `{tag}` removed from `{server_id}`.") + + @join_group.command(name="tags") + @commands.is_owner() + async def join_tags(self, ctx: commands.Context) -> None: + """View all tags and which servers have them.""" + all_tags = await self.config.server_tags() + if not all_tags: + await ctx.send("No tags set.") + return + + # Group by tag + tag_map: dict[str, list[str]] = {} + for gid_str, gtags in all_tags.items(): + for t in gtags: + tag_map.setdefault(t, []).append(gid_str) + + em = discord.Embed(title="🏷️ Server Tags", colour=EMBED_COLOUR) + for tag, gids in sorted(tag_map.items()): + names = [] + for gid_str in gids: + guild = self.bot.get_guild(int(gid_str)) + name = guild.name if guild else gid_str + names.append(f"**{name}** (`{gid_str}`)") + em.add_field(name=f"`{tag}` ({len(gids)})", value="\n".join(names[:10]), inline=False) + await ctx.send(embed=em) + + # ═══════════════════════════════════════════════════════════ + # Temporary Whitelist (NEW v4) + # ═══════════════════════════════════════════════════════════ + + @join_group.command(name="temp") + @commands.is_owner() + async def join_temp(self, ctx: commands.Context, server_id: int, *, duration: str) -> None: + """Temporarily whitelist a server. + + Duration examples: ``7d``, ``12h``, ``2d 6h 30m`` + """ + delta = parse_duration(duration) + if delta is None: + await ctx.send("⚠️ Invalid duration. Examples: `7d`, `12h`, `2d 6h 30m`.") + return + + blacklist = await self.config.blacklist() + if server_id in blacklist: + await ctx.send(f"⚠️ `{server_id}` is blacklisted. Unblacklist first.") + return + + expires = datetime.now(timezone.utc) + delta + gid_str = str(server_id) + + async with self.config.whitelist() as wl: + if server_id not in wl: + wl.append(server_id) + + async with self.config.temp_whitelist() as tw: + tw[gid_str] = { + "expires": expires.isoformat(), + "added_by": str(ctx.author), + "name": "", + } + + # Reset attempts + async with self.config.join_attempts() as att: + att.pop(gid_str, None) + + await ctx.send( + f"⏰ `{server_id}` temporarily whitelisted until " + f"**{discord.utils.format_dt(expires, style='F')}** ({discord.utils.format_dt(expires, style='R')})." + ) await self._log_event( - title="⚙️ Max Attempts Updated", - description=f"Max attempts changed from **{old}** → **{count}** by **{ctx.author}**.", + title="⏰ Temp Whitelisted", + description=f"`{server_id}` temp-whitelisted by **{ctx.author}** until {expires.isoformat()[:19]}.", colour=0x3498DB, ) # ═══════════════════════════════════════════════════════════ - # Custom Leave DM Message + # Server Requirements (NEW v4) # ═══════════════════════════════════════════════════════════ - @join_group.command(name="setmessage", aliases=["setmsg", "dmtext"]) + @join_group.command(name="minmembers") @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. + async def join_minmembers(self, ctx: commands.Context, count: int) -> None: + """Set minimum member count to join (0 = no minimum).""" + if count < 0: + await ctx.send("⚠️ Must be ≥ 0.") + return + await self.config.min_members.set(count) + await ctx.send(f"✅ Min members: **{count}** {'(disabled)' if count == 0 else ''}.") - The message is sent in a rich embed along with the server name, reason, and warnings. + @join_group.command(name="maxmembers") + @commands.is_owner() + async def join_maxmembers(self, ctx: commands.Context, count: int) -> None: + """Set maximum member count to join (0 = no maximum).""" + if count < 0: + await ctx.send("⚠️ Must be ≥ 0.") + return + await self.config.max_members.set(count) + await ctx.send(f"✅ Max members: **{count}** {'(disabled)' if count == 0 else ''}.") - 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).") + # ═══════════════════════════════════════════════════════════ + # Trusted Inviters (NEW v4) + # ═══════════════════════════════════════════════════════════ + + @join_group.command(name="trust") + @commands.is_owner() + async def join_trust(self, ctx: commands.Context, user_id: int) -> None: + """Trust a user — any server they add the bot to is auto-whitelisted.""" + async with self.config.trusted_inviters() as ti: + if user_id in ti: + await ctx.send(f"⚠️ `{user_id}` is already trusted.") + return + ti.append(user_id) + try: + user = await self.bot.fetch_user(user_id) + name = str(user) + except (discord.HTTPException, discord.NotFound): + name = str(user_id) + await ctx.send(f"🔑 **{name}** (`{user_id}`) is now a trusted inviter.") + await self._log_event(title="🔑 Trusted Inviter Added", description=f"**{name}** added by **{ctx.author}**.", colour=0x2ECC71) + + @join_group.command(name="untrust") + @commands.is_owner() + async def join_untrust(self, ctx: commands.Context, user_id: int) -> None: + """Remove a trusted inviter.""" + async with self.config.trusted_inviters() as ti: + if user_id not in ti: + await ctx.send(f"⚠️ `{user_id}` is not trusted.") + return + ti.remove(user_id) + await ctx.send(f"✅ `{user_id}` removed from trusted inviters.") + + @join_group.command(name="trusted") + @commands.is_owner() + async def join_trusted(self, ctx: commands.Context) -> None: + """View all trusted inviters.""" + trusted = await self.config.trusted_inviters() + if not trusted: + await ctx.send("No trusted inviters.") + return + lines = [] + for uid in trusted: + try: + user = await self.bot.fetch_user(uid) + lines.append(f"🔑 **{user}** (`{uid}`)") + except (discord.HTTPException, discord.NotFound): + lines.append(f"🔑 `{uid}` (unknown user)") + em = discord.Embed(title="🔑 Trusted Inviters", description="\n".join(lines), colour=EMBED_COLOUR) + await ctx.send(embed=em) + + # ═══════════════════════════════════════════════════════════ + # Owner Alerts (NEW v4) + # ═══════════════════════════════════════════════════════════ + + @join_group.command(name="alerts") + @commands.is_owner() + async def join_alerts(self, ctx: commands.Context, state: str) -> None: + """Toggle owner DM alerts. Usage: ``[p]join alerts on`` or ``off``.""" + if state.lower() in ("on", "true", "yes", "enable", "1"): + await self.config.owner_alerts.set(True) + await ctx.send("🔔 Owner alerts **enabled** — you'll get DMs for join/leave/block events.") + elif state.lower() in ("off", "false", "no", "disable", "0"): + await self.config.owner_alerts.set(False) + await ctx.send("🔕 Owner alerts **disabled**.") + else: + await ctx.send("Usage: `[p]join alerts on` or `[p]join alerts off`.") + + # ═══════════════════════════════════════════════════════════ + # Backup & Restore (NEW v4) + # ═══════════════════════════════════════════════════════════ + + @join_group.command(name="backup") + @commands.is_owner() + async def join_backup(self, ctx: commands.Context) -> None: + """Export the full ServerWhitelist config as a JSON file.""" + data = { + "version": VERSION, + "exported_at": datetime.now(timezone.utc).isoformat(), + "whitelist": await self.config.whitelist(), + "blacklist": await self.config.blacklist(), + "locked": await self.config.locked(), + "log_channel": await self.config.log_channel(), + "max_attempts": await self.config.max_attempts(), + "leave_message": await self.config.leave_message(), + "join_attempts": await self.config.join_attempts(), + "min_members": await self.config.min_members(), + "max_members": await self.config.max_members(), + "trusted_inviters": await self.config.trusted_inviters(), + "owner_alerts": await self.config.owner_alerts(), + "temp_whitelist": await self.config.temp_whitelist(), + "server_notes": await self.config.server_notes(), + "server_tags": await self.config.server_tags(), + "whitelist_requests": await self.config.whitelist_requests(), + } + buf = io.BytesIO(json.dumps(data, indent=2).encode()) + await ctx.send( + "💾 Here's your full config backup:", + file=discord.File(buf, filename=f"serverwhitelist_backup_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json"), + ) + + @join_group.command(name="restore") + @commands.is_owner() + async def join_restore(self, ctx: commands.Context) -> None: + """Restore config from an attached JSON file.""" + if not ctx.message.attachments: + await ctx.send("⚠️ Attach a `.json` backup file to the message.") return - await self.config.leave_message.set(text) - await ctx.send( - f"✅ Leave DM message updated!\n\n**Preview:**\n>>> {text[:500]}" - ) + attachment = ctx.message.attachments[0] + if not attachment.filename.endswith(".json"): + await ctx.send("⚠️ File must be `.json`.") + return - @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. + try: + raw = await attachment.read() + data = json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + await ctx.send("⚠️ Invalid JSON file.") + return - 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}" + # Confirm + keys_found = [k for k in data.keys() if k not in ("version", "exported_at")] + em = discord.Embed( + title="⚠️ Confirm Restore", + description=( + f"This will **overwrite** your current config with the backup.\n\n" + f"**Backup version:** {data.get('version', '?')}\n" + f"**Exported:** {data.get('exported_at', '?')}\n" + f"**Keys:** {', '.join(keys_found)}" + ), + colour=0xE74C3C, ) + view = ConfirmView(author_id=ctx.author.id) + msg = await ctx.send(embed=em, view=view) + await view.wait() + + if not view.result: + await msg.edit(embed=discord.Embed(title="Cancelled", colour=EMBED_COLOUR), view=None) + return + + # Apply + field_map = { + "whitelist": self.config.whitelist, + "blacklist": self.config.blacklist, + "locked": self.config.locked, + "log_channel": self.config.log_channel, + "max_attempts": self.config.max_attempts, + "leave_message": self.config.leave_message, + "join_attempts": self.config.join_attempts, + "min_members": self.config.min_members, + "max_members": self.config.max_members, + "trusted_inviters": self.config.trusted_inviters, + "owner_alerts": self.config.owner_alerts, + "temp_whitelist": self.config.temp_whitelist, + "server_notes": self.config.server_notes, + "server_tags": self.config.server_tags, + "whitelist_requests": self.config.whitelist_requests, + } + restored = 0 + for key, setter in field_map.items(): + if key in data: + await setter.set(data[key]) + restored += 1 + + await msg.edit( + embed=discord.Embed( + title="✅ Restore Complete", + description=f"Restored **{restored}** config key(s) from backup.", + colour=0x2ECC71, + ), + view=None, + ) + await self._log_event(title="💾 Config Restored", description=f"Config restored by **{ctx.author}** ({restored} keys).", colour=0x3498DB)