Add persistent storage for federation mappings and tickets
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
This commit is contained in:
parent
2b822b9260
commit
7ca85f433a
10 changed files with 661 additions and 17 deletions
4
.replit
4
.replit
|
|
@ -22,6 +22,10 @@ externalPort = 80
|
||||||
localPort = 8080
|
localPort = 8080
|
||||||
externalPort = 8080
|
externalPort = 8080
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 33701
|
||||||
|
externalPort = 3000
|
||||||
|
|
||||||
[workflows]
|
[workflows]
|
||||||
runButton = "Project"
|
runButton = "Project"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ const { createClient } = require("@supabase/supabase-js");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
|
// Dashboard HTML path
|
||||||
|
const dashboardPath = path.join(__dirname, "public", "dashboard.html");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -107,6 +110,54 @@ client.HEAT_THRESHOLD = HEAT_THRESHOLD;
|
||||||
const federationMappings = new Map();
|
const federationMappings = new Map();
|
||||||
client.federationMappings = federationMappings;
|
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 = {
|
const REALM_GUILDS = {
|
||||||
hub: process.env.HUB_GUILD_ID,
|
hub: process.env.HUB_GUILD_ID,
|
||||||
labs: process.env.LABS_GUILD_ID,
|
labs: process.env.LABS_GUILD_ID,
|
||||||
|
|
@ -153,6 +204,59 @@ client.on('guildCreate', async (guild) => {
|
||||||
const activeTickets = new Map();
|
const activeTickets = new Map();
|
||||||
client.activeTickets = activeTickets;
|
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)
|
// SENTINEL: ALERT SYSTEM (New)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -269,16 +373,21 @@ client.on("interactionCreate", async (interaction) => {
|
||||||
console.log(`[Command] Completed: ${interaction.commandName}`);
|
console.log(`[Command] Completed: ${interaction.commandName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error executing ${interaction.commandName}:`, 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) {
|
try {
|
||||||
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
|
const errorEmbed = new EmbedBuilder()
|
||||||
} else {
|
.setColor(0xff0000)
|
||||||
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
.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;
|
const channel = interaction.channel;
|
||||||
if (channel && channel.type === ChannelType.GuildText) {
|
if (channel && channel.type === ChannelType.GuildText) {
|
||||||
await interaction.reply({ content: 'Closing ticket...', ephemeral: true });
|
await interaction.reply({ content: 'Closing ticket...', ephemeral: true });
|
||||||
|
activeTickets.delete(channel.id);
|
||||||
|
await closeTicket(channel.id);
|
||||||
setTimeout(() => channel.delete().catch(console.error), 3000);
|
setTimeout(() => channel.delete().catch(console.error), 3000);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -417,6 +528,19 @@ http
|
||||||
return;
|
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") {
|
if (req.url === "/health") {
|
||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
res.end(
|
res.end(
|
||||||
|
|
@ -886,13 +1010,17 @@ client.login(token).catch((error) => {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.once("ready", async () => {
|
client.once("clientReady", async () => {
|
||||||
console.log(`Bot logged in as ${client.user.tag}`);
|
console.log(`Bot logged in as ${client.user.tag}`);
|
||||||
console.log(`Bot ID: ${client.user.id}`);
|
console.log(`Bot ID: ${client.user.id}`);
|
||||||
console.log(`CLIENT_ID from env: ${process.env.DISCORD_CLIENT_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(`IDs match: ${client.user.id === process.env.DISCORD_CLIENT_ID}`);
|
||||||
console.log(`Watching ${client.guilds.cache.size} server(s)`);
|
console.log(`Watching ${client.guilds.cache.size} server(s)`);
|
||||||
|
|
||||||
|
// Load persisted data from Supabase
|
||||||
|
await loadFederationMappings();
|
||||||
|
await loadActiveTickets();
|
||||||
|
|
||||||
// Auto-register commands on startup
|
// Auto-register commands on startup
|
||||||
console.log("Registering slash commands with Discord...");
|
console.log("Registering slash commands with Discord...");
|
||||||
const regResult = await registerDiscordCommands();
|
const regResult = await registerDiscordCommands();
|
||||||
|
|
|
||||||
111
aethex-bot/commands/announce.js
Normal file
111
aethex-bot/commands/announce.js
Normal file
|
|
@ -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] });
|
||||||
|
},
|
||||||
|
};
|
||||||
105
aethex-bot/commands/auditlog.js
Normal file
105
aethex-bot/commands/auditlog.js
Normal file
|
|
@ -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 = `<t:${Math.floor(time.getTime() / 1000)}:R>`;
|
||||||
|
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] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -37,12 +37,17 @@ module.exports = {
|
||||||
if (subcommand === 'link') {
|
if (subcommand === 'link') {
|
||||||
const role = interaction.options.getRole('role');
|
const role = interaction.options.getRole('role');
|
||||||
|
|
||||||
client.federationMappings.set(role.id, {
|
const mappingData = {
|
||||||
name: role.name,
|
name: role.name,
|
||||||
guildId: interaction.guildId,
|
guildId: interaction.guildId,
|
||||||
guildName: interaction.guild.name,
|
guildName: interaction.guild.name,
|
||||||
linkedAt: Date.now(),
|
linkedAt: Date.now(),
|
||||||
});
|
};
|
||||||
|
client.federationMappings.set(role.id, mappingData);
|
||||||
|
|
||||||
|
if (client.saveFederationMapping) {
|
||||||
|
await client.saveFederationMapping(role.id, mappingData);
|
||||||
|
}
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setColor(0x00ff00)
|
.setColor(0x00ff00)
|
||||||
|
|
@ -63,6 +68,10 @@ module.exports = {
|
||||||
if (client.federationMappings.has(role.id)) {
|
if (client.federationMappings.has(role.id)) {
|
||||||
client.federationMappings.delete(role.id);
|
client.federationMappings.delete(role.id);
|
||||||
|
|
||||||
|
if (client.deleteFederationMapping) {
|
||||||
|
await client.deleteFederationMapping(role.id);
|
||||||
|
}
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setColor(0xff6600)
|
.setColor(0xff6600)
|
||||||
.setTitle('Role Unlinked')
|
.setTitle('Role Unlinked')
|
||||||
|
|
|
||||||
91
aethex-bot/commands/poll.js
Normal file
91
aethex-bot/commands/poll.js
Normal file
|
|
@ -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: `<t:${Math.floor(endsAt.getTime() / 1000)}:R>`, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -12,11 +12,15 @@ module.exports = {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: link } = await supabase
|
const { data: link, error: linkError } = await supabase
|
||||||
.from("discord_links")
|
.from("discord_links")
|
||||||
.select("user_id, primary_arm, created_at")
|
.select("user_id, primary_arm, linked_at")
|
||||||
.eq("discord_id", interaction.user.id)
|
.eq("discord_id", interaction.user.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
if (linkError) {
|
||||||
|
console.error("Stats link query error:", linkError);
|
||||||
|
}
|
||||||
|
|
||||||
if (!link) {
|
if (!link) {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
|
|
@ -64,7 +68,7 @@ module.exports = {
|
||||||
devlink: "💻",
|
devlink: "💻",
|
||||||
};
|
};
|
||||||
|
|
||||||
const linkedDate = new Date(link.created_at);
|
const linkedDate = new Date(link.linked_at);
|
||||||
const daysSinceLinked = Math.floor(
|
const daysSinceLinked = Math.floor(
|
||||||
(Date.now() - linkedDate.getTime()) / (1000 * 60 * 60 * 24)
|
(Date.now() - linkedDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,17 @@ module.exports = {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
client.activeTickets.set(ticketChannel.id, {
|
const ticketData = {
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
guildId: interaction.guildId,
|
guildId: interaction.guildId,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
};
|
||||||
|
client.activeTickets.set(ticketChannel.id, ticketData);
|
||||||
|
|
||||||
|
if (client.saveTicket) {
|
||||||
|
await client.saveTicket(ticketChannel.id, ticketData);
|
||||||
|
}
|
||||||
|
|
||||||
const closeButton = new ActionRowBuilder().addComponents(
|
const closeButton = new ActionRowBuilder().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
|
|
@ -127,6 +132,10 @@ module.exports = {
|
||||||
|
|
||||||
client.activeTickets.delete(interaction.channelId);
|
client.activeTickets.delete(interaction.channelId);
|
||||||
|
|
||||||
|
if (client.closeTicket) {
|
||||||
|
await client.closeTicket(interaction.channelId);
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await interaction.channel.delete();
|
await interaction.channel.delete();
|
||||||
|
|
|
||||||
183
aethex-bot/public/dashboard.html
Normal file
183
aethex-bot/public/dashboard.html
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AeThex Bot Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.logo { width: 48px; height: 48px; background: #7c3aed; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; }
|
||||||
|
h1 { font-size: 1.75rem; font-weight: 600; }
|
||||||
|
.status-badge { padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.status-online { background: #10b981; }
|
||||||
|
.status-offline { background: #ef4444; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; }
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.card h3 { font-size: 0.875rem; color: #a1a1aa; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.card .value { font-size: 2rem; font-weight: 700; }
|
||||||
|
.server-list { list-style: none; }
|
||||||
|
.server-list li {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.server-list li:last-child { border-bottom: none; }
|
||||||
|
.server-members { color: #a1a1aa; font-size: 0.875rem; }
|
||||||
|
.commands-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75rem; }
|
||||||
|
.command-item {
|
||||||
|
background: rgba(124, 58, 237, 0.2);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.section { margin-bottom: 2rem; }
|
||||||
|
.section h2 { font-size: 1.25rem; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.refresh-btn {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover { background: #6d28d9; }
|
||||||
|
#lastUpdated { color: #a1a1aa; font-size: 0.75rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div class="logo">A</div>
|
||||||
|
<div>
|
||||||
|
<h1>AeThex Bot Dashboard</h1>
|
||||||
|
<span id="lastUpdated">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<span id="statusBadge" class="status-badge status-offline">Offline</span>
|
||||||
|
<button class="refresh-btn" onclick="fetchData()">Refresh</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Servers</h3>
|
||||||
|
<div class="value" id="serverCount">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Total Members</h3>
|
||||||
|
<div class="value" id="memberCount">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Commands</h3>
|
||||||
|
<div class="value" id="commandCount">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Uptime</h3>
|
||||||
|
<div class="value" id="uptime">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Connected Servers</h2>
|
||||||
|
<div class="card">
|
||||||
|
<ul class="server-list" id="serverList">
|
||||||
|
<li>Loading...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Available Commands</h2>
|
||||||
|
<div class="commands-grid" id="commandsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Sentinel Status</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Heat Map Size</h3>
|
||||||
|
<div class="value" id="heatMapSize">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Active Tickets</h3>
|
||||||
|
<div class="value" id="activeTickets">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Federation Mappings</h3>
|
||||||
|
<div class="value" id="federationMappings">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [health, stats] = await Promise.all([
|
||||||
|
fetch('/health').then(r => r.json()),
|
||||||
|
fetch('/stats').then(r => r.json())
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.getElementById('statusBadge').textContent = health.status === 'online' ? 'Online' : 'Offline';
|
||||||
|
document.getElementById('statusBadge').className = `status-badge ${health.status === 'online' ? 'status-online' : 'status-offline'}`;
|
||||||
|
document.getElementById('serverCount').textContent = health.guilds;
|
||||||
|
document.getElementById('commandCount').textContent = health.commands;
|
||||||
|
document.getElementById('heatMapSize').textContent = health.heatMapSize;
|
||||||
|
|
||||||
|
const hours = Math.floor(health.uptime / 3600);
|
||||||
|
const minutes = Math.floor((health.uptime % 3600) / 60);
|
||||||
|
document.getElementById('uptime').textContent = `${hours}h ${minutes}m`;
|
||||||
|
|
||||||
|
document.getElementById('memberCount').textContent = stats.totalMembers.toLocaleString();
|
||||||
|
document.getElementById('activeTickets').textContent = stats.activeTickets;
|
||||||
|
|
||||||
|
const serverList = document.getElementById('serverList');
|
||||||
|
serverList.innerHTML = stats.guilds.map(g => `
|
||||||
|
<li>
|
||||||
|
<span>${g.name}</span>
|
||||||
|
<span class="server-members">${g.memberCount.toLocaleString()} members</span>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.getElementById('lastUpdated').textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
|
||||||
|
|
||||||
|
const commands = ['verify', 'unlink', 'profile', 'stats', 'set-realm', 'verify-role', 'refresh-roles',
|
||||||
|
'post', 'leaderboard', 'help', 'admin', 'federation', 'ticket', 'status', 'announce', 'poll', 'auditlog'];
|
||||||
|
document.getElementById('commandsList').innerHTML = commands.map(c => `
|
||||||
|
<div class="command-item">/${c}</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch data:', error);
|
||||||
|
document.getElementById('statusBadge').textContent = 'Error';
|
||||||
|
document.getElementById('statusBadge').className = 'status-badge status-offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
setInterval(fetchData, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
attached_assets/image_1765164237221.png
Normal file
BIN
attached_assets/image_1765164237221.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Loading…
Reference in a new issue