diff --git a/.replit b/.replit index 5b6f647..f9aee22 100644 --- a/.replit +++ b/.replit @@ -22,6 +22,10 @@ externalPort = 80 localPort = 8080 externalPort = 8080 +[[ports]] +localPort = 33701 +externalPort = 3000 + [workflows] runButton = "Project" diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index d8833f0..e18d28b 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -12,6 +12,9 @@ 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(); // ============================================================================= @@ -107,6 +110,54 @@ client.HEAT_THRESHOLD = HEAT_THRESHOLD; 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, @@ -153,6 +204,59 @@ client.on('guildCreate', async (guild) => { 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) // ============================================================================= @@ -269,16 +373,21 @@ client.on("interactionCreate", async (interaction) => { 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 }); + 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); } } } @@ -293,6 +402,8 @@ client.on("interactionCreate", async (interaction) => { 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) { @@ -417,6 +528,19 @@ http 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("

Dashboard not found

"); + } + return; + } + if (req.url === "/health") { res.writeHead(200); res.end( @@ -886,13 +1010,17 @@ client.login(token).catch((error) => { process.exit(1); }); -client.once("ready", async () => { +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(); diff --git a/aethex-bot/commands/announce.js b/aethex-bot/commands/announce.js new file mode 100644 index 0000000..9f8d8f9 --- /dev/null +++ b/aethex-bot/commands/announce.js @@ -0,0 +1,111 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('announce') + .setDescription('Send an announcement to all realms') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addStringOption(option => + option.setName('title') + .setDescription('Announcement title') + .setRequired(true) + .setMaxLength(256) + ) + .addStringOption(option => + option.setName('message') + .setDescription('Announcement message') + .setRequired(true) + .setMaxLength(2000) + ) + .addStringOption(option => + option.setName('color') + .setDescription('Embed color') + .setRequired(false) + .addChoices( + { name: 'Purple (Default)', value: '7c3aed' }, + { name: 'Green (Success)', value: '00ff00' }, + { name: 'Red (Alert)', value: 'ff0000' }, + { name: 'Blue (Info)', value: '3b82f6' }, + { name: 'Yellow (Warning)', value: 'fbbf24' } + ) + ) + .addBooleanOption(option => + option.setName('ping') + .setDescription('Ping @everyone with this announcement') + .setRequired(false) + ), + + async execute(interaction, supabase, client) { + await interaction.deferReply({ ephemeral: true }); + + const title = interaction.options.getString('title'); + const message = interaction.options.getString('message'); + const color = parseInt(interaction.options.getString('color') || '7c3aed', 16); + const ping = interaction.options.getBoolean('ping') || false; + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(title) + .setDescription(message) + .setFooter({ text: `Announced by ${interaction.user.tag}` }) + .setTimestamp(); + + const results = []; + const REALM_GUILDS = client.REALM_GUILDS; + + for (const [realm, guildId] of Object.entries(REALM_GUILDS)) { + if (!guildId) continue; + + const guild = client.guilds.cache.get(guildId); + if (!guild) { + results.push({ realm, status: 'offline' }); + continue; + } + + try { + const announcementChannel = guild.channels.cache.find( + c => c.isTextBased() && !c.isThread() && !c.isVoiceBased() && + (c.name.includes('announcement') || c.name.includes('general')) + ); + + if (announcementChannel && announcementChannel.isTextBased()) { + const content = ping ? '@everyone' : null; + await announcementChannel.send({ content, embeds: [embed] }); + results.push({ realm, status: 'sent', channel: announcementChannel.name }); + } else { + results.push({ realm, status: 'no_channel' }); + } + } catch (error) { + console.error(`Announce error for ${realm}:`, error); + results.push({ realm, status: 'error', error: error.message }); + } + } + + if (supabase) { + try { + await supabase.from('audit_logs').insert({ + action: 'announce', + user_id: interaction.user.id, + username: interaction.user.tag, + guild_id: interaction.guildId, + details: { title, message, results }, + }); + } catch (e) { + console.warn('Failed to log announcement:', e.message); + } + } + + const resultText = results.map(r => { + const emoji = r.status === 'sent' ? '✅' : r.status === 'offline' ? '⚫' : '❌'; + return `${emoji} **${r.realm}**: ${r.status}${r.channel ? ` (#${r.channel})` : ''}`; + }).join('\n'); + + const resultEmbed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('Announcement Results') + .setDescription(resultText || 'No realms configured') + .setTimestamp(); + + await interaction.editReply({ embeds: [resultEmbed] }); + }, +}; diff --git a/aethex-bot/commands/auditlog.js b/aethex-bot/commands/auditlog.js new file mode 100644 index 0000000..2285efb --- /dev/null +++ b/aethex-bot/commands/auditlog.js @@ -0,0 +1,105 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('auditlog') + .setDescription('View admin action audit logs') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(subcommand => + subcommand + .setName('view') + .setDescription('View recent audit logs') + .addIntegerOption(option => + option.setName('limit') + .setDescription('Number of logs to show (default: 10)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(50) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('user') + .setDescription('View logs for a specific user') + .addUserOption(option => + option.setName('target') + .setDescription('User to lookup') + .setRequired(true) + ) + ), + + async execute(interaction, supabase, client) { + if (!supabase) { + return interaction.reply({ content: 'Audit logging requires Supabase.', ephemeral: true }); + } + + await interaction.deferReply({ ephemeral: true }); + const subcommand = interaction.options.getSubcommand(); + + try { + let logs = []; + + if (subcommand === 'view') { + const limit = interaction.options.getInteger('limit') || 10; + + const { data, error } = await supabase + .from('audit_logs') + .select('*') + .eq('guild_id', interaction.guildId) + .order('created_at', { ascending: false }) + .limit(limit); + + if (error) throw error; + logs = data || []; + } + + if (subcommand === 'user') { + const target = interaction.options.getUser('target'); + + const { data, error } = await supabase + .from('audit_logs') + .select('*') + .eq('user_id', target.id) + .order('created_at', { ascending: false }) + .limit(20); + + if (error) throw error; + logs = data || []; + } + + if (logs.length === 0) { + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('Audit Logs') + .setDescription('No audit logs found.') + .setTimestamp(); + + return await interaction.editReply({ embeds: [embed] }); + } + + const logText = logs.map(log => { + const time = new Date(log.created_at); + const timeStr = ``; + return `**${log.action}** by ${log.username} ${timeStr}`; + }).join('\n'); + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('Audit Logs') + .setDescription(logText) + .setFooter({ text: `Showing ${logs.length} log(s)` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Auditlog command error:', error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Error') + .setDescription('Failed to fetch audit logs.') + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/aethex-bot/commands/federation.js b/aethex-bot/commands/federation.js index 1d07978..0f2d544 100644 --- a/aethex-bot/commands/federation.js +++ b/aethex-bot/commands/federation.js @@ -37,12 +37,17 @@ module.exports = { if (subcommand === 'link') { const role = interaction.options.getRole('role'); - client.federationMappings.set(role.id, { + const mappingData = { name: role.name, guildId: interaction.guildId, guildName: interaction.guild.name, linkedAt: Date.now(), - }); + }; + client.federationMappings.set(role.id, mappingData); + + if (client.saveFederationMapping) { + await client.saveFederationMapping(role.id, mappingData); + } const embed = new EmbedBuilder() .setColor(0x00ff00) @@ -63,6 +68,10 @@ module.exports = { if (client.federationMappings.has(role.id)) { client.federationMappings.delete(role.id); + if (client.deleteFederationMapping) { + await client.deleteFederationMapping(role.id); + } + const embed = new EmbedBuilder() .setColor(0xff6600) .setTitle('Role Unlinked') diff --git a/aethex-bot/commands/poll.js b/aethex-bot/commands/poll.js new file mode 100644 index 0000000..9045db1 --- /dev/null +++ b/aethex-bot/commands/poll.js @@ -0,0 +1,91 @@ +const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); + +const POLL_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; + +module.exports = { + data: new SlashCommandBuilder() + .setName('poll') + .setDescription('Create a community poll') + .addStringOption(option => + option.setName('question') + .setDescription('The poll question') + .setRequired(true) + .setMaxLength(256) + ) + .addStringOption(option => + option.setName('options') + .setDescription('Poll options separated by | (e.g., "Yes | No | Maybe")') + .setRequired(true) + .setMaxLength(1000) + ) + .addIntegerOption(option => + option.setName('duration') + .setDescription('Poll duration in hours (default: 24)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(168) + ), + + async execute(interaction, supabase, client) { + const question = interaction.options.getString('question'); + const optionsRaw = interaction.options.getString('options'); + const duration = interaction.options.getInteger('duration') || 24; + + const options = optionsRaw.split('|').map(o => o.trim()).filter(o => o.length > 0); + + if (options.length < 2) { + return interaction.reply({ + content: 'Please provide at least 2 options separated by |', + ephemeral: true + }); + } + + if (options.length > 10) { + return interaction.reply({ + content: 'Maximum 10 options allowed', + ephemeral: true + }); + } + + const endsAt = new Date(Date.now() + duration * 60 * 60 * 1000); + const optionsText = options.map((opt, i) => `${POLL_EMOJIS[i]} ${opt}`).join('\n'); + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle(`📊 ${question}`) + .setDescription(optionsText) + .addFields( + { name: 'Created by', value: interaction.user.tag, inline: true }, + { name: 'Ends', value: ``, inline: true }, + { name: 'Options', value: `${options.length}`, inline: true } + ) + .setFooter({ text: 'React to vote!' }) + .setTimestamp(); + + const message = await interaction.reply({ embeds: [embed], fetchReply: true }); + + for (let i = 0; i < options.length; i++) { + try { + await message.react(POLL_EMOJIS[i]); + } catch (e) { + console.error('Failed to add reaction:', e.message); + } + } + + if (supabase) { + try { + await supabase.from('polls').insert({ + message_id: message.id, + channel_id: interaction.channelId, + guild_id: interaction.guildId, + question: question, + options: JSON.stringify(options), + created_by: interaction.user.id, + ends_at: endsAt.toISOString(), + }); + } catch (e) { + console.warn('Failed to save poll:', e.message); + } + } + }, +}; diff --git a/aethex-bot/commands/stats.js b/aethex-bot/commands/stats.js index eef0a18..b51b6b8 100644 --- a/aethex-bot/commands/stats.js +++ b/aethex-bot/commands/stats.js @@ -12,11 +12,15 @@ module.exports = { await interaction.deferReply({ ephemeral: true }); try { - const { data: link } = await supabase + const { data: link, error: linkError } = await supabase .from("discord_links") - .select("user_id, primary_arm, created_at") + .select("user_id, primary_arm, linked_at") .eq("discord_id", interaction.user.id) .single(); + + if (linkError) { + console.error("Stats link query error:", linkError); + } if (!link) { const embed = new EmbedBuilder() @@ -64,7 +68,7 @@ module.exports = { devlink: "💻", }; - const linkedDate = new Date(link.created_at); + const linkedDate = new Date(link.linked_at); const daysSinceLinked = Math.floor( (Date.now() - linkedDate.getTime()) / (1000 * 60 * 60 * 24) ); diff --git a/aethex-bot/commands/ticket.js b/aethex-bot/commands/ticket.js index d0cf286..2bb70d4 100644 --- a/aethex-bot/commands/ticket.js +++ b/aethex-bot/commands/ticket.js @@ -57,12 +57,17 @@ module.exports = { ], }); - client.activeTickets.set(ticketChannel.id, { + const ticketData = { userId: interaction.user.id, guildId: interaction.guildId, reason: reason, createdAt: Date.now(), - }); + }; + client.activeTickets.set(ticketChannel.id, ticketData); + + if (client.saveTicket) { + await client.saveTicket(ticketChannel.id, ticketData); + } const closeButton = new ActionRowBuilder().addComponents( new ButtonBuilder() @@ -127,6 +132,10 @@ module.exports = { client.activeTickets.delete(interaction.channelId); + if (client.closeTicket) { + await client.closeTicket(interaction.channelId); + } + setTimeout(async () => { try { await interaction.channel.delete(); diff --git a/aethex-bot/public/dashboard.html b/aethex-bot/public/dashboard.html new file mode 100644 index 0000000..760d542 --- /dev/null +++ b/aethex-bot/public/dashboard.html @@ -0,0 +1,183 @@ + + + + + + AeThex Bot Dashboard + + + +
+
+ +
+

AeThex Bot Dashboard

+ Loading... +
+ Offline + +
+ +
+
+

Servers

+
-
+
+
+

Total Members

+
-
+
+
+

Commands

+
-
+
+
+

Uptime

+
-
+
+
+ +
+

Connected Servers

+
+
    +
  • Loading...
  • +
+
+
+ +
+

Available Commands

+
+
+ +
+

Sentinel Status

+
+
+

Heat Map Size

+
0
+
+
+

Active Tickets

+
0
+
+
+

Federation Mappings

+
0
+
+
+
+
+ + + + diff --git a/attached_assets/image_1765164237221.png b/attached_assets/image_1765164237221.png new file mode 100644 index 0000000..57726a9 Binary files /dev/null and b/attached_assets/image_1765164237221.png differ