Integrates Supabase for persistent storage of federation mappings and active tickets, along with adding new commands for announcements, audit logs, and polls. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 110a0afc-77c3-48ac-afca-8e969438dafc Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/hHBt1No Replit-Helium-Checkpoint-Created: true
1055 lines
35 KiB
JavaScript
1055 lines
35 KiB
JavaScript
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");
|
|
|
|
// Dashboard HTML path
|
|
const dashboardPath = path.join(__dirname, "public", "dashboard.html");
|
|
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;
|
|
|
|
async function loadFederationMappings() {
|
|
if (!supabase) return;
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('federation_mappings')
|
|
.select('*');
|
|
if (error) throw error;
|
|
for (const mapping of data || []) {
|
|
federationMappings.set(mapping.role_id, {
|
|
name: mapping.role_name,
|
|
guildId: mapping.guild_id,
|
|
guildName: mapping.guild_name,
|
|
linkedAt: new Date(mapping.linked_at).getTime(),
|
|
});
|
|
}
|
|
console.log(`[Federation] Loaded ${federationMappings.size} mappings from database`);
|
|
} catch (e) {
|
|
console.warn('[Federation] Could not load mappings:', e.message);
|
|
}
|
|
}
|
|
|
|
async function saveFederationMapping(roleId, data) {
|
|
if (!supabase) return;
|
|
try {
|
|
await supabase.from('federation_mappings').upsert({
|
|
role_id: roleId,
|
|
role_name: data.name,
|
|
guild_id: data.guildId,
|
|
guild_name: data.guildName,
|
|
linked_at: new Date(data.linkedAt).toISOString(),
|
|
});
|
|
} catch (e) {
|
|
console.warn('[Federation] Could not save mapping:', e.message);
|
|
}
|
|
}
|
|
|
|
async function deleteFederationMapping(roleId) {
|
|
if (!supabase) return;
|
|
try {
|
|
await supabase.from('federation_mappings').delete().eq('role_id', roleId);
|
|
} catch (e) {
|
|
console.warn('[Federation] Could not delete mapping:', e.message);
|
|
}
|
|
}
|
|
|
|
client.saveFederationMapping = saveFederationMapping;
|
|
client.deleteFederationMapping = deleteFederationMapping;
|
|
|
|
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;
|
|
|
|
async function loadActiveTickets() {
|
|
if (!supabase) return;
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('tickets')
|
|
.select('*')
|
|
.eq('status', 'open');
|
|
if (error) throw error;
|
|
for (const ticket of data || []) {
|
|
activeTickets.set(ticket.channel_id, {
|
|
odId: ticket.id,
|
|
userId: ticket.user_id,
|
|
guildId: ticket.guild_id,
|
|
reason: ticket.reason,
|
|
createdAt: new Date(ticket.created_at).getTime(),
|
|
});
|
|
}
|
|
console.log(`[Tickets] Loaded ${activeTickets.size} active tickets from database`);
|
|
} catch (e) {
|
|
console.warn('[Tickets] Could not load tickets:', e.message);
|
|
}
|
|
}
|
|
|
|
async function saveTicket(channelId, data) {
|
|
if (!supabase) return;
|
|
try {
|
|
await supabase.from('tickets').insert({
|
|
channel_id: channelId,
|
|
user_id: data.userId,
|
|
guild_id: data.guildId,
|
|
reason: data.reason,
|
|
status: 'open',
|
|
});
|
|
} catch (e) {
|
|
console.warn('[Tickets] Could not save ticket:', e.message);
|
|
}
|
|
}
|
|
|
|
async function closeTicket(channelId) {
|
|
if (!supabase) return;
|
|
try {
|
|
await supabase
|
|
.from('tickets')
|
|
.update({ status: 'closed', closed_at: new Date().toISOString() })
|
|
.eq('channel_id', channelId);
|
|
} catch (e) {
|
|
console.warn('[Tickets] Could not close ticket:', e.message);
|
|
}
|
|
}
|
|
|
|
client.saveTicket = saveTicket;
|
|
client.closeTicket = closeTicket;
|
|
|
|
// =============================================================================
|
|
// 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);
|
|
|
|
try {
|
|
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 }).catch(() => {});
|
|
} else {
|
|
await interaction.reply({ embeds: [errorEmbed], ephemeral: true }).catch(() => {});
|
|
}
|
|
} catch (replyError) {
|
|
console.error("Failed to send error response:", replyError.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 });
|
|
activeTickets.delete(channel.id);
|
|
await closeTicket(channel.id);
|
|
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 === "/" || req.url === "/dashboard") {
|
|
res.setHeader("Content-Type", "text/html");
|
|
try {
|
|
const html = fs.readFileSync(dashboardPath, "utf8");
|
|
res.writeHead(200);
|
|
res.end(html);
|
|
} catch (e) {
|
|
res.writeHead(404);
|
|
res.end("<h1>Dashboard not found</h1>");
|
|
}
|
|
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(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Register Discord Commands</title>
|
|
<style>
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
}
|
|
.container {
|
|
background: white;
|
|
padding: 40px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
|
text-align: center;
|
|
max-width: 500px;
|
|
}
|
|
h1 { color: #333; margin-bottom: 20px; }
|
|
p { color: #666; margin-bottom: 30px; }
|
|
button {
|
|
background: #667eea;
|
|
color: white;
|
|
border: none;
|
|
padding: 12px 30px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
transition: background 0.3s;
|
|
}
|
|
button:hover { background: #764ba2; }
|
|
button:disabled { background: #ccc; cursor: not-allowed; }
|
|
#result { margin-top: 30px; padding: 20px; border-radius: 5px; display: none; }
|
|
#result.success { background: #d4edda; color: #155724; display: block; }
|
|
#result.error { background: #f8d7da; color: #721c24; display: block; }
|
|
#loading { display: none; color: #667eea; font-weight: bold; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Discord Commands Registration</h1>
|
|
<p>Click to register all ${client.commands.size} slash commands</p>
|
|
<button id="registerBtn" onclick="registerCommands()">Register Commands</button>
|
|
<div id="loading">Registering... please wait...</div>
|
|
<div id="result"></div>
|
|
</div>
|
|
<script>
|
|
async function registerCommands() {
|
|
const btn = document.getElementById('registerBtn');
|
|
const loading = document.getElementById('loading');
|
|
const result = document.getElementById('result');
|
|
btn.disabled = true;
|
|
loading.style.display = 'block';
|
|
result.style.display = 'none';
|
|
try {
|
|
const response = await fetch('/register-commands', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ${ADMIN_TOKEN}', 'Content-Type': 'application/json' }
|
|
});
|
|
const data = await response.json();
|
|
loading.style.display = 'none';
|
|
if (response.ok && data.success) {
|
|
result.className = 'success';
|
|
result.innerHTML = '<h3>Success!</h3><p>Registered ' + data.count + ' commands</p>';
|
|
} else {
|
|
result.className = 'error';
|
|
result.innerHTML = '<h3>Error</h3><p>' + (data.error || 'Failed') + '</p>';
|
|
}
|
|
} catch (error) {
|
|
loading.style.display = 'none';
|
|
result.className = 'error';
|
|
result.innerHTML = '<h3>Error</h3><p>' + error.message + '</p>';
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST") {
|
|
if (!checkAdminAuth(req)) {
|
|
res.writeHead(401);
|
|
res.end(JSON.stringify({ error: "Unauthorized - Admin token required" }));
|
|
return;
|
|
}
|
|
|
|
registerDiscordCommands().then((result) => {
|
|
if (result.success) {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify(result));
|
|
} else {
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify(result));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
})
|
|
.listen(healthPort, () => {
|
|
console.log(`Health check server running on port ${healthPort}`);
|
|
console.log(`Register commands at: POST http://localhost:${healthPort}/register-commands`);
|
|
});
|
|
|
|
// =============================================================================
|
|
// BOT LOGIN AND READY
|
|
// =============================================================================
|
|
|
|
client.login(token).catch((error) => {
|
|
console.error("Failed to login to Discord");
|
|
console.error(`Error Code: ${error.code}`);
|
|
console.error(`Error Message: ${error.message}`);
|
|
|
|
if (error.code === "TokenInvalid") {
|
|
console.error("\nDISCORD_BOT_TOKEN is invalid!");
|
|
console.error("Get a new token from: https://discord.com/developers/applications");
|
|
}
|
|
|
|
process.exit(1);
|
|
});
|
|
|
|
client.once("clientReady", async () => {
|
|
console.log(`Bot logged in as ${client.user.tag}`);
|
|
console.log(`Bot ID: ${client.user.id}`);
|
|
console.log(`CLIENT_ID from env: ${process.env.DISCORD_CLIENT_ID}`);
|
|
console.log(`IDs match: ${client.user.id === process.env.DISCORD_CLIENT_ID}`);
|
|
console.log(`Watching ${client.guilds.cache.size} server(s)`);
|
|
|
|
// Load persisted data from Supabase
|
|
await loadFederationMappings();
|
|
await loadActiveTickets();
|
|
|
|
// Auto-register commands on startup
|
|
console.log("Registering slash commands with Discord...");
|
|
const regResult = await registerDiscordCommands();
|
|
if (regResult.success) {
|
|
console.log(`Successfully registered ${regResult.count} commands`);
|
|
} else {
|
|
console.error("Failed to register commands:", regResult.error);
|
|
}
|
|
|
|
client.user.setActivity("Protecting the Federation", { type: 3 });
|
|
|
|
if (setupFeedListener && supabase) {
|
|
setupFeedListener(client);
|
|
}
|
|
|
|
sendAlert(`AeThex Bot is now online! Watching ${client.guilds.cache.size} servers.`);
|
|
});
|
|
|
|
// =============================================================================
|
|
// ERROR HANDLING
|
|
// =============================================================================
|
|
|
|
process.on("unhandledRejection", (error) => {
|
|
console.error("Unhandled Promise Rejection:", error);
|
|
});
|
|
|
|
process.on("uncaughtException", (error) => {
|
|
console.error("Uncaught Exception:", error);
|
|
process.exit(1);
|
|
});
|
|
|
|
module.exports = client;
|