Kode-Animator's picture
Add DNS retry logic for HF container startup
262b3ee
Raw
History Blame Contribute Delete
18.6 kB
"""
πŸ‘οΈ 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.")