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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sentinel Status
+
+
+
+
+
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