Spaces:
Runtime error
Runtime error
| """ | |
| ποΈ 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) | |
| 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") | |
| 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) | |
| 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) | |
| 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") | |
| 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.") | |