const { Client, GatewayIntentBits, REST, Routes, Collection, EmbedBuilder, } = require("discord.js"); const { createClient } = require("@supabase/supabase-js"); const http = require("http"); const fs = require("fs"); const path = require("path"); require("dotenv").config(); const { setupFeedListener, sendPostToDiscord, getFeedChannelId } = require("./listeners/feedSync"); // Validate environment variables const requiredEnvVars = [ "DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID", "SUPABASE_URL", "SUPABASE_SERVICE_ROLE", ]; const missingVars = requiredEnvVars.filter((envVar) => !process.env[envVar]); if (missingVars.length > 0) { console.error( "❌ FATAL ERROR: Missing required environment variables:", missingVars.join(", "), ); console.error("\nPlease set these in your Discloud/hosting environment:"); missingVars.forEach((envVar) => { console.error(` - ${envVar}`); }); process.exit(1); } // Validate token format const token = process.env.DISCORD_BOT_TOKEN; if (!token || token.length < 20) { console.error("❌ FATAL ERROR: DISCORD_BOT_TOKEN is empty or invalid"); console.error(` Length: ${token ? token.length : 0}`); process.exit(1); } console.log("[Token] Bot token loaded (length: " + token.length + " chars)"); // Initialize Discord client with message intents for feed sync const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, ], }); // Initialize Supabase const supabase = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE, ); // Store slash commands client.commands = new Collection(); // Load commands from commands directory const commandsPath = path.join(__dirname, "commands"); const commandFiles = fs .readdirSync(commandsPath) .filter((file) => file.endsWith(".js")); for (const file of commandFiles) { const filePath = path.join(commandsPath, file); const command = require(filePath); if ("data" in command && "execute" in command) { client.commands.set(command.data.name, command); console.log(`✅ Loaded command: ${command.data.name}`); } } // Load event handlers from events directory const eventsPath = path.join(__dirname, "events"); if (fs.existsSync(eventsPath)) { const eventFiles = fs .readdirSync(eventsPath) .filter((file) => file.endsWith(".js")); for (const file of eventFiles) { const filePath = path.join(eventsPath, file); const event = require(filePath); if ("name" in event && "execute" in event) { client.on(event.name, (...args) => event.execute(...args, client, supabase), ); console.log(`✅ Loaded event listener: ${event.name}`); } } } // Slash command interaction handler client.on("interactionCreate", async (interaction) => { if (!interaction.isChatInputCommand()) return; const command = client.commands.get(interaction.commandName); if (!command) { console.warn( `⚠️ No command matching ${interaction.commandName} was found.`, ); return; } try { await command.execute(interaction, supabase, client); } catch (error) { console.error(`❌ Error executing ${interaction.commandName}:`, error); const errorEmbed = new EmbedBuilder() .setColor(0xff0000) .setTitle("❌ Command Error") .setDescription("There was an error while executing this command.") .setFooter({ text: "Contact support if this persists" }); if (interaction.replied || interaction.deferred) { await interaction.followUp({ embeds: [errorEmbed], ephemeral: true }); } else { await interaction.reply({ embeds: [errorEmbed], ephemeral: true }); } } }); // IMPORTANT: Commands are now registered via a separate script // Run this ONCE during deployment: npm run register-commands // This prevents Error 50240 (Entry Point conflict) when Activities are enabled // The bot will simply load and listen for the already-registered commands // Define all commands for registration const COMMANDS_TO_REGISTER = [ { name: "verify", description: "Link your Discord account to AeThex", }, { name: "set-realm", description: "Choose your primary arm/realm (Labs, GameForge, Corp, etc.)", options: [ { name: "realm", type: 3, description: "Your primary realm", required: true, choices: [ { name: "Labs", value: "labs" }, { name: "GameForge", value: "gameforge" }, { name: "Corp", value: "corp" }, { name: "Foundation", value: "foundation" }, { name: "Dev-Link", value: "devlink" }, ], }, ], }, { name: "profile", description: "View your linked AeThex profile", }, { name: "unlink", description: "Disconnect your Discord account from AeThex", }, { name: "verify-role", description: "Check your assigned Discord roles", }, { name: "help", description: "View all AeThex bot commands and features", }, { name: "stats", description: "View your AeThex statistics and activity", }, { name: "leaderboard", description: "View the top AeThex contributors", options: [ { name: "category", type: 3, description: "Leaderboard category", required: false, choices: [ { name: "Most Active (Posts)", value: "posts" }, { name: "Most Liked", value: "likes" }, { name: "Top Creators", value: "creators" }, ], }, ], }, { name: "post", description: "Create a post in the AeThex community feed", options: [ { name: "content", type: 3, description: "Your post content", required: true, max_length: 500, }, { name: "category", type: 3, description: "Post category", required: false, choices: [ { name: "General", value: "general" }, { name: "Project Update", value: "project_update" }, { name: "Question", value: "question" }, { name: "Idea", value: "idea" }, { name: "Announcement", value: "announcement" }, ], }, { name: "image", type: 11, description: "Attach an image to your post", required: false, }, ], }, ]; // Function to register commands with Discord async function registerDiscordCommands() { try { const rest = new REST({ version: "10" }).setToken( process.env.DISCORD_BOT_TOKEN, ); console.log( `📝 Registering ${COMMANDS_TO_REGISTER.length} slash commands...`, ); try { // Try bulk update first const data = await rest.put( Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), { body: COMMANDS_TO_REGISTER }, ); console.log(`✅ Successfully registered ${data.length} slash commands`); return { success: true, count: data.length, results: null }; } catch (bulkError) { // Handle Error 50240 (Entry Point conflict) if (bulkError.code === 50240) { console.warn( "⚠️ Error 50240: Entry Point detected. Registering individually...", ); const results = []; let successCount = 0; let skipCount = 0; for (const command of COMMANDS_TO_REGISTER) { try { const posted = await rest.post( Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), { body: command }, ); results.push({ name: command.name, status: "registered", id: posted.id, }); successCount++; } catch (postError) { if (postError.code === 50045) { results.push({ name: command.name, status: "already_exists", }); skipCount++; } else { results.push({ name: command.name, status: "error", error: postError.message, }); } } } console.log( `✅ Registration complete: ${successCount} new, ${skipCount} already existed`, ); return { success: true, count: successCount, skipped: skipCount, results, }; } throw bulkError; } } catch (error) { console.error("❌ Failed to register commands:", error); return { success: false, error: error.message }; } } // Start HTTP health check server const healthPort = process.env.HEALTH_PORT || 8044; const ADMIN_TOKEN = process.env.DISCORD_ADMIN_TOKEN || "aethex-bot-admin"; // Helper to check admin authentication const checkAdminAuth = (req) => { const authHeader = req.headers.authorization; return authHeader === `Bearer ${ADMIN_TOKEN}`; }; http .createServer((req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.setHeader("Content-Type", "application/json"); if (req.method === "OPTIONS") { res.writeHead(200); res.end(); return; } if (req.url === "/health") { res.writeHead(200); res.end( JSON.stringify({ status: "online", guilds: client.guilds.cache.size, commands: client.commands.size, uptime: Math.floor(process.uptime()), timestamp: new Date().toISOString(), }), ); return; } // GET /bot-status - Comprehensive bot status for management panel (requires auth) if (req.url === "/bot-status") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } const channelId = getFeedChannelId(); const guilds = client.guilds.cache.map((guild) => ({ id: guild.id, name: guild.name, memberCount: guild.memberCount, icon: guild.iconURL(), })); res.writeHead(200); res.end( JSON.stringify({ status: client.isReady() ? "online" : "offline", bot: { tag: client.user?.tag || "Not logged in", id: client.user?.id, avatar: client.user?.displayAvatarURL(), }, guilds: guilds, guildCount: client.guilds.cache.size, commands: Array.from(client.commands.keys()), commandCount: client.commands.size, uptime: Math.floor(process.uptime()), feedBridge: { enabled: !!channelId, channelId: channelId, }, timestamp: new Date().toISOString(), }), ); return; } // GET /linked-users - Get all Discord-linked users (requires auth, sanitizes PII) if (req.url === "/linked-users") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } (async () => { try { const { data: links, error } = await supabase .from("discord_links") .select("discord_id, user_id, primary_arm, created_at") .order("created_at", { ascending: false }) .limit(50); if (error) throw error; const enrichedLinks = await Promise.all( (links || []).map(async (link) => { const { data: profile } = await supabase .from("user_profiles") .select("username, avatar_url") .eq("id", link.user_id) .single(); return { discord_id: link.discord_id.slice(0, 6) + "***", user_id: link.user_id.slice(0, 8) + "...", primary_arm: link.primary_arm, created_at: link.created_at, profile: profile ? { username: profile.username, avatar_url: profile.avatar_url, } : null, }; }) ); res.writeHead(200); res.end(JSON.stringify({ success: true, links: enrichedLinks, count: enrichedLinks.length })); } catch (error) { res.writeHead(500); res.end(JSON.stringify({ success: false, error: error.message })); } })(); return; } // GET /command-stats - Get command usage statistics (requires auth) if (req.url === "/command-stats") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } (async () => { try { const stats = { commands: COMMANDS_TO_REGISTER.map((cmd) => ({ name: cmd.name, description: cmd.description, options: cmd.options?.length || 0, })), totalCommands: COMMANDS_TO_REGISTER.length, }; res.writeHead(200); res.end(JSON.stringify({ success: true, stats })); } catch (error) { res.writeHead(500); res.end(JSON.stringify({ success: false, error: error.message })); } })(); return; } // GET /feed-stats - Get feed bridge statistics (requires auth) if (req.url === "/feed-stats") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } (async () => { try { const { count: totalPosts } = await supabase .from("community_posts") .select("*", { count: "exact", head: true }); const { count: discordPosts } = await supabase .from("community_posts") .select("*", { count: "exact", head: true }) .eq("source", "discord"); const { count: websitePosts } = await supabase .from("community_posts") .select("*", { count: "exact", head: true }) .or("source.is.null,source.neq.discord"); const { data: recentPosts } = await supabase .from("community_posts") .select("id, content, source, created_at") .order("created_at", { ascending: false }) .limit(10); res.writeHead(200); res.end( JSON.stringify({ success: true, stats: { totalPosts: totalPosts || 0, discordPosts: discordPosts || 0, websitePosts: websitePosts || 0, recentPosts: (recentPosts || []).map(p => ({ id: p.id, content: p.content?.slice(0, 100) + (p.content?.length > 100 ? "..." : ""), source: p.source, created_at: p.created_at, })), }, }) ); } catch (error) { res.writeHead(500); res.end(JSON.stringify({ success: false, error: error.message })); } })(); return; } // POST /send-to-discord - Send a post from AeThex to Discord channel if (req.url === "/send-to-discord" && req.method === "POST") { let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", async () => { try { // Simple auth check const authHeader = req.headers.authorization; const expectedToken = process.env.DISCORD_BRIDGE_TOKEN || "aethex-bridge"; if (authHeader !== `Bearer ${expectedToken}`) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized" })); return; } const post = JSON.parse(body); console.log("[API] Received post to send to Discord:", post.id); const result = await sendPostToDiscord(post, post.author); res.writeHead(result.success ? 200 : 500); res.end(JSON.stringify(result)); } catch (error) { console.error("[API] Error processing send-to-discord:", error); res.writeHead(500); res.end(JSON.stringify({ error: error.message })); } }); return; } // GET /bridge-status - Check if bridge is configured if (req.url === "/bridge-status") { const channelId = getFeedChannelId(); res.writeHead(200); res.end( JSON.stringify({ enabled: !!channelId, channelId: channelId, botReady: client.isReady(), }), ); return; } if (req.url === "/register-commands") { if (req.method === "GET") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } // Show HTML form with button res.writeHead(200, { "Content-Type": "text/html" }); res.end(`
Click the button below to register all Discord slash commands (/verify, /set-realm, /profile, /unlink, /verify-role)