const { Client, GatewayIntentBits, REST, Routes, Collection, EmbedBuilder, ChannelType, PermissionFlagsBits, } = require("discord.js"); const { createClient } = require("@supabase/supabase-js"); const http = require("http"); const fs = require("fs"); const path = require("path"); require("dotenv").config(); // ============================================================================= // ENVIRONMENT VALIDATION (Modified: Supabase now optional) // ============================================================================= const token = process.env.DISCORD_BOT_TOKEN; const clientId = process.env.DISCORD_CLIENT_ID; if (!token) { console.error("Missing DISCORD_BOT_TOKEN environment variable"); process.exit(1); } if (!clientId) { console.error("Missing DISCORD_CLIENT_ID environment variable"); process.exit(1); } console.log("[Token] Bot token loaded (length: " + token.length + " chars)"); // ============================================================================= // DISCORD CLIENT SETUP (Modified: Added intents for Sentinel) // ============================================================================= const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildModeration, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, ], }); // ============================================================================= // SUPABASE SETUP (Modified: Now optional) // ============================================================================= let supabase = null; if (process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE) { supabase = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE ); console.log("Supabase connected"); } else { console.log("Supabase not configured - community features will be limited"); } // ============================================================================= // SENTINEL: HEAT TRACKING SYSTEM (New) // ============================================================================= const heatMap = new Map(); const HEAT_THRESHOLD = 3; const HEAT_WINDOW_MS = 10000; const whitelistedUsers = (process.env.WHITELISTED_USERS || '').split(',').filter(Boolean); function addHeat(userId, action) { if (whitelistedUsers.includes(userId)) return 0; const now = Date.now(); if (!heatMap.has(userId)) { heatMap.set(userId, []); } const userEvents = heatMap.get(userId); userEvents.push({ action, timestamp: now }); const recentEvents = userEvents.filter(e => now - e.timestamp < HEAT_WINDOW_MS); heatMap.set(userId, recentEvents); return recentEvents.length; } function getHeat(userId) { const now = Date.now(); const userEvents = heatMap.get(userId) || []; return userEvents.filter(e => now - e.timestamp < HEAT_WINDOW_MS).length; } client.heatMap = heatMap; client.addHeat = addHeat; client.getHeat = getHeat; client.HEAT_THRESHOLD = HEAT_THRESHOLD; // ============================================================================= // SENTINEL: FEDERATION MAPPINGS (New) // ============================================================================= const federationMappings = new Map(); client.federationMappings = federationMappings; const REALM_GUILDS = { hub: process.env.HUB_GUILD_ID, labs: process.env.LABS_GUILD_ID, gameforge: process.env.GAMEFORGE_GUILD_ID, corp: process.env.CORP_GUILD_ID, foundation: process.env.FOUNDATION_GUILD_ID, }; client.REALM_GUILDS = REALM_GUILDS; // ============================================================================= // GUILD WHITELIST SYSTEM // ============================================================================= const WHITELISTED_GUILDS = [ '373713073594302464', // AeThex | Corporation '515711457946632232', // AeThex (Main) '525971009313046529', // AeThex | Nexus '1245619208805416970', // AeThex | GameForge '1275962459596783686', // AeThex | LABS '1284290638564687925', // AeThex | DevOps '1338564560277344287', // AeThex | Foundation ...(process.env.EXTRA_WHITELISTED_GUILDS || '').split(',').filter(Boolean), ]; client.WHITELISTED_GUILDS = WHITELISTED_GUILDS; client.on('guildCreate', async (guild) => { if (!WHITELISTED_GUILDS.includes(guild.id)) { console.log(`[Whitelist] Unauthorized server detected: ${guild.name} (${guild.id}) - Leaving...`); try { const owner = await guild.fetchOwner(); await owner.send(`Your server "${guild.name}" is not authorized to use AeThex Bot. The bot has automatically left. Contact the AeThex team if you believe this is an error.`).catch(() => {}); } catch (e) {} await guild.leave(); console.log(`[Whitelist] Left unauthorized server: ${guild.name}`); return; } console.log(`[Whitelist] Joined authorized server: ${guild.name} (${guild.id})`); }); // ============================================================================= // SENTINEL: TICKET TRACKING (New) // ============================================================================= const activeTickets = new Map(); client.activeTickets = activeTickets; // ============================================================================= // SENTINEL: ALERT SYSTEM (New) // ============================================================================= let alertChannelId = process.env.ALERT_CHANNEL_ID; client.alertChannelId = alertChannelId; async function sendAlert(message, embed = null) { if (!alertChannelId) return; try { const channel = await client.channels.fetch(alertChannelId); if (channel) { if (embed) { await channel.send({ content: message, embeds: [embed] }); } else { await channel.send(message); } } } catch (err) { console.error("Failed to send alert:", err.message); } } client.sendAlert = sendAlert; // ============================================================================= // COMMAND LOADING // ============================================================================= client.commands = new Collection(); const commandsPath = path.join(__dirname, "commands"); if (fs.existsSync(commandsPath)) { 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}`); } } } // ============================================================================= // EVENT LOADING // ============================================================================= 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: ${event.name}`); } } } // ============================================================================= // SENTINEL LISTENER LOADING (New) // ============================================================================= const sentinelPath = path.join(__dirname, "listeners", "sentinel"); if (fs.existsSync(sentinelPath)) { const sentinelFiles = fs.readdirSync(sentinelPath).filter((file) => file.endsWith(".js")); for (const file of sentinelFiles) { const filePath = path.join(sentinelPath, file); const listener = require(filePath); if ("name" in listener && "execute" in listener) { client.on(listener.name, (...args) => listener.execute(...args, client)); console.log(`Loaded sentinel listener: ${listener.name}`); } } } // ============================================================================= // FEED SYNC SETUP (Modified: Guard for missing Supabase) // ============================================================================= let feedSyncModule = null; let setupFeedListener = null; let sendPostToDiscord = null; let getFeedChannelId = () => null; try { feedSyncModule = require("./listeners/feedSync"); setupFeedListener = feedSyncModule.setupFeedListener; sendPostToDiscord = feedSyncModule.sendPostToDiscord; getFeedChannelId = feedSyncModule.getFeedChannelId; } catch (e) { console.log("Feed sync module not available"); } // ============================================================================= // INTERACTION HANDLER (Modified: Added button handling for tickets) // ============================================================================= client.on("interactionCreate", async (interaction) => { console.log(`[Interaction] Received: type=${interaction.type}, commandName=${interaction.commandName || 'N/A'}, user=${interaction.user?.tag}, guild=${interaction.guildId}`); if (interaction.isChatInputCommand()) { console.log(`[Command] Processing: ${interaction.commandName}`); const command = client.commands.get(interaction.commandName); if (!command) { console.warn(`No command matching ${interaction.commandName} was found.`); return; } try { console.log(`[Command] Executing: ${interaction.commandName}`); await command.execute(interaction, supabase, client); console.log(`[Command] Completed: ${interaction.commandName}`); } 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 }); } } } if (interaction.isButton()) { const [action, ...params] = interaction.customId.split('_'); if (action === 'ticket') { const ticketAction = params[0]; if (ticketAction === 'close') { try { const channel = interaction.channel; if (channel && channel.type === ChannelType.GuildText) { await interaction.reply({ content: 'Closing ticket...', ephemeral: true }); setTimeout(() => channel.delete().catch(console.error), 3000); } } catch (err) { console.error('Ticket close error:', err); } } } } }); // ============================================================================= // COMMANDS FOR REGISTRATION (Uses actual command file definitions) // ============================================================================= function getCommandsToRegister() { return Array.from(client.commands.values()).map(cmd => cmd.data.toJSON()); } // ============================================================================= // COMMAND REGISTRATION FUNCTION // ============================================================================= async function registerDiscordCommands() { try { const rest = new REST({ version: "10" }).setToken( process.env.DISCORD_BOT_TOKEN, ); const commandsToRegister = getCommandsToRegister(); console.log( `Registering ${commandsToRegister.length} slash commands...`, ); try { const data = await rest.put( Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), { body: commandsToRegister }, ); console.log(`Successfully registered ${data.length} slash commands`); return { success: true, count: data.length, results: null }; } catch (bulkError) { 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 commandsToRegister) { 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 }; } } // ============================================================================= // HTTP SERVER (Modified: Added Sentinel stats to health endpoint) // ============================================================================= const healthPort = process.env.HEALTH_PORT || 8080; const ADMIN_TOKEN = process.env.DISCORD_ADMIN_TOKEN || "aethex-bot-admin"; 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()), heatMapSize: heatMap.size, supabaseConnected: !!supabase, timestamp: new Date().toISOString(), }), ); return; } if (req.url === "/stats") { const guildStats = client.guilds.cache.map(g => ({ id: g.id, name: g.name, memberCount: g.memberCount, })); res.writeHead(200); res.end(JSON.stringify({ guilds: guildStats, totalMembers: guildStats.reduce((sum, g) => sum + g.memberCount, 0), uptime: Math.floor(process.uptime()), activeTickets: activeTickets.size, heatEvents: heatMap.size, })); return; } 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, }, sentinel: { heatMapSize: heatMap.size, activeTickets: activeTickets.size, federationMappings: federationMappings.size, }, supabaseConnected: !!supabase, timestamp: new Date().toISOString(), }), ); return; } if (req.url === "/linked-users") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } if (!supabase) { res.writeHead(200); res.end(JSON.stringify({ success: true, links: [], count: 0, message: "Supabase not configured" })); 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; } if (req.url === "/command-stats") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } const cmds = getCommandsToRegister(); const stats = { commands: cmds.map((cmd) => ({ name: cmd.name, description: cmd.description, options: cmd.options?.length || 0, })), totalCommands: cmds.length, }; res.writeHead(200); res.end(JSON.stringify({ success: true, stats })); return; } if (req.url === "/feed-stats") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } if (!supabase) { res.writeHead(200); res.end(JSON.stringify({ success: true, stats: { totalPosts: 0, discordPosts: 0, websitePosts: 0, recentPosts: [] }, message: "Supabase not configured" })); 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; } if (req.url === "/send-to-discord" && req.method === "POST") { let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", async () => { try { 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); if (sendPostToDiscord) { const result = await sendPostToDiscord(post, post.author); res.writeHead(result.success ? 200 : 500); res.end(JSON.stringify(result)); } else { res.writeHead(500); res.end(JSON.stringify({ error: "Feed sync not available" })); } } catch (error) { console.error("[API] Error processing send-to-discord:", error); res.writeHead(500); res.end(JSON.stringify({ error: error.message })); } }); return; } 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.startsWith("/leave-guild/") && req.method === "POST") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } const guildId = req.url.split("/leave-guild/")[1]; (async () => { try { const guild = client.guilds.cache.get(guildId); if (!guild) { res.writeHead(404); res.end(JSON.stringify({ error: "Guild not found" })); return; } const guildName = guild.name; await guild.leave(); console.log(`[Admin] Left guild: ${guildName} (${guildId})`); res.writeHead(200); res.end(JSON.stringify({ success: true, message: `Left guild: ${guildName}` })); } catch (error) { res.writeHead(500); res.end(JSON.stringify({ error: error.message })); } })(); return; } if (req.url.startsWith("/create-invite/") && req.method === "GET") { if (!checkAdminAuth(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } const guildId = req.url.split("/create-invite/")[1]; (async () => { try { const guild = client.guilds.cache.get(guildId); if (!guild) { res.writeHead(404); res.end(JSON.stringify({ error: "Guild not found" })); return; } const channel = guild.channels.cache.find(ch => ch.type === ChannelType.GuildText && ch.permissionsFor(guild.members.me).has('CreateInstantInvite')); if (!channel) { res.writeHead(403); res.end(JSON.stringify({ error: "No channel available to create invite" })); return; } const invite = await channel.createInvite({ maxAge: 86400, maxUses: 1 }); res.writeHead(200); res.end(JSON.stringify({ success: true, invite: invite.url, guild: guild.name, expiresIn: "24 hours" })); } catch (error) { res.writeHead(500); res.end(JSON.stringify({ error: error.message })); } })(); 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; } res.writeHead(200, { "Content-Type": "text/html" }); res.end(`
Click to register all ${client.commands.size} slash commands