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:
sirpiglr 2025-12-08 03:38:13 +00:00
parent 2b822b9260
commit 7ca85f433a
10 changed files with 661 additions and 17 deletions

View file

@ -22,6 +22,10 @@ externalPort = 80
localPort = 8080
externalPort = 8080
[[ports]]
localPort = 33701
externalPort = 3000
[workflows]
runButton = "Project"

View file

@ -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("<h1>Dashboard not found</h1>");
}
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();

View 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] });
},
};

View 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] });
}
},
};

View file

@ -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')

View 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);
}
}
},
};

View file

@ -12,12 +12,16 @@ 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()
.setColor(0xff6b6b)
@ -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)
);

View file

@ -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();

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB