""" πŸ‘οΈ THE SENTINEL - Multi-Menu Role System ========================================== Professional-grade role kiosk with multiple themed panels. Inspired by "adult server architecture" but SFW. Architecture: Data-driven menus, ephemeral confirmations, one locked #roles channel Version: 2.0.0 """ import os import sys import threading from http.server import HTTPServer, BaseHTTPRequestHandler import discord from discord.ext import commands from pathlib import Path from typing import List, Dict, Any # ============================================================================= # ENVIRONMENT LOADING (Multi-Strategy) # ============================================================================= def load_token() -> str: """Load DISCORD_BOT_TOKEN with Federation support.""" # 1. Check OS first (HF Spaces sets this) token = os.getenv("DISCORD_BOT_TOKEN") if token: print("πŸ”‘ Token from OS environment") return token # 2. Try Federation Constitution PILLAR try: user_home = Path(__file__).resolve().parent.parent.parent.parent infra_path = user_home / "Infrastructure" if infra_path.exists(): sys.path.insert(0, str(infra_path)) from federation_heart.pillars import ConstitutionPillar constitution = ConstitutionPillar(infra_path) token = constitution.env.get("DISCORD_BOT_TOKEN") if token: print(f"πŸ” Token from Federation Constitution Pillar") return token except Exception as e: print(f"⚠️ Federation Constitution not available: {e}") # 3. Try local .env file try: from dotenv import load_dotenv load_dotenv() token = os.getenv("DISCORD_BOT_TOKEN") if token: print("πŸ“ Token from .env file") return token except ImportError: pass return None # ============================================================================= # ROLE MENUS CONFIGURATION (Data-Driven!) # ============================================================================= ROLE_MENUS = [ { "id": "pronouns", "title": "🏷️ Role Menu: Pronouns", "description": "Let others know how to address you.", "color": 0x9B59B6, # Purple "buttons": [ {"label": "He/Him", "emoji": "πŸ”΅", "role": "He/Him", "style": "primary"}, {"label": "She/Her", "emoji": "πŸ”΄", "role": "She/Her", "style": "primary"}, {"label": "They/Them", "emoji": "🟣", "role": "They/Them", "style": "primary"}, {"label": "Any", "emoji": "βšͺ", "role": "Any Pronouns", "style": "secondary"}, {"label": "Ask Me", "emoji": "❓", "role": "Ask Pronouns", "style": "secondary"}, ] }, { "id": "timezone", "title": "🌍 Role Menu: Timezone", "description": "Help us know when you're active.", "color": 0x3498DB, # Blue "buttons": [ {"label": "Americas", "emoji": "🌎", "role": "TZ: Americas", "style": "primary"}, {"label": "Europe/Africa", "emoji": "🌍", "role": "TZ: Europe/Africa", "style": "primary"}, {"label": "Asia/Pacific", "emoji": "🌏", "role": "TZ: Asia/Pacific", "style": "primary"}, {"label": "Night Owl", "emoji": "πŸ¦‰", "role": "TZ: Night Owl", "style": "secondary"}, ] }, { "id": "pings", "title": "πŸ”” Role Menu: Notifications", "description": "What do you want to be pinged for?", "color": 0xE67E22, # Orange "buttons": [ {"label": "Events", "emoji": "πŸ“…", "role": "Ping: Events", "style": "primary"}, {"label": "Releases", "emoji": "πŸš€", "role": "Ping: Releases", "style": "primary"}, {"label": "Streams", "emoji": "πŸ“Ί", "role": "Ping: Streams", "style": "secondary"}, {"label": "QOTD", "emoji": "πŸ’¬", "role": "Ping: QOTD", "style": "secondary"}, ] }, { "id": "tracks", "title": "πŸ›€οΈ Role Menu: Builder Tracks", "description": "What do you build? (Pick as many as you want)", "color": 0x2ECC71, # Green "buttons": [ {"label": "Code", "emoji": "πŸ’»", "role": "Code Crafter πŸ’»", "style": "primary"}, {"label": "Systems", "emoji": "πŸ—οΈ", "role": "System Architect πŸ—οΈ", "style": "primary"}, {"label": "UI/Design", "emoji": "🎨", "role": "UI Designer 🎨", "style": "primary"}, {"label": "AI/Agents", "emoji": "πŸ€–", "role": "AI Builder πŸ€–", "style": "secondary"}, {"label": "Writer", "emoji": "πŸ“–", "role": "Writer πŸ“–", "style": "secondary"}, ] }, { "id": "collab", "title": "🀝 Role Menu: Collaboration Style", "description": "How do you like to work with others?", "color": 0x1ABC9C, # Teal "buttons": [ {"label": "Mentor Available", "emoji": "πŸ§‘β€πŸ«", "role": "Mentor Available", "style": "success"}, {"label": "Looking for Mentor", "emoji": "🧭", "role": "Seeking Mentor", "style": "primary"}, {"label": "Pair Program", "emoji": "πŸ‘₯", "role": "Pair Ready", "style": "primary"}, {"label": "Async Only", "emoji": "πŸ“¨", "role": "Async Only", "style": "secondary"}, ] }, { "id": "dm", "title": "βœ‰οΈ Role Menu: DM Preferences", "description": "Let others know your DM policy.", "color": 0xE91E63, # Pink "buttons": [ {"label": "No DMs", "emoji": "β›”", "role": "No DMs", "style": "danger"}, {"label": "Ask First", "emoji": "πŸšͺ", "role": "Ask to DM", "style": "primary"}, {"label": "Open DMs", "emoji": "πŸ’¬", "role": "Open DMs", "style": "success"}, ] }, ] # ============================================================================= # RULES GATE (Verification System) # ============================================================================= VERIFIED_ROLE = "Verified" # Role granted on rules acceptance RULES_GATE = { "channel": "welcome-and-rules", "embed": { "title": "πŸ“œ Server Rules", "description": """Before you continue, please acknowledge our community guidelines: **1.** Build in public or privateβ€”your choice **2.** Help when you can, ask when you can't **3.** No gatekeeping knowledge **4.** Be excellent to each other By clicking **Accept**, you agree to these rules and gain access to the server.""", "color": 0x2ECC71, # Green }, "button": { "label": "βœ… I Accept the Rules", "style": "success", } } # The directions post (pinned at top of #roles) DIRECTIONS_POST = """ πŸͺͺ **Please assign your own roles by clicking buttons below** **Directions:** β€’ Click a button to add the role β€’ Click again to remove it β€’ You can't mess it up β€’ Questions? Ask in #support *Scroll down for all the menus!* """ # ============================================================================= # DYNAMIC VIEW FACTORY # ============================================================================= def get_button_style(style_name: str) -> discord.ButtonStyle: """Convert style name to discord.ButtonStyle.""" styles = { "primary": discord.ButtonStyle.primary, "secondary": discord.ButtonStyle.secondary, "success": discord.ButtonStyle.success, "danger": discord.ButtonStyle.danger, } return styles.get(style_name, discord.ButtonStyle.secondary) class RoleMenuView(discord.ui.View): """Dynamic view that creates buttons from menu config.""" def __init__(self, menu_config: Dict[str, Any]): super().__init__(timeout=None) # Persistent! self.menu_id = menu_config["id"] for i, btn_config in enumerate(menu_config["buttons"]): button = discord.ui.Button( label=btn_config["label"], emoji=btn_config.get("emoji"), style=get_button_style(btn_config.get("style", "secondary")), custom_id=f"role_{self.menu_id}_{i}", # Unique & persistent row=i // 5 # Max 5 buttons per row ) button.callback = self._make_callback(btn_config["role"]) self.add_item(button) def _make_callback(self, role_name: str): async def callback(interaction: discord.Interaction): await self._toggle_role(interaction, role_name) return callback async def _toggle_role(self, interaction: discord.Interaction, role_name: str): """Toggle a role with ephemeral feedback.""" role = discord.utils.get(interaction.guild.roles, name=role_name) if not role: # Don't expose missing role names - be vague await interaction.response.send_message( "⚠️ This option is temporarily unavailable.", ephemeral=True ) print(f"⚠️ Role not found: {role_name}") return if role in interaction.user.roles: await interaction.user.remove_roles(role) await interaction.response.send_message( f"βž– Removed: **{role_name}**", ephemeral=True ) else: await interaction.user.add_roles(role) await interaction.response.send_message( f"βœ… Added: **{role_name}**", ephemeral=True ) class RulesGateView(discord.ui.View): """Verification gate - grants Verified role on acceptance.""" def __init__(self): super().__init__(timeout=None) # Persistent! button = discord.ui.Button( label=RULES_GATE["button"]["label"], style=get_button_style(RULES_GATE["button"]["style"]), custom_id="rules_accept", # Persistent ID emoji="βœ…" ) button.callback = self._accept_callback self.add_item(button) async def _accept_callback(self, interaction: discord.Interaction): """Handle rules acceptance.""" # Defer immediately to avoid timeout await interaction.response.defer(ephemeral=True) role = discord.utils.get(interaction.guild.roles, name=VERIFIED_ROLE) if not role: await interaction.followup.send( "⚠️ Verification system is being set up. Please try again later.", ephemeral=True ) print(f"⚠️ Verified role '{VERIFIED_ROLE}' not found!") return if role in interaction.user.roles: await interaction.followup.send( "βœ… You're already verified! Welcome to the server.", ephemeral=True ) else: await interaction.user.add_roles(role) await interaction.followup.send( "πŸŽ‰ **Welcome to Pantheon LadderWorks!**\n\n" "You now have access to the server. Head to #roles to customize your experience!", ephemeral=True ) print(f"βœ… Verified: {interaction.user.name}") # ============================================================================= # THE BOT # ============================================================================= intents = discord.Intents.default() intents.guilds = True intents.members = True intents.message_content = True bot = commands.Bot(command_prefix="!", intents=intents) @bot.event async def on_ready(): print(f"πŸ‘οΈ Sentinel Online: {bot.user}") print(f"πŸ›οΈ Connected to {len(bot.guilds)} guild(s)") # Register ALL menu views for persistence for menu in ROLE_MENUS: bot.add_view(RoleMenuView(menu)) print(f"πŸ“‹ Registered {len(ROLE_MENUS)} role menus") # Register rules gate view for persistence bot.add_view(RulesGateView()) print(f"πŸšͺ Registered rules gate") @bot.command() @commands.has_permissions(administrator=True) async def deploy_roles(ctx): """Deploy the full multi-menu role system.""" print(f"πŸš€ Deploying role menus to #{ctx.channel.name}...") # 1. Post the directions (pin it) directions = await ctx.send(DIRECTIONS_POST) await directions.pin() print(f"πŸ“Œ Directions pinned") # 2. Post each menu panel for menu in ROLE_MENUS: embed = discord.Embed( title=menu["title"], description=menu["description"], color=menu["color"] ) embed.set_footer(text="Click to toggle β€’ Pantheon LadderWorks") view = RoleMenuView(menu) msg = await ctx.send(embed=embed, view=view) print(f" βœ… {menu['title']} deployed (ID: {msg.id})") # 3. Hide the command await ctx.message.delete() await ctx.send("βœ… **Role menus deployed!** This message will self-destruct.", delete_after=5) @bot.command() @commands.has_permissions(administrator=True) async def sentinel_status(ctx): """Check Sentinel status.""" embed = discord.Embed( title="πŸ‘οΈ Sentinel Status", description="The Sentinel is online and watching.", color=0x3498db ) embed.add_field(name="Guilds", value=str(len(bot.guilds)), inline=True) embed.add_field(name="Latency", value=f"{round(bot.latency * 1000)}ms", inline=True) embed.add_field(name="Menus Registered", value=str(len(ROLE_MENUS)), inline=True) await ctx.send(embed=embed) @bot.command() @commands.has_permissions(administrator=True) async def sync_roles(ctx): """Create missing roles from the menu config.""" created = [] existing = [] # Collect all role names from menus all_roles = set() for menu in ROLE_MENUS: for btn in menu["buttons"]: all_roles.add(btn["role"]) # Check/create each for role_name in sorted(all_roles): existing_role = discord.utils.get(ctx.guild.roles, name=role_name) if existing_role: existing.append(role_name) else: await ctx.guild.create_role( name=role_name, color=discord.Color.default(), hoist=False, mentionable=False ) created.append(role_name) # Also create Verified role if missing verified_role = discord.utils.get(ctx.guild.roles, name=VERIFIED_ROLE) if not verified_role: await ctx.guild.create_role( name=VERIFIED_ROLE, color=discord.Color.green(), hoist=False, mentionable=False ) created.append(f"⭐ {VERIFIED_ROLE}") else: existing.append(VERIFIED_ROLE) embed = discord.Embed( title="πŸ”„ Role Sync Complete", color=0x2ECC71 if created else 0x3498DB ) if created: embed.add_field(name="βœ… Created", value="\n".join(created[:10]) + ("..." if len(created) > 10 else ""), inline=False) embed.add_field(name="πŸ“‹ Total Roles", value=f"{len(all_roles) + 1} configured, {len(created)} created, {len(existing)} existed", inline=False) await ctx.send(embed=embed) print(f"πŸ”„ Synced roles: {len(created)} created, {len(existing)} existed") @bot.command() @commands.has_permissions(administrator=True) async def deploy_gate(ctx): """Deploy the rules acceptance gate to #welcome-and-rules.""" channel_name = RULES_GATE["channel"] channel = discord.utils.get(ctx.guild.text_channels, name=channel_name) if not channel: await ctx.send(f"❌ Channel #{channel_name} not found!") return # Build the embed embed = discord.Embed( title=RULES_GATE["embed"]["title"], description=RULES_GATE["embed"]["description"], color=RULES_GATE["embed"]["color"] ) # Post with button view = RulesGateView() await channel.send(embed=embed, view=view) await ctx.send(f"βœ… Rules gate deployed to #{channel_name}!") print(f"πŸšͺ Rules gate deployed to #{channel_name}") # ============================================================================= # KEEPALIVE WEB SERVER (HuggingFace requires port 7860) # ============================================================================= class HealthHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() status = "online" if bot.is_ready() else "starting" self.wfile.write(f'{{"status":"{status}","service":"discord-sentinel"}}'.encode()) def log_message(self, format, *args): pass # Suppress request logs def start_keepalive(): server = HTTPServer(("0.0.0.0", 7860), HealthHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() print("🌐 Keepalive server on port 7860") # ============================================================================= # LAUNCH # ============================================================================= if __name__ == "__main__": print("πŸš€ Starting Sentinel v2.0...") start_keepalive() # HuggingFace needs port 7860 alive token = load_token() if not token: print("❌ DISCORD_BOT_TOKEN not found!") print(" 1. Set as OS environment variable") print(" 2. Place in Infrastructure/.secrets/.env") print(" 3. Create local .env file") else: # Retry loop: HuggingFace containers sometimes need time for DNS to resolve import time max_retries = 5 for attempt in range(1, max_retries + 1): try: print(f"πŸ”Œ Connection attempt {attempt}/{max_retries}...") bot.run(token) break # If bot.run returns normally (disconnect), exit except Exception as e: if "No address associated with hostname" in str(e) or "DNS" in str(e): wait_time = 10 * attempt # 10s, 20s, 30s... print(f"⏳ DNS not ready, retrying in {wait_time}s... ({e})") time.sleep(wait_time) else: print(f"❌ Fatal error: {e}") break else: print("❌ All connection attempts failed. Check HF Space networking.")