From 42c24762b06ff6727c0906f6cd0ec7fe005df3c2 Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Sun, 7 Dec 2025 23:19:50 +0000 Subject: [PATCH] Add optional Supabase integration and improve command reliability Implement guards for Supabase-dependent commands, refine error handling, and introduce feed synchronization capabilities. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e72fc1b7-94bd-4d6c-801f-cbac2fae245c Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 48ccfc2d-e27b-4e3b-b0d2-25bdb3ece9c8 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/e72fc1b7-94bd-4d6c-801f-cbac2fae245c/NXjYRWJ Replit-Helium-Checkpoint-Created: true --- aethex-bot/bot.js | 25 +- aethex-bot/commands/admin.js | 3 +- aethex-bot/commands/federation.js | 86 +- aethex-bot/commands/leaderboard.js | 8 + aethex-bot/commands/post.js | 8 + aethex-bot/commands/profile.js | 8 + aethex-bot/commands/refresh-roles.js | 9 +- aethex-bot/commands/set-realm.js | 8 + aethex-bot/commands/stats.js | 8 + aethex-bot/commands/ticket.js | 131 +- aethex-bot/commands/unlink.js | 8 + aethex-bot/commands/verify-role.js | 8 + aethex-bot/commands/verify.js | 11 +- aethex-bot/events/guildMemberUpdate.js | 80 -- aethex-bot/events/messageCreate.js | 181 +++ aethex-bot/listeners/feedSync.js | 239 ++++ aethex-bot/listeners/sentinel/antiNuke.js | 88 +- aethex-bot/listeners/sentinel/memberBan.js | 87 +- aethex-bot/listeners/sentinel/memberKick.js | 93 +- aethex-bot/listeners/sentinel/roleDelete.js | 87 +- aethex-bot/scripts/register-commands.js | 153 ++- .../discord-bot/.env.example | 23 + .../discord-bot/DEPLOYMENT_GUIDE.md | 211 +++ .../discord-bot-source/discord-bot/Dockerfile | 22 + .../discord-bot-source/discord-bot/bot.js | 803 ++++++++++++ .../discord-bot/commands/help.js | 55 + .../discord-bot/commands/leaderboard.js | 155 +++ .../discord-bot/commands/post.js | 144 ++ .../discord-bot/commands/profile.js | 93 ++ .../discord-bot/commands/refresh-roles.js | 72 + .../discord-bot/commands/set-realm.js | 139 ++ .../discord-bot/commands/stats.js | 140 ++ .../discord-bot/commands/unlink.js | 75 ++ .../discord-bot/commands/verify-role.js | 97 ++ .../discord-bot/commands/verify.js | 85 ++ .../discord-bot/discloud.config | 10 + .../discord-bot/events/messageCreate.js | 180 +++ .../discord-bot/listeners/feedSync.js | 239 ++++ .../discord-bot/package-lock.json | 1157 +++++++++++++++++ .../discord-bot/package.json | 34 + .../discord-bot/scripts/register-commands.js | 110 ++ .../discord-bot/utils/roleManager.js | 137 ++ attached_assets/discord-bot_1765148924805.zip | Bin 0 -> 44005 bytes 43 files changed, 4864 insertions(+), 446 deletions(-) delete mode 100644 aethex-bot/events/guildMemberUpdate.js create mode 100644 aethex-bot/events/messageCreate.js create mode 100644 aethex-bot/listeners/feedSync.js create mode 100644 attached_assets/discord-bot-source/discord-bot/.env.example create mode 100644 attached_assets/discord-bot-source/discord-bot/DEPLOYMENT_GUIDE.md create mode 100644 attached_assets/discord-bot-source/discord-bot/Dockerfile create mode 100644 attached_assets/discord-bot-source/discord-bot/bot.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/help.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/leaderboard.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/post.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/profile.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/refresh-roles.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/set-realm.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/stats.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/unlink.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/verify-role.js create mode 100644 attached_assets/discord-bot-source/discord-bot/commands/verify.js create mode 100644 attached_assets/discord-bot-source/discord-bot/discloud.config create mode 100644 attached_assets/discord-bot-source/discord-bot/events/messageCreate.js create mode 100644 attached_assets/discord-bot-source/discord-bot/listeners/feedSync.js create mode 100644 attached_assets/discord-bot-source/discord-bot/package-lock.json create mode 100644 attached_assets/discord-bot-source/discord-bot/package.json create mode 100644 attached_assets/discord-bot-source/discord-bot/scripts/register-commands.js create mode 100644 attached_assets/discord-bot-source/discord-bot/utils/roleManager.js create mode 100644 attached_assets/discord-bot_1765148924805.zip diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index 8c990ac..39ec557 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -7,7 +7,6 @@ const { EmbedBuilder, ChannelType, PermissionFlagsBits, - AuditLogEvent, } = require("discord.js"); const { createClient } = require("@supabase/supabase-js"); const http = require("http"); @@ -23,6 +22,13 @@ if (!token) { process.exit(1); } +if (!clientId) { + console.error("Missing DISCORD_CLIENT_ID environment variable"); + process.exit(1); +} + +console.log("[Token] Bot token loaded (length: " + token.length + " chars)"); + const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -41,6 +47,8 @@ if (process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE) { process.env.SUPABASE_SERVICE_ROLE ); console.log("Supabase connected"); +} else { + console.log("Supabase not configured - community features will be limited"); } client.commands = new Collection(); @@ -87,7 +95,6 @@ if (fs.existsSync(sentinelPath)) { const heatMap = new Map(); const HEAT_THRESHOLD = 3; const HEAT_WINDOW_MS = 10000; -const DANGEROUS_ACTIONS = ['CHANNEL_DELETE', 'ROLE_DELETE', 'MEMBER_BAN_ADD', 'MEMBER_KICK']; const whitelistedUsers = (process.env.WHITELISTED_USERS || '').split(',').filter(Boolean); function addHeat(userId, action) { @@ -153,12 +160,23 @@ async function sendAlert(message, embed = null) { } client.sendAlert = sendAlert; +let feedSyncModule = null; +try { + feedSyncModule = require("./listeners/feedSync"); +} catch (e) { + console.log("Feed sync module not available"); +} + client.once("ready", () => { console.log(`Bot logged in as ${client.user.tag}`); console.log(`Watching ${client.guilds.cache.size} server(s)`); client.user.setActivity("Protecting the Federation", { type: 3 }); + if (feedSyncModule && feedSyncModule.setupFeedListener && supabase) { + feedSyncModule.setupFeedListener(client); + } + sendAlert(`AeThex Bot is now online! Watching ${client.guilds.cache.size} servers.`); }); @@ -190,7 +208,6 @@ client.on("interactionCreate", async (interaction) => { if (action === 'ticket') { const ticketAction = params[0]; if (ticketAction === 'close') { - const ticketChannelId = params[1]; try { const channel = interaction.channel; if (channel && channel.type === ChannelType.GuildText) { @@ -206,6 +223,7 @@ client.on("interactionCreate", async (interaction) => { }); const healthPort = process.env.HEALTH_PORT || 8080; + http.createServer((req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Content-Type", "application/json"); @@ -218,6 +236,7 @@ http.createServer((req, res) => { commands: client.commands.size, uptime: Math.floor(process.uptime()), heatMapSize: heatMap.size, + supabaseConnected: !!supabase, timestamp: new Date().toISOString(), })); return; diff --git a/aethex-bot/commands/admin.js b/aethex-bot/commands/admin.js index 4573f7b..64a4c0b 100644 --- a/aethex-bot/commands/admin.js +++ b/aethex-bot/commands/admin.js @@ -57,7 +57,8 @@ module.exports = { { name: 'Commands', value: `${commandCount}`, inline: true }, { name: 'Uptime', value: `${hours}h ${minutes}m`, inline: true }, { name: 'Active Tickets', value: `${client.activeTickets.size}`, inline: true }, - { name: 'Heat Map Size', value: `${client.heatMap.size}`, inline: true } + { name: 'Heat Map Size', value: `${client.heatMap.size}`, inline: true }, + { name: 'Supabase', value: supabase ? 'Connected' : 'Not configured', inline: true } ) .setTimestamp(); diff --git a/aethex-bot/commands/federation.js b/aethex-bot/commands/federation.js index a55e3ba..1d07978 100644 --- a/aethex-bot/commands/federation.js +++ b/aethex-bot/commands/federation.js @@ -3,22 +3,22 @@ const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('disc module.exports = { data: new SlashCommandBuilder() .setName('federation') - .setDescription('Manage cross-server role sync') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDescription('Manage cross-server role synchronization') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) .addSubcommand(subcommand => subcommand .setName('link') - .setDescription('Link a role for cross-server syncing') + .setDescription('Link a role for cross-server sync') .addRoleOption(option => option.setName('role') - .setDescription('Role to sync across realms') + .setDescription('Role to sync across servers') .setRequired(true) ) ) .addSubcommand(subcommand => subcommand .setName('unlink') - .setDescription('Remove a role from cross-server syncing') + .setDescription('Remove a role from sync') .addRoleOption(option => option.setName('role') .setDescription('Role to remove from sync') @@ -37,78 +37,76 @@ module.exports = { if (subcommand === 'link') { const role = interaction.options.getRole('role'); - if (client.federationMappings.has(role.id)) { - return interaction.reply({ - content: `${role} is already linked for federation sync.`, - ephemeral: true, - }); - } - client.federationMappings.set(role.id, { name: role.name, - guildId: interaction.guild.id, - createdAt: Date.now(), + guildId: interaction.guildId, + guildName: interaction.guild.name, + linkedAt: Date.now(), }); const embed = new EmbedBuilder() .setColor(0x00ff00) .setTitle('Role Linked') - .setDescription(`${role} will now sync across all realm servers.`) + .setDescription(`${role} is now linked for federation sync.`) .addFields( - { name: 'Role Name', value: role.name, inline: true }, - { name: 'Source Guild', value: interaction.guild.name, inline: true } + { name: 'Role ID', value: role.id, inline: true }, + { name: 'Server', value: interaction.guild.name, inline: true } ) - .setFooter({ text: 'Users with this role will receive it in all connected realms.' }) .setTimestamp(); await interaction.reply({ embeds: [embed] }); - - client.sendAlert(`Federation: Role ${role.name} linked by ${interaction.user.tag}`); } if (subcommand === 'unlink') { const role = interaction.options.getRole('role'); - if (!client.federationMappings.has(role.id)) { - return interaction.reply({ - content: `${role} is not linked for federation sync.`, - ephemeral: true, - }); + if (client.federationMappings.has(role.id)) { + client.federationMappings.delete(role.id); + + const embed = new EmbedBuilder() + .setColor(0xff6600) + .setTitle('Role Unlinked') + .setDescription(`${role} has been removed from federation sync.`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } else { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Not Found') + .setDescription(`${role} is not currently linked.`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); } - - client.federationMappings.delete(role.id); - - const embed = new EmbedBuilder() - .setColor(0xff0000) - .setTitle('Role Unlinked') - .setDescription(`${role} will no longer sync across realm servers.`) - .setTimestamp(); - - await interaction.reply({ embeds: [embed] }); - - client.sendAlert(`Federation: Role ${role.name} unlinked by ${interaction.user.tag}`); } if (subcommand === 'list') { const mappings = [...client.federationMappings.entries()]; if (mappings.length === 0) { - return interaction.reply({ - content: 'No roles are currently linked for federation sync.', - ephemeral: true, - }); + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('Federation Roles') + .setDescription('No roles are currently linked for federation sync.\nUse `/federation link` to add roles.') + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + return; } - const roleList = mappings.map(([roleId, data]) => `<@&${roleId}> - Added `).join('\n'); + const roleList = mappings.map(([roleId, data]) => + `<@&${roleId}> - ${data.guildName}` + ).join('\n'); const embed = new EmbedBuilder() .setColor(0x7c3aed) - .setTitle('Federation Linked Roles') + .setTitle('Federation Roles') .setDescription(roleList) .setFooter({ text: `${mappings.length} role(s) linked` }) .setTimestamp(); - await interaction.reply({ embeds: [embed], ephemeral: true }); + await interaction.reply({ embeds: [embed] }); } }, }; diff --git a/aethex-bot/commands/leaderboard.js b/aethex-bot/commands/leaderboard.js index cbc5b01..2f6fcfd 100644 --- a/aethex-bot/commands/leaderboard.js +++ b/aethex-bot/commands/leaderboard.js @@ -19,6 +19,14 @@ module.exports = { async execute(interaction, supabase) { await interaction.deferReply(); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Leaderboard is not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { const category = interaction.options.getString("category") || "posts"; diff --git a/aethex-bot/commands/post.js b/aethex-bot/commands/post.js index 61057e6..04c91dd 100644 --- a/aethex-bot/commands/post.js +++ b/aethex-bot/commands/post.js @@ -41,6 +41,14 @@ module.exports = { async execute(interaction, supabase, client) { await interaction.deferReply({ ephemeral: true }); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Posting is not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { const { data: link } = await supabase .from("discord_links") diff --git a/aethex-bot/commands/profile.js b/aethex-bot/commands/profile.js index 035f251..4faab7d 100644 --- a/aethex-bot/commands/profile.js +++ b/aethex-bot/commands/profile.js @@ -8,6 +8,14 @@ module.exports = { async execute(interaction, supabase) { await interaction.deferReply({ ephemeral: true }); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Profile features are not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { const { data: link } = await supabase .from("discord_links") diff --git a/aethex-bot/commands/refresh-roles.js b/aethex-bot/commands/refresh-roles.js index 459bd79..a159d11 100644 --- a/aethex-bot/commands/refresh-roles.js +++ b/aethex-bot/commands/refresh-roles.js @@ -11,8 +11,15 @@ module.exports = { async execute(interaction, supabase, client) { await interaction.deferReply({ ephemeral: true }); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Role sync is not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { - // Check if user is linked const { data: link } = await supabase .from("discord_links") .select("primary_arm") diff --git a/aethex-bot/commands/set-realm.js b/aethex-bot/commands/set-realm.js index c1af120..f8e8204 100644 --- a/aethex-bot/commands/set-realm.js +++ b/aethex-bot/commands/set-realm.js @@ -34,6 +34,14 @@ module.exports = { async execute(interaction, supabase, client) { await interaction.deferReply({ ephemeral: true }); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Realm settings are not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { const { data: link } = await supabase .from("discord_links") diff --git a/aethex-bot/commands/stats.js b/aethex-bot/commands/stats.js index fe9814b..6931f9d 100644 --- a/aethex-bot/commands/stats.js +++ b/aethex-bot/commands/stats.js @@ -8,6 +8,14 @@ module.exports = { async execute(interaction, supabase) { await interaction.deferReply({ ephemeral: true }); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Stats are not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { const { data: link } = await supabase .from("discord_links") diff --git a/aethex-bot/commands/ticket.js b/aethex-bot/commands/ticket.js index 8a9fb2b..d0cf286 100644 --- a/aethex-bot/commands/ticket.js +++ b/aethex-bot/commands/ticket.js @@ -3,15 +3,15 @@ const { SlashCommandBuilder, EmbedBuilder, ChannelType, PermissionFlagsBits, Act module.exports = { data: new SlashCommandBuilder() .setName('ticket') - .setDescription('Ticket management system') + .setDescription('Support ticket system') .addSubcommand(subcommand => subcommand .setName('create') - .setDescription('Create a new support ticket') + .setDescription('Create a support ticket') .addStringOption(option => option.setName('reason') - .setDescription('Brief reason for opening this ticket') - .setRequired(true) + .setDescription('Reason for the ticket') + .setRequired(false) ) ) .addSubcommand(subcommand => @@ -24,103 +24,114 @@ module.exports = { const subcommand = interaction.options.getSubcommand(); if (subcommand === 'create') { - const reason = interaction.options.getString('reason'); - const guild = interaction.guild; - const user = interaction.user; + const reason = interaction.options.getString('reason') || 'No reason provided'; + + const existingTicket = [...client.activeTickets.entries()].find( + ([, data]) => data.userId === interaction.user.id && data.guildId === interaction.guildId + ); - const existingTicket = client.activeTickets.get(user.id); if (existingTicket) { - return interaction.reply({ - content: `You already have an open ticket: <#${existingTicket}>`, - ephemeral: true, - }); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Ticket Already Exists') + .setDescription(`You already have an open ticket: <#${existingTicket[0]}>`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; } try { - const ticketChannel = await guild.channels.create({ - name: `ticket-${user.username}`, + const ticketChannel = await interaction.guild.channels.create({ + name: `ticket-${interaction.user.username}`, type: ChannelType.GuildText, permissionOverwrites: [ { - id: guild.id, + id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel], }, { - id: user.id, + id: interaction.user.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages], }, - { - id: client.user.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageChannels], - }, ], }); - client.activeTickets.set(user.id, ticketChannel.id); + client.activeTickets.set(ticketChannel.id, { + userId: interaction.user.id, + guildId: interaction.guildId, + reason: reason, + createdAt: Date.now(), + }); - const closeButton = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId(`ticket_close_${ticketChannel.id}`) - .setLabel('Close Ticket') - .setStyle(ButtonStyle.Danger) - ); + const closeButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ticket_close_${ticketChannel.id}`) + .setLabel('Close Ticket') + .setStyle(ButtonStyle.Danger) + ); const ticketEmbed = new EmbedBuilder() .setColor(0x7c3aed) .setTitle('Support Ticket') - .setDescription(`Ticket created by ${user}`) + .setDescription(`Ticket created by ${interaction.user}`) .addFields( { name: 'Reason', value: reason }, - { name: 'User ID', value: user.id, inline: true }, - { name: 'Created', value: ``, inline: true } + { name: 'Created', value: `` } ) - .setFooter({ text: 'A staff member will assist you shortly.' }); + .setFooter({ text: 'Click the button below to close this ticket' }) + .setTimestamp(); await ticketChannel.send({ embeds: [ticketEmbed], components: [closeButton] }); - await interaction.reply({ - content: `Ticket created: ${ticketChannel}`, - ephemeral: true, - }); - - client.sendAlert(`New ticket opened by ${user.tag}: ${reason}`); + const successEmbed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Ticket Created') + .setDescription(`Your ticket has been created: ${ticketChannel}`) + .setTimestamp(); + await interaction.reply({ embeds: [successEmbed], ephemeral: true }); } catch (error) { - console.error('[Ticket] Create error:', error); - await interaction.reply({ - content: 'Failed to create ticket. Please try again.', - ephemeral: true, - }); + console.error('Ticket creation error:', error); + const errorEmbed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Error') + .setDescription('Failed to create ticket. I may not have the required permissions.') + .setTimestamp(); + + await interaction.reply({ embeds: [errorEmbed], ephemeral: true }); } } if (subcommand === 'close') { - const channel = interaction.channel; + const ticketData = client.activeTickets.get(interaction.channelId); - if (!channel.name.startsWith('ticket-')) { - return interaction.reply({ - content: 'This command can only be used in ticket channels.', - ephemeral: true, - }); + if (!ticketData) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('Not a Ticket') + .setDescription('This channel is not a support ticket.') + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; } - const userId = [...client.activeTickets.entries()].find(([k, v]) => v === channel.id)?.[0]; - if (userId) { - client.activeTickets.delete(userId); - } + const embed = new EmbedBuilder() + .setColor(0xff6600) + .setTitle('Closing Ticket') + .setDescription('This ticket will be closed in 5 seconds...') + .setTimestamp(); - await interaction.reply({ - content: 'Closing ticket in 5 seconds...', - }); + await interaction.reply({ embeds: [embed] }); - client.sendAlert(`Ticket ${channel.name} closed by ${interaction.user.tag}`); + client.activeTickets.delete(interaction.channelId); setTimeout(async () => { try { - await channel.delete(); - } catch (err) { - console.error('[Ticket] Delete error:', err); + await interaction.channel.delete(); + } catch (e) { + console.error('Failed to delete ticket channel:', e); } }, 5000); } diff --git a/aethex-bot/commands/unlink.js b/aethex-bot/commands/unlink.js index ac06d2a..4812ea6 100644 --- a/aethex-bot/commands/unlink.js +++ b/aethex-bot/commands/unlink.js @@ -8,6 +8,14 @@ module.exports = { async execute(interaction, supabase) { await interaction.deferReply({ ephemeral: true }); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Account unlinking is not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { const { data: link } = await supabase .from("discord_links") diff --git a/aethex-bot/commands/verify-role.js b/aethex-bot/commands/verify-role.js index 1b7e6b9..1677d58 100644 --- a/aethex-bot/commands/verify-role.js +++ b/aethex-bot/commands/verify-role.js @@ -8,6 +8,14 @@ module.exports = { async execute(interaction, supabase) { await interaction.deferReply({ ephemeral: true }); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Role verification is not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { const { data: link } = await supabase .from("discord_links") diff --git a/aethex-bot/commands/verify.js b/aethex-bot/commands/verify.js index 4e5e6c4..a4bbf1b 100644 --- a/aethex-bot/commands/verify.js +++ b/aethex-bot/commands/verify.js @@ -15,6 +15,14 @@ module.exports = { async execute(interaction, supabase, client) { await interaction.deferReply({ ephemeral: true }); + if (!supabase) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("⚠️ Feature Unavailable") + .setDescription("Account linking is not configured. Contact an administrator."); + return await interaction.editReply({ embeds: [embed] }); + } + try { const { data: existingLink } = await supabase .from("discord_links") @@ -40,10 +48,11 @@ module.exports = { .toUpperCase(); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes - // Store verification code + // Store verification code with Discord username await supabase.from("discord_verifications").insert({ discord_id: interaction.user.id, verification_code: verificationCode, + username: interaction.user.username, expires_at: expiresAt.toISOString(), }); diff --git a/aethex-bot/events/guildMemberUpdate.js b/aethex-bot/events/guildMemberUpdate.js deleted file mode 100644 index 0e0c6b8..0000000 --- a/aethex-bot/events/guildMemberUpdate.js +++ /dev/null @@ -1,80 +0,0 @@ -const { EmbedBuilder } = require('discord.js'); - -module.exports = { - name: 'guildMemberUpdate', - async execute(oldMember, newMember, client) { - try { - const addedRoles = newMember.roles.cache.filter(role => !oldMember.roles.cache.has(role.id)); - const removedRoles = oldMember.roles.cache.filter(role => !newMember.roles.cache.has(role.id)); - - if (addedRoles.size === 0 && removedRoles.size === 0) return; - - const REALM_GUILDS = client.REALM_GUILDS; - const realmGuildIds = Object.values(REALM_GUILDS).filter(Boolean); - - if (!realmGuildIds.includes(newMember.guild.id)) return; - - const federationMappings = client.federationMappings; - - for (const [roleId, role] of addedRoles) { - const mapping = federationMappings.get(roleId); - if (!mapping) continue; - - console.log(`[Federation] Syncing role ${role.name} for ${newMember.user.tag} across realms`); - - for (const targetGuildId of realmGuildIds) { - if (targetGuildId === newMember.guild.id) continue; - - try { - const targetGuild = client.guilds.cache.get(targetGuildId); - if (!targetGuild) continue; - - const targetMember = await targetGuild.members.fetch(newMember.user.id).catch(() => null); - if (!targetMember) continue; - - const targetRole = targetGuild.roles.cache.find(r => r.name === role.name); - if (!targetRole) continue; - - if (!targetMember.roles.cache.has(targetRole.id)) { - await targetMember.roles.add(targetRole, '[Federation] Cross-server sync'); - console.log(`[Federation] Added ${role.name} to ${newMember.user.tag} in ${targetGuild.name}`); - } - } catch (err) { - console.error(`[Federation] Sync error for guild ${targetGuildId}:`, err.message); - } - } - } - - for (const [roleId, role] of removedRoles) { - const mapping = federationMappings.get(roleId); - if (!mapping) continue; - - console.log(`[Federation] Removing synced role ${role.name} for ${newMember.user.tag} across realms`); - - for (const targetGuildId of realmGuildIds) { - if (targetGuildId === newMember.guild.id) continue; - - try { - const targetGuild = client.guilds.cache.get(targetGuildId); - if (!targetGuild) continue; - - const targetMember = await targetGuild.members.fetch(newMember.user.id).catch(() => null); - if (!targetMember) continue; - - const targetRole = targetGuild.roles.cache.find(r => r.name === role.name); - if (!targetRole) continue; - - if (targetMember.roles.cache.has(targetRole.id)) { - await targetMember.roles.remove(targetRole, '[Federation] Cross-server sync'); - console.log(`[Federation] Removed ${role.name} from ${newMember.user.tag} in ${targetGuild.name}`); - } - } catch (err) { - console.error(`[Federation] Sync error for guild ${targetGuildId}:`, err.message); - } - } - } - } catch (error) { - console.error('[Federation] guildMemberUpdate error:', error); - } - }, -}; diff --git a/aethex-bot/events/messageCreate.js b/aethex-bot/events/messageCreate.js new file mode 100644 index 0000000..afc0b30 --- /dev/null +++ b/aethex-bot/events/messageCreate.js @@ -0,0 +1,181 @@ +const { createClient } = require("@supabase/supabase-js"); + +let supabase = null; +if (process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE) { + supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE, + ); +} + +// Only sync messages from this specific channel +const FEED_CHANNEL_ID = process.env.DISCORD_MAIN_CHAT_CHANNELS + ? process.env.DISCORD_MAIN_CHAT_CHANNELS.split(",")[0].trim() + : null; + +function getArmAffiliation(message) { + const guildName = message.guild?.name?.toLowerCase() || ""; + const channelName = message.channel?.name?.toLowerCase() || ""; + const searchString = `${guildName} ${channelName}`; + + if (searchString.includes("gameforge")) return "gameforge"; + if (searchString.includes("corp")) return "corp"; + if (searchString.includes("foundation")) return "foundation"; + if (searchString.includes("devlink") || searchString.includes("dev-link")) + return "devlink"; + if (searchString.includes("nexus")) return "nexus"; + if (searchString.includes("staff")) return "staff"; + + return "labs"; +} + +async function syncMessageToFeed(message) { + try { + console.log( + `[Feed Sync] Processing from ${message.author.tag} in #${message.channel.name}`, + ); + + const { data: linkedAccount } = await supabase + .from("discord_links") + .select("user_id") + .eq("discord_id", message.author.id) + .single(); + + let authorId = linkedAccount?.user_id; + let authorInfo = null; + + if (authorId) { + const { data: profile } = await supabase + .from("user_profiles") + .select("id, username, full_name, avatar_url") + .eq("id", authorId) + .single(); + authorInfo = profile; + } + + if (!authorId) { + const discordUsername = `discord-${message.author.id}`; + let { data: guestProfile } = await supabase + .from("user_profiles") + .select("id, username, full_name, avatar_url") + .eq("username", discordUsername) + .single(); + + if (!guestProfile) { + const { data: newProfile, error: createError } = await supabase + .from("user_profiles") + .insert({ + username: discordUsername, + full_name: message.author.displayName || message.author.username, + avatar_url: message.author.displayAvatarURL({ size: 256 }), + }) + .select("id, username, full_name, avatar_url") + .single(); + + if (createError) { + console.error("[Feed Sync] Could not create guest profile:", createError); + return; + } + guestProfile = newProfile; + } + + authorId = guestProfile?.id; + authorInfo = guestProfile; + } + + if (!authorId) { + console.error("[Feed Sync] Could not get author ID"); + return; + } + + let content = message.content || "Shared a message on Discord"; + let mediaUrl = null; + let mediaType = "none"; + + if (message.attachments.size > 0) { + const attachment = message.attachments.first(); + if (attachment) { + mediaUrl = attachment.url; + const attachmentLower = attachment.name.toLowerCase(); + + if ( + [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((ext) => + attachmentLower.endsWith(ext), + ) + ) { + mediaType = "image"; + } else if ( + [".mp4", ".webm", ".mov", ".avi"].some((ext) => + attachmentLower.endsWith(ext), + ) + ) { + mediaType = "video"; + } + } + } + + const armAffiliation = getArmAffiliation(message); + + const postContent = JSON.stringify({ + text: content, + mediaUrl: mediaUrl, + mediaType: mediaType, + source: "discord", + discord_message_id: message.id, + discord_channel_id: message.channelId, + discord_channel_name: message.channel.name, + discord_guild_id: message.guildId, + discord_guild_name: message.guild?.name, + discord_author_id: message.author.id, + discord_author_tag: message.author.tag, + discord_author_avatar: message.author.displayAvatarURL({ size: 256 }), + is_linked_user: !!linkedAccount, + }); + + const { error: insertError } = await supabase + .from("community_posts") + .insert({ + title: content.substring(0, 100) || "Discord Message", + content: postContent, + arm_affiliation: armAffiliation, + author_id: authorId, + tags: ["discord", "feed"], + category: "discord", + is_published: true, + likes_count: 0, + comments_count: 0, + }); + + if (insertError) { + console.error("[Feed Sync] Post creation failed:", insertError); + return; + } + + console.log( + `[Feed Sync] ✅ Synced message from ${message.author.tag} to AeThex feed`, + ); + } catch (error) { + console.error("[Feed Sync] Error:", error); + } +} + +module.exports = { + name: "messageCreate", + async execute(message, client) { + if (!supabase) return; + + if (message.author.bot) return; + + if (!message.content && message.attachments.size === 0) return; + + if (!FEED_CHANNEL_ID) { + return; + } + + if (message.channelId !== FEED_CHANNEL_ID) { + return; + } + + await syncMessageToFeed(message); + }, +}; diff --git a/aethex-bot/listeners/feedSync.js b/aethex-bot/listeners/feedSync.js new file mode 100644 index 0000000..b8168c4 --- /dev/null +++ b/aethex-bot/listeners/feedSync.js @@ -0,0 +1,239 @@ +const { EmbedBuilder } = require("discord.js"); +const { createClient } = require("@supabase/supabase-js"); + +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE, +); + +const FEED_CHANNEL_ID = process.env.DISCORD_MAIN_CHAT_CHANNELS + ? process.env.DISCORD_MAIN_CHAT_CHANNELS.split(",")[0].trim() + : null; + +const POLL_INTERVAL = 5000; // Check every 5 seconds + +let discordClient = null; +let lastCheckedTime = null; +let pollInterval = null; +let isPolling = false; // Concurrency lock to prevent overlapping polls +const processedPostIds = new Set(); // Track already-processed posts to prevent duplicates + +function getArmColor(arm) { + const colors = { + labs: 0x00d4ff, + gameforge: 0xff6b00, + corp: 0x9945ff, + foundation: 0x14f195, + devlink: 0xf7931a, + nexus: 0xff00ff, + staff: 0xffd700, + }; + return colors[arm] || 0x5865f2; +} + +function getArmEmoji(arm) { + const emojis = { + labs: "🔬", + gameforge: "🎮", + corp: "🏢", + foundation: "🎓", + devlink: "🔗", + nexus: "🌐", + staff: "⭐", + }; + return emojis[arm] || "📝"; +} + +async function sendPostToDiscord(post, authorInfo = null) { + if (!discordClient || !FEED_CHANNEL_ID) { + console.log("[Feed Bridge] No Discord client or channel configured"); + return { success: false, error: "No Discord client or channel configured" }; + } + + try { + const channel = await discordClient.channels.fetch(FEED_CHANNEL_ID); + if (!channel || !channel.isTextBased()) { + console.error("[Feed Bridge] Could not find text channel:", FEED_CHANNEL_ID); + return { success: false, error: "Could not find text channel" }; + } + + let content = {}; + try { + content = typeof post.content === "string" ? JSON.parse(post.content) : post.content; + } catch { + content = { text: post.content }; + } + + if (content.source === "discord") { + return { success: true, skipped: true, reason: "Discord-sourced post" }; + } + + let author = authorInfo; + if (!author && post.author_id) { + const { data } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", post.author_id) + .single(); + author = data; + } + + const authorName = author?.full_name || author?.username || "AeThex User"; + // Discord only accepts HTTP/HTTPS URLs for icons - filter out base64/data URLs + const rawAvatar = author?.avatar_url || ""; + const authorAvatar = rawAvatar.startsWith("http://") || rawAvatar.startsWith("https://") + ? rawAvatar + : "https://aethex.dev/logo.png"; + const arm = post.arm_affiliation || "labs"; + + const embed = new EmbedBuilder() + .setColor(getArmColor(arm)) + .setAuthor({ + name: `${getArmEmoji(arm)} ${authorName}`, + iconURL: authorAvatar, + url: `https://aethex.dev/creators/${author?.username || post.author_id}`, + }) + .setDescription(content.text || post.title || "New post") + .setTimestamp(post.created_at ? new Date(post.created_at) : new Date()) + .setFooter({ + text: `Posted from AeThex • ${arm.charAt(0).toUpperCase() + arm.slice(1)}`, + iconURL: "https://aethex.dev/logo.png", + }); + + if (content.mediaUrl) { + if (content.mediaType === "image") { + embed.setImage(content.mediaUrl); + } else if (content.mediaType === "video") { + embed.addFields({ + name: "🎬 Video", + value: `[Watch Video](${content.mediaUrl})`, + }); + } + } + + if (post.tags && post.tags.length > 0) { + const tagString = post.tags + .filter((t) => t !== "discord" && t !== "main-chat") + .map((t) => `#${t}`) + .join(" "); + if (tagString) { + embed.addFields({ name: "Tags", value: tagString, inline: true }); + } + } + + const postUrl = `https://aethex.dev/community/feed?post=${post.id}`; + embed.addFields({ + name: "🔗 View on AeThex", + value: `[Open Post](${postUrl})`, + inline: true, + }); + + await channel.send({ embeds: [embed] }); + console.log(`[Feed Bridge] ✅ Sent post ${post.id} to Discord`); + return { success: true }; + } catch (error) { + console.error("[Feed Bridge] Error sending to Discord:", error); + return { success: false, error: error.message }; + } +} + +async function checkForNewPosts() { + if (!discordClient || !FEED_CHANNEL_ID) return; + + // Prevent overlapping polls - if already polling, skip this run + if (isPolling) { + console.log("[Feed Bridge] Skipping poll - previous poll still in progress"); + return; + } + + isPolling = true; + + try { + const { data: posts, error } = await supabase + .from("community_posts") + .select("*") + .gt("created_at", lastCheckedTime.toISOString()) + .order("created_at", { ascending: true }); + + if (error) { + console.error("[Feed Bridge] Error fetching new posts:", error); + return; + } + + if (posts && posts.length > 0) { + // Update lastCheckedTime IMMEDIATELY after fetching to prevent re-fetching same posts + lastCheckedTime = new Date(posts[posts.length - 1].created_at); + + // Filter out already-processed posts (double safety) + const newPosts = posts.filter(post => !processedPostIds.has(post.id)); + + if (newPosts.length > 0) { + console.log(`[Feed Bridge] Found ${newPosts.length} new post(s)`); + + for (const post of newPosts) { + // Mark as processed BEFORE sending to prevent duplicates + processedPostIds.add(post.id); + + let content = {}; + try { + content = typeof post.content === "string" ? JSON.parse(post.content) : post.content; + } catch { + content = { text: post.content }; + } + + if (content.source === "discord") { + console.log(`[Feed Bridge] Skipping Discord-sourced post ${post.id}`); + continue; + } + + console.log(`[Feed Bridge] Bridging post ${post.id} to Discord...`); + await sendPostToDiscord(post); + } + } + + // Keep processedPostIds from growing indefinitely - trim old entries + if (processedPostIds.size > 1000) { + const idsArray = Array.from(processedPostIds); + idsArray.slice(0, 500).forEach(id => processedPostIds.delete(id)); + } + } + } catch (error) { + console.error("[Feed Bridge] Poll error:", error); + } finally { + isPolling = false; + } +} + +function setupFeedListener(client) { + discordClient = client; + + if (!FEED_CHANNEL_ID) { + console.log("[Feed Bridge] No DISCORD_MAIN_CHAT_CHANNELS configured - bridge disabled"); + return; + } + + lastCheckedTime = new Date(); + + console.log("[Feed Bridge] Starting polling for new posts (every 5 seconds)..."); + + pollInterval = setInterval(checkForNewPosts, POLL_INTERVAL); + + console.log("[Feed Bridge] ✅ Feed bridge ready (channel: " + FEED_CHANNEL_ID + ")"); +} + +function getDiscordClient() { + return discordClient; +} + +function getFeedChannelId() { + return FEED_CHANNEL_ID; +} + +function cleanup() { + if (pollInterval) { + clearInterval(pollInterval); + console.log("[Feed Bridge] Stopped polling"); + } +} + +module.exports = { setupFeedListener, sendPostToDiscord, getDiscordClient, getFeedChannelId, cleanup }; diff --git a/aethex-bot/listeners/sentinel/antiNuke.js b/aethex-bot/listeners/sentinel/antiNuke.js index 161a935..0c0138d 100644 --- a/aethex-bot/listeners/sentinel/antiNuke.js +++ b/aethex-bot/listeners/sentinel/antiNuke.js @@ -1,55 +1,47 @@ -const { EmbedBuilder, AuditLogEvent } = require('discord.js'); +const { EmbedBuilder } = require("discord.js"); module.exports = { - name: 'channelDelete', + name: "channelDelete", async execute(channel, client) { - try { - const guild = channel.guild; - if (!guild) return; - - const auditLogs = await guild.fetchAuditLogs({ - type: AuditLogEvent.ChannelDelete, - limit: 1, - }); - - const log = auditLogs.entries.first(); - if (!log) return; - - const { executor, target } = log; - if (!executor || executor.id === client.user.id) return; - - const heat = client.addHeat(executor.id, 'CHANNEL_DELETE'); - console.log(`[Sentinel] User ${executor.tag} deleted channel. Heat: ${heat}/${client.HEAT_THRESHOLD}`); - - if (heat >= client.HEAT_THRESHOLD) { - const embed = new EmbedBuilder() - .setColor(0xff0000) - .setTitle('ANTI-NUKE TRIGGERED') - .setDescription(`User **${executor.tag}** has been banned for deleting too many channels.`) - .addFields( - { name: 'User ID', value: executor.id, inline: true }, - { name: 'Heat Level', value: `${heat}/${client.HEAT_THRESHOLD}`, inline: true }, - { name: 'Action', value: 'Automatic Ban', inline: true } - ) - .setTimestamp(); - - try { - await guild.members.ban(executor.id, { reason: '[Sentinel] Anti-nuke: Mass channel deletion detected' }); - console.log(`[Sentinel] BANNED ${executor.tag} for mass channel deletion`); - - client.sendAlert(`ANTI-NUKE: Banned ${executor.tag} for mass channel deletion`, embed); - - const owner = await guild.fetchOwner(); - if (owner) { - await owner.send({ content: `[SENTINEL ALERT] User ${executor.tag} was banned for mass channel deletion in ${guild.name}`, embeds: [embed] }).catch(() => {}); - } - } catch (banError) { - console.error('[Sentinel] Failed to ban user:', banError.message); - client.sendAlert(`ANTI-NUKE FAILED: Could not ban ${executor.tag} - ${banError.message}`); - } + if (!channel.guild) return; + + const auditLogs = await channel.guild.fetchAuditLogs({ + type: 12, + limit: 1, + }).catch(() => null); + + if (!auditLogs || !auditLogs.entries.first()) return; + + const entry = auditLogs.entries.first(); + const executor = entry.executor; + + if (!executor || executor.bot) return; + if (executor.id === channel.guild.ownerId) return; + + const heatLevel = client.addHeat(executor.id, "channel_delete"); + console.log(`[Sentinel] ${executor.tag} deleted channel #${channel.name} - Heat: ${heatLevel}/${client.HEAT_THRESHOLD}`); + + if (heatLevel >= client.HEAT_THRESHOLD) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("SENTINEL ALERT: Mass Channel Deletion Detected") + .setDescription(`**User:** ${executor.tag} (${executor.id})\n**Action:** Deleted ${heatLevel} channels in rapid succession\n**Server:** ${channel.guild.name}`) + .setTimestamp(); + + try { + const member = await channel.guild.members.fetch(executor.id); + await member.ban({ reason: "[Sentinel] Anti-nuke triggered: Mass channel deletion" }); + embed.addFields({ name: "Action Taken", value: "User has been banned" }); + } catch (err) { + embed.addFields({ name: "Action Failed", value: `Could not ban user: ${err.message}` }); } - } catch (error) { - console.error('[Sentinel] Channel delete handler error:', error); + + client.sendAlert(`SENTINEL ALERT`, embed); + + try { + const owner = await channel.guild.fetchOwner(); + await owner.send({ content: `[Sentinel Alert] ${executor.tag} was detected deleting multiple channels and has been banned from ${channel.guild.name}.`, embeds: [embed] }); + } catch (e) {} } }, }; diff --git a/aethex-bot/listeners/sentinel/memberBan.js b/aethex-bot/listeners/sentinel/memberBan.js index c847d55..ae67c15 100644 --- a/aethex-bot/listeners/sentinel/memberBan.js +++ b/aethex-bot/listeners/sentinel/memberBan.js @@ -1,53 +1,48 @@ -const { EmbedBuilder, AuditLogEvent } = require('discord.js'); +const { EmbedBuilder } = require("discord.js"); module.exports = { - name: 'guildBanAdd', + name: "guildBanAdd", async execute(ban, client) { - try { - const guild = ban.guild; - - const auditLogs = await guild.fetchAuditLogs({ - type: AuditLogEvent.MemberBanAdd, - limit: 1, - }); - - const log = auditLogs.entries.first(); - if (!log) return; - - const { executor, target } = log; - if (!executor || executor.id === client.user.id) return; - - const heat = client.addHeat(executor.id, 'MEMBER_BAN_ADD'); - console.log(`[Sentinel] User ${executor.tag} banned ${target.tag}. Heat: ${heat}/${client.HEAT_THRESHOLD}`); - - if (heat >= client.HEAT_THRESHOLD) { - const embed = new EmbedBuilder() - .setColor(0xff0000) - .setTitle('ANTI-NUKE TRIGGERED') - .setDescription(`User **${executor.tag}** has been banned for mass banning members.`) - .addFields( - { name: 'User ID', value: executor.id, inline: true }, - { name: 'Heat Level', value: `${heat}/${client.HEAT_THRESHOLD}`, inline: true }, - { name: 'Action', value: 'Mass Ban Detected', inline: true } - ) - .setTimestamp(); - - try { - await guild.members.ban(executor.id, { reason: '[Sentinel] Anti-nuke: Mass banning detected' }); - console.log(`[Sentinel] BANNED ${executor.tag} for mass banning`); - - client.sendAlert(`ANTI-NUKE: Banned ${executor.tag} for mass banning`, embed); - - const owner = await guild.fetchOwner(); - if (owner) { - await owner.send({ content: `[SENTINEL ALERT] User ${executor.tag} was banned for mass banning in ${guild.name}`, embeds: [embed] }).catch(() => {}); - } - } catch (banError) { - console.error('[Sentinel] Failed to ban user:', banError.message); - } + const guild = ban.guild; + + const auditLogs = await guild.fetchAuditLogs({ + type: 22, + limit: 1, + }).catch(() => null); + + if (!auditLogs || !auditLogs.entries.first()) return; + + const entry = auditLogs.entries.first(); + const executor = entry.executor; + + if (!executor || executor.bot) return; + if (executor.id === guild.ownerId) return; + if (executor.id === client.user.id) return; + + const heatLevel = client.addHeat(executor.id, "member_ban"); + console.log(`[Sentinel] ${executor.tag} banned ${ban.user.tag} - Heat: ${heatLevel}/${client.HEAT_THRESHOLD}`); + + if (heatLevel >= client.HEAT_THRESHOLD) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("SENTINEL ALERT: Mass Ban Detected") + .setDescription(`**User:** ${executor.tag} (${executor.id})\n**Action:** Banned ${heatLevel} members in rapid succession\n**Server:** ${guild.name}`) + .setTimestamp(); + + try { + const member = await guild.members.fetch(executor.id); + await member.ban({ reason: "[Sentinel] Anti-nuke triggered: Mass banning" }); + embed.addFields({ name: "Action Taken", value: "User has been banned" }); + } catch (err) { + embed.addFields({ name: "Action Failed", value: `Could not ban user: ${err.message}` }); } - } catch (error) { - console.error('[Sentinel] Member ban handler error:', error); + + client.sendAlert(`SENTINEL ALERT`, embed); + + try { + const owner = await guild.fetchOwner(); + await owner.send({ content: `[Sentinel Alert] ${executor.tag} was detected mass banning members and has been banned from ${guild.name}.`, embeds: [embed] }); + } catch (e) {} } }, }; diff --git a/aethex-bot/listeners/sentinel/memberKick.js b/aethex-bot/listeners/sentinel/memberKick.js index c1fd69c..847f0c7 100644 --- a/aethex-bot/listeners/sentinel/memberKick.js +++ b/aethex-bot/listeners/sentinel/memberKick.js @@ -1,56 +1,51 @@ -const { EmbedBuilder, AuditLogEvent } = require('discord.js'); +const { EmbedBuilder } = require("discord.js"); module.exports = { - name: 'guildMemberRemove', + name: "guildMemberRemove", async execute(member, client) { - try { - const guild = member.guild; - - const auditLogs = await guild.fetchAuditLogs({ - type: AuditLogEvent.MemberKick, - limit: 1, - }); - - const log = auditLogs.entries.first(); - if (!log) return; - - const { executor, target, createdTimestamp } = log; - if (!executor || executor.id === client.user.id) return; - - if (Date.now() - createdTimestamp > 5000) return; - if (target.id !== member.id) return; - - const heat = client.addHeat(executor.id, 'MEMBER_KICK'); - console.log(`[Sentinel] User ${executor.tag} kicked ${member.user.tag}. Heat: ${heat}/${client.HEAT_THRESHOLD}`); - - if (heat >= client.HEAT_THRESHOLD) { - const embed = new EmbedBuilder() - .setColor(0xff0000) - .setTitle('ANTI-NUKE TRIGGERED') - .setDescription(`User **${executor.tag}** has been banned for mass kicking members.`) - .addFields( - { name: 'User ID', value: executor.id, inline: true }, - { name: 'Heat Level', value: `${heat}/${client.HEAT_THRESHOLD}`, inline: true }, - { name: 'Action', value: 'Mass Kick Detected', inline: true } - ) - .setTimestamp(); - - try { - await guild.members.ban(executor.id, { reason: '[Sentinel] Anti-nuke: Mass kicking detected' }); - console.log(`[Sentinel] BANNED ${executor.tag} for mass kicking`); - - client.sendAlert(`ANTI-NUKE: Banned ${executor.tag} for mass kicking`, embed); - - const owner = await guild.fetchOwner(); - if (owner) { - await owner.send({ content: `[SENTINEL ALERT] User ${executor.tag} was banned for mass kicking in ${guild.name}`, embeds: [embed] }).catch(() => {}); - } - } catch (banError) { - console.error('[Sentinel] Failed to ban user:', banError.message); - } + const guild = member.guild; + + const auditLogs = await guild.fetchAuditLogs({ + type: 20, + limit: 1, + }).catch(() => null); + + if (!auditLogs || !auditLogs.entries.first()) return; + + const entry = auditLogs.entries.first(); + + if (Date.now() - entry.createdTimestamp > 5000) return; + if (entry.target.id !== member.id) return; + + const executor = entry.executor; + + if (!executor || executor.bot) return; + if (executor.id === guild.ownerId) return; + + const heatLevel = client.addHeat(executor.id, "member_kick"); + console.log(`[Sentinel] ${executor.tag} kicked ${member.user.tag} - Heat: ${heatLevel}/${client.HEAT_THRESHOLD}`); + + if (heatLevel >= client.HEAT_THRESHOLD) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("SENTINEL ALERT: Mass Kick Detected") + .setDescription(`**User:** ${executor.tag} (${executor.id})\n**Action:** Kicked ${heatLevel} members in rapid succession\n**Server:** ${guild.name}`) + .setTimestamp(); + + try { + const kickerMember = await guild.members.fetch(executor.id); + await kickerMember.ban({ reason: "[Sentinel] Anti-nuke triggered: Mass kicking" }); + embed.addFields({ name: "Action Taken", value: "User has been banned" }); + } catch (err) { + embed.addFields({ name: "Action Failed", value: `Could not ban user: ${err.message}` }); } - } catch (error) { - console.error('[Sentinel] Member kick handler error:', error); + + client.sendAlert(`SENTINEL ALERT`, embed); + + try { + const owner = await guild.fetchOwner(); + await owner.send({ content: `[Sentinel Alert] ${executor.tag} was detected mass kicking members and has been banned from ${guild.name}.`, embeds: [embed] }); + } catch (e) {} } }, }; diff --git a/aethex-bot/listeners/sentinel/roleDelete.js b/aethex-bot/listeners/sentinel/roleDelete.js index 3fe5690..2dcb751 100644 --- a/aethex-bot/listeners/sentinel/roleDelete.js +++ b/aethex-bot/listeners/sentinel/roleDelete.js @@ -1,54 +1,47 @@ -const { EmbedBuilder, AuditLogEvent } = require('discord.js'); +const { EmbedBuilder } = require("discord.js"); module.exports = { - name: 'roleDelete', + name: "roleDelete", async execute(role, client) { - try { - const guild = role.guild; - if (!guild) return; - - const auditLogs = await guild.fetchAuditLogs({ - type: AuditLogEvent.RoleDelete, - limit: 1, - }); - - const log = auditLogs.entries.first(); - if (!log) return; - - const { executor } = log; - if (!executor || executor.id === client.user.id) return; - - const heat = client.addHeat(executor.id, 'ROLE_DELETE'); - console.log(`[Sentinel] User ${executor.tag} deleted role ${role.name}. Heat: ${heat}/${client.HEAT_THRESHOLD}`); - - if (heat >= client.HEAT_THRESHOLD) { - const embed = new EmbedBuilder() - .setColor(0xff0000) - .setTitle('ANTI-NUKE TRIGGERED') - .setDescription(`User **${executor.tag}** has been banned for deleting too many roles.`) - .addFields( - { name: 'User ID', value: executor.id, inline: true }, - { name: 'Heat Level', value: `${heat}/${client.HEAT_THRESHOLD}`, inline: true }, - { name: 'Deleted Role', value: role.name, inline: true } - ) - .setTimestamp(); - - try { - await guild.members.ban(executor.id, { reason: '[Sentinel] Anti-nuke: Mass role deletion detected' }); - console.log(`[Sentinel] BANNED ${executor.tag} for mass role deletion`); - - client.sendAlert(`ANTI-NUKE: Banned ${executor.tag} for mass role deletion`, embed); - - const owner = await guild.fetchOwner(); - if (owner) { - await owner.send({ content: `[SENTINEL ALERT] User ${executor.tag} was banned for mass role deletion in ${guild.name}`, embeds: [embed] }).catch(() => {}); - } - } catch (banError) { - console.error('[Sentinel] Failed to ban user:', banError.message); - } + if (!role.guild) return; + + const auditLogs = await role.guild.fetchAuditLogs({ + type: 32, + limit: 1, + }).catch(() => null); + + if (!auditLogs || !auditLogs.entries.first()) return; + + const entry = auditLogs.entries.first(); + const executor = entry.executor; + + if (!executor || executor.bot) return; + if (executor.id === role.guild.ownerId) return; + + const heatLevel = client.addHeat(executor.id, "role_delete"); + console.log(`[Sentinel] ${executor.tag} deleted role @${role.name} - Heat: ${heatLevel}/${client.HEAT_THRESHOLD}`); + + if (heatLevel >= client.HEAT_THRESHOLD) { + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("SENTINEL ALERT: Mass Role Deletion Detected") + .setDescription(`**User:** ${executor.tag} (${executor.id})\n**Action:** Deleted ${heatLevel} roles in rapid succession\n**Server:** ${role.guild.name}`) + .setTimestamp(); + + try { + const member = await role.guild.members.fetch(executor.id); + await member.ban({ reason: "[Sentinel] Anti-nuke triggered: Mass role deletion" }); + embed.addFields({ name: "Action Taken", value: "User has been banned" }); + } catch (err) { + embed.addFields({ name: "Action Failed", value: `Could not ban user: ${err.message}` }); } - } catch (error) { - console.error('[Sentinel] Role delete handler error:', error); + + client.sendAlert(`SENTINEL ALERT`, embed); + + try { + const owner = await role.guild.fetchOwner(); + await owner.send({ content: `[Sentinel Alert] ${executor.tag} was detected deleting multiple roles and has been banned from ${role.guild.name}.`, embeds: [embed] }); + } catch (e) {} } }, }; diff --git a/aethex-bot/scripts/register-commands.js b/aethex-bot/scripts/register-commands.js index 8c84277..27ffb8d 100644 --- a/aethex-bot/scripts/register-commands.js +++ b/aethex-bot/scripts/register-commands.js @@ -1,89 +1,110 @@ -const { REST, Routes } = require('discord.js'); -const fs = require('fs'); -const path = require('path'); -require('dotenv').config(); +const { REST, Routes } = require("discord.js"); +const fs = require("fs"); +const path = require("path"); +require("dotenv").config(); -const token = process.env.DISCORD_BOT_TOKEN; -const clientId = process.env.DISCORD_CLIENT_ID; +// Validate environment variables +const requiredEnvVars = ["DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID"]; -if (!token) { - console.error('Missing DISCORD_BOT_TOKEN'); +const missingVars = requiredEnvVars.filter((envVar) => !process.env[envVar]); +if (missingVars.length > 0) { + console.error( + "❌ FATAL ERROR: Missing required environment variables:", + missingVars.join(", "), + ); + console.error("\nPlease set these before running command registration:"); + missingVars.forEach((envVar) => { + console.error(` - ${envVar}`); + }); process.exit(1); } -if (!clientId) { - console.error('Missing DISCORD_CLIENT_ID'); - process.exit(1); -} - -const commandsPath = path.join(__dirname, '../commands'); -const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); +// Load commands from commands directory +const commandsPath = path.join(__dirname, "../commands"); +const commandFiles = fs + .readdirSync(commandsPath) + .filter((file) => file.endsWith(".js")); const commands = []; for (const file of commandFiles) { const filePath = path.join(commandsPath, file); const command = require(filePath); - if ('data' in command && 'execute' in command) { + if ("data" in command && "execute" in command) { commands.push(command.data.toJSON()); - console.log(`Loaded command: ${command.data.name}`); + console.log(`✅ Loaded command: ${command.data.name}`); } } -const rest = new REST({ version: '10' }).setToken(token); - -(async () => { +// Register commands with Discord API +async function registerCommands() { try { - console.log(`\nFetching existing commands...`); - - const existingCommands = await rest.get( - Routes.applicationCommands(clientId) + const rest = new REST({ version: "10" }).setToken( + process.env.DISCORD_BOT_TOKEN, ); - - console.log(`Found ${existingCommands.length} existing commands`); - - const entryPointCommands = existingCommands.filter(cmd => cmd.type === 4); - - const allCommands = [...commands, ...entryPointCommands.map(cmd => ({ - name: cmd.name, - description: cmd.description, - type: cmd.type, - handler: cmd.handler, - }))]; - - console.log(`Registering ${commands.length} commands (preserving ${entryPointCommands.length} entry points)...`); - - const data = await rest.put( - Routes.applicationCommands(clientId), - { body: allCommands } + + console.log(`\n📝 Registering ${commands.length} slash commands...`); + console.log( + "⚠️ This will co-exist with Discord's auto-generated Entry Point command.\n", ); - - console.log(`\nSuccessfully registered ${data.length} commands:`); - data.forEach(cmd => console.log(` - /${cmd.name}`)); - } catch (error) { - if (error.code === 50240) { - console.warn('\nEntry Point detected. Registering individually...'); - - let successCount = 0; - for (const command of commands) { - try { - await rest.post( - Routes.applicationCommands(clientId), - { body: command } - ); - console.log(` + /${command.name}`); - successCount++; - } catch (postError) { - if (postError.code === 50045) { - console.log(` ~ /${command.name} (already exists)`); - } else { - console.error(` x /${command.name}: ${postError.message}`); + + try { + const data = await rest.put( + Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), + { body: commands }, + ); + console.log(`✅ Successfully registered ${data.length} slash commands.`); + console.log("\n🎉 Command registration complete!"); + console.log("ℹ️ Your commands are now live in Discord."); + console.log( + "ℹ️ The Entry Point command (for Activities) will be managed by Discord.\n", + ); + } catch (error) { + // Handle Entry Point command conflict + if (error.code === 50240) { + console.warn( + "⚠️ Error 50240: Entry Point command detected (Discord Activity enabled).", + ); + console.warn("Registering commands individually...\n"); + + let successCount = 0; + for (const command of commands) { + try { + await rest.post( + Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), + { body: command }, + ); + successCount++; + } catch (postError) { + if (postError.code === 50045) { + console.warn( + ` ⚠️ ${command.name}: Already registered (skipping)`, + ); + } else { + console.error(` ❌ ${command.name}: ${postError.message}`); + } } } + + console.log( + `\n✅ Registered ${successCount} slash commands (individual mode).`, + ); + console.log("🎉 Command registration complete!"); + console.log( + "ℹ️ The Entry Point command will be managed by Discord.\n", + ); + } else { + throw error; } - console.log(`\nRegistered ${successCount} commands individually.`); - } else { - console.error('Error registering commands:', error); } + } catch (error) { + console.error( + "❌ Fatal error registering commands:", + error.message || error, + ); + process.exit(1); } -})(); +} + +// Run registration +registerCommands(); diff --git a/attached_assets/discord-bot-source/discord-bot/.env.example b/attached_assets/discord-bot-source/discord-bot/.env.example new file mode 100644 index 0000000..a4ddc91 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/.env.example @@ -0,0 +1,23 @@ +# Discord Bot Configuration +DISCORD_BOT_TOKEN=your_bot_token_here +DISCORD_CLIENT_ID=your_client_id_here +DISCORD_PUBLIC_KEY=your_public_key_here + +# Supabase Configuration +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_ROLE=your_service_role_key_here + +# API Configuration +VITE_API_BASE=https://api.aethex.dev + +# Discord Feed Webhook Configuration +DISCORD_FEED_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN +DISCORD_FEED_GUILD_ID=515711457946632232 +DISCORD_FEED_CHANNEL_ID=1425114041021497454 + +# Discord Announcement Channels (comma-separated channel IDs) +DISCORD_ANNOUNCEMENT_CHANNELS=1435667453244866702,your_other_channel_ids_here + +# Discord Role Mappings (optional) +DISCORD_FOUNDER_ROLE_ID=your_role_id_here +DISCORD_ADMIN_ROLE_ID=your_admin_role_id_here diff --git a/attached_assets/discord-bot-source/discord-bot/DEPLOYMENT_GUIDE.md b/attached_assets/discord-bot-source/discord-bot/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..c90a3aa --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/DEPLOYMENT_GUIDE.md @@ -0,0 +1,211 @@ +# AeThex Discord Bot - Spaceship Deployment Guide + +## 📋 Prerequisites + +- Spaceship hosting account with Node.js support +- Discord bot credentials (already in your environment variables) +- Supabase project credentials +- Git access to your repository + +## 🚀 Deployment Steps + +### Step 1: Prepare the Bot Directory + +Ensure all bot files are committed: + +``` +code/discord-bot/ +├── bot.js +├── package.json +├── .env.example +├── Dockerfile +└── commands/ + ├── verify.js + ├── set-realm.js + ├── profile.js + ├── unlink.js + └── verify-role.js +``` + +### Step 2: Create Node.js App on Spaceship + +1. Log in to your Spaceship hosting dashboard +2. Click "Create New Application" +3. Select **Node.js** as the runtime +4. Name it: `aethex-discord-bot` +5. Select your repository and branch + +### Step 3: Configure Environment Variables + +In Spaceship Application Settings → Environment Variables, add: + +``` +DISCORD_BOT_TOKEN= +DISCORD_CLIENT_ID= +DISCORD_PUBLIC_KEY= +SUPABASE_URL= +SUPABASE_SERVICE_ROLE= +BOT_PORT=3000 +NODE_ENV=production +``` + +**Note:** Get these values from: + +- Discord Developer Portal: Applications → Your Bot → Token & General Information +- Supabase Dashboard: Project Settings → API + +### Step 4: Configure Build & Run Settings + +In Spaceship Application Settings: + +**Build Command:** + +```bash +npm install +``` + +**Start Command:** + +```bash +npm start +``` + +**Root Directory:** + +``` +code/discord-bot +``` + +### Step 5: Deploy + +1. Click "Deploy" in Spaceship dashboard +2. Monitor logs for: + ``` + ✅ Bot logged in as # + 📡 Listening in X server(s) + ✅ Successfully registered X slash commands. + ``` + +### Step 6: Verify Bot is Online + +Once deployed: + +1. Go to your Discord server +2. Type `/verify` - the command autocomplete should appear +3. Bot should be online with status "Listening to /verify to link your AeThex account" + +## 📡 Discord Bot Endpoints + +The bot will be accessible at: + +``` +https:/// +``` + +The bot uses Discord's WebSocket connection (not HTTP), so it doesn't need to expose HTTP endpoints. It listens to Discord events via `client.login(DISCORD_BOT_TOKEN)`. + +## 🔌 API Integration + +Frontend calls to link Discord accounts: + +- **Endpoint:** `POST /api/discord/link` +- **Body:** `{ verification_code, user_id }` +- **Response:** `{ success: true, message: "..." }` + +Discord Verify page (`/discord-verify?code=XXX`) will automatically: + +1. Call `/api/discord/link` with the verification code +2. Link the Discord ID to the AeThex user account +3. Redirect to dashboard on success + +## 🛠️ Debugging + +### Check bot logs on Spaceship: + +- Application → Logs +- Filter for "bot.js" or "error" + +### Common issues: + +**"Discord bot not responding to commands"** + +- Check: `DISCORD_BOT_TOKEN` is correct +- Check: Bot is added to the Discord server with "applications.commands" scope +- Check: Spaceship logs show "✅ Logged in" + +**"Supabase verification fails"** + +- Check: `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE` are correct +- Check: `discord_links` and `discord_verifications` tables exist +- Run migration: `code/supabase/migrations/20250107_add_discord_integration.sql` + +**"Slash commands not appearing in Discord"** + +- Check: Logs show "✅ Successfully registered X slash commands" +- Discord may need 1-2 minutes to sync commands +- Try typing `/` in Discord to force refresh +- Check: Bot has "applications.commands" permission in server + +## 📊 Monitoring + +### Key metrics to monitor: + +- Bot uptime (should be 24/7) +- Command usage (in Supabase) +- Verification code usage (in Supabase) +- Discord role sync success rate + +### View in Admin Dashboard: + +- AeThex Admin Panel → Discord Management tab +- Shows: + - Bot status + - Servers connected + - Linked accounts count + - Role mapping status + +## 🔄 Updating the Bot + +1. Make code changes locally +2. Test with `npm start` +3. Commit and push to your branch +4. Spaceship will auto-deploy on push +5. Monitor logs to ensure deployment succeeds + +## 🆘 Support + +For issues: + +1. Check Spaceship logs +2. Review `/api/discord/link` endpoint response +3. Verify all environment variables are set correctly +4. Ensure Supabase tables exist and have correct schema + +## 📝 Database Setup + +Run this migration on your AeThex Supabase: + +```sql +-- From code/supabase/migrations/20250107_add_discord_integration.sql +-- This creates: +-- - discord_links (links Discord ID to AeThex user) +-- - discord_verifications (temporary verification codes) +-- - discord_role_mappings (realm → Discord role mapping) +-- - discord_user_roles (tracking assigned roles) +``` + +## 🎉 You're All Set! + +Once deployed, users can: + +1. Click "Link Discord" in their profile settings +2. Type `/verify` in Discord +3. Click the verification link +4. Their Discord account is linked to their AeThex account +5. They can use `/set-realm`, `/profile`, `/unlink`, and `/verify-role` commands + +--- + +**Deployment Date:** `` +**Bot Status:** `` +**Last Updated:** `` diff --git a/attached_assets/discord-bot-source/discord-bot/Dockerfile b/attached_assets/discord-bot-source/discord-bot/Dockerfile new file mode 100644 index 0000000..279b52f --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/Dockerfile @@ -0,0 +1,22 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --production + +# Copy bot source +COPY . . + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" + +# Start bot +CMD ["npm", "start"] diff --git a/attached_assets/discord-bot-source/discord-bot/bot.js b/attached_assets/discord-bot-source/discord-bot/bot.js new file mode 100644 index 0000000..401b132 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/bot.js @@ -0,0 +1,803 @@ +const { + Client, + GatewayIntentBits, + REST, + Routes, + Collection, + EmbedBuilder, +} = require("discord.js"); +const { createClient } = require("@supabase/supabase-js"); +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +require("dotenv").config(); + +const { setupFeedListener, sendPostToDiscord, getFeedChannelId } = require("./listeners/feedSync"); + +// Validate environment variables +const requiredEnvVars = [ + "DISCORD_BOT_TOKEN", + "DISCORD_CLIENT_ID", + "SUPABASE_URL", + "SUPABASE_SERVICE_ROLE", +]; + +const missingVars = requiredEnvVars.filter((envVar) => !process.env[envVar]); +if (missingVars.length > 0) { + console.error( + "❌ FATAL ERROR: Missing required environment variables:", + missingVars.join(", "), + ); + console.error("\nPlease set these in your Discloud/hosting environment:"); + missingVars.forEach((envVar) => { + console.error(` - ${envVar}`); + }); + process.exit(1); +} + +// Validate token format +const token = process.env.DISCORD_BOT_TOKEN; +if (!token || token.length < 20) { + console.error("❌ FATAL ERROR: DISCORD_BOT_TOKEN is empty or invalid"); + console.error(` Length: ${token ? token.length : 0}`); + process.exit(1); +} + +console.log("[Token] Bot token loaded (length: " + token.length + " chars)"); + +// Initialize Discord client with message intents for feed sync +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], +}); + +// Initialize Supabase +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE, +); + +// Store slash commands +client.commands = new Collection(); + +// Load commands from commands directory +const commandsPath = path.join(__dirname, "commands"); +const commandFiles = fs + .readdirSync(commandsPath) + .filter((file) => file.endsWith(".js")); + +for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + const command = require(filePath); + if ("data" in command && "execute" in command) { + client.commands.set(command.data.name, command); + console.log(`✅ Loaded command: ${command.data.name}`); + } +} + +// Load event handlers from events directory +const eventsPath = path.join(__dirname, "events"); +if (fs.existsSync(eventsPath)) { + const eventFiles = fs + .readdirSync(eventsPath) + .filter((file) => file.endsWith(".js")); + + for (const file of eventFiles) { + const filePath = path.join(eventsPath, file); + const event = require(filePath); + if ("name" in event && "execute" in event) { + client.on(event.name, (...args) => + event.execute(...args, client, supabase), + ); + console.log(`✅ Loaded event listener: ${event.name}`); + } + } +} + +// Slash command interaction handler +client.on("interactionCreate", async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const command = client.commands.get(interaction.commandName); + + if (!command) { + console.warn( + `⚠️ No command matching ${interaction.commandName} was found.`, + ); + return; + } + + try { + await command.execute(interaction, supabase, client); + } 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 }); + } + } +}); + +// IMPORTANT: Commands are now registered via a separate script +// Run this ONCE during deployment: npm run register-commands +// This prevents Error 50240 (Entry Point conflict) when Activities are enabled +// The bot will simply load and listen for the already-registered commands + +// Define all commands for registration +const COMMANDS_TO_REGISTER = [ + { + name: "verify", + description: "Link your Discord account to AeThex", + }, + { + name: "set-realm", + description: "Choose your primary arm/realm (Labs, GameForge, Corp, etc.)", + options: [ + { + name: "realm", + type: 3, + description: "Your primary realm", + required: true, + choices: [ + { name: "Labs", value: "labs" }, + { name: "GameForge", value: "gameforge" }, + { name: "Corp", value: "corp" }, + { name: "Foundation", value: "foundation" }, + { name: "Dev-Link", value: "devlink" }, + ], + }, + ], + }, + { + name: "profile", + description: "View your linked AeThex profile", + }, + { + name: "unlink", + description: "Disconnect your Discord account from AeThex", + }, + { + name: "verify-role", + description: "Check your assigned Discord roles", + }, + { + name: "help", + description: "View all AeThex bot commands and features", + }, + { + name: "stats", + description: "View your AeThex statistics and activity", + }, + { + name: "leaderboard", + description: "View the top AeThex contributors", + options: [ + { + name: "category", + type: 3, + description: "Leaderboard category", + required: false, + choices: [ + { name: "Most Active (Posts)", value: "posts" }, + { name: "Most Liked", value: "likes" }, + { name: "Top Creators", value: "creators" }, + ], + }, + ], + }, + { + name: "post", + description: "Create a post in the AeThex community feed", + options: [ + { + name: "content", + type: 3, + description: "Your post content", + required: true, + max_length: 500, + }, + { + name: "category", + type: 3, + description: "Post category", + required: false, + choices: [ + { name: "General", value: "general" }, + { name: "Project Update", value: "project_update" }, + { name: "Question", value: "question" }, + { name: "Idea", value: "idea" }, + { name: "Announcement", value: "announcement" }, + ], + }, + { + name: "image", + type: 11, + description: "Attach an image to your post", + required: false, + }, + ], + }, +]; + +// Function to register commands with Discord +async function registerDiscordCommands() { + try { + const rest = new REST({ version: "10" }).setToken( + process.env.DISCORD_BOT_TOKEN, + ); + + console.log( + `📝 Registering ${COMMANDS_TO_REGISTER.length} slash commands...`, + ); + + try { + // Try bulk update first + const data = await rest.put( + Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), + { body: COMMANDS_TO_REGISTER }, + ); + + console.log(`✅ Successfully registered ${data.length} slash commands`); + return { success: true, count: data.length, results: null }; + } catch (bulkError) { + // Handle Error 50240 (Entry Point conflict) + if (bulkError.code === 50240) { + console.warn( + "⚠️ Error 50240: Entry Point detected. Registering individually...", + ); + + const results = []; + let successCount = 0; + let skipCount = 0; + + for (const command of COMMANDS_TO_REGISTER) { + try { + const posted = await rest.post( + Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), + { body: command }, + ); + results.push({ + name: command.name, + status: "registered", + id: posted.id, + }); + successCount++; + } catch (postError) { + if (postError.code === 50045) { + results.push({ + name: command.name, + status: "already_exists", + }); + skipCount++; + } else { + results.push({ + name: command.name, + status: "error", + error: postError.message, + }); + } + } + } + + console.log( + `✅ Registration complete: ${successCount} new, ${skipCount} already existed`, + ); + return { + success: true, + count: successCount, + skipped: skipCount, + results, + }; + } + + throw bulkError; + } + } catch (error) { + console.error("❌ Failed to register commands:", error); + return { success: false, error: error.message }; + } +} + +// Start HTTP health check server +const healthPort = process.env.HEALTH_PORT || 8080; +const ADMIN_TOKEN = process.env.DISCORD_ADMIN_TOKEN || "aethex-bot-admin"; + +// Helper to check admin authentication +const checkAdminAuth = (req) => { + const authHeader = req.headers.authorization; + return authHeader === `Bearer ${ADMIN_TOKEN}`; +}; + +http + .createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.setHeader("Content-Type", "application/json"); + + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + + if (req.url === "/health") { + res.writeHead(200); + res.end( + JSON.stringify({ + status: "online", + guilds: client.guilds.cache.size, + commands: client.commands.size, + uptime: Math.floor(process.uptime()), + timestamp: new Date().toISOString(), + }), + ); + return; + } + + // GET /bot-status - Comprehensive bot status for management panel (requires auth) + if (req.url === "/bot-status") { + if (!checkAdminAuth(req)) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); + return; + } + + const channelId = getFeedChannelId(); + const guilds = client.guilds.cache.map((guild) => ({ + id: guild.id, + name: guild.name, + memberCount: guild.memberCount, + icon: guild.iconURL(), + })); + + res.writeHead(200); + res.end( + JSON.stringify({ + status: client.isReady() ? "online" : "offline", + bot: { + tag: client.user?.tag || "Not logged in", + id: client.user?.id, + avatar: client.user?.displayAvatarURL(), + }, + guilds: guilds, + guildCount: client.guilds.cache.size, + commands: Array.from(client.commands.keys()), + commandCount: client.commands.size, + uptime: Math.floor(process.uptime()), + feedBridge: { + enabled: !!channelId, + channelId: channelId, + }, + timestamp: new Date().toISOString(), + }), + ); + return; + } + + // GET /linked-users - Get all Discord-linked users (requires auth, sanitizes PII) + if (req.url === "/linked-users") { + if (!checkAdminAuth(req)) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); + return; + } + + (async () => { + try { + const { data: links, error } = await supabase + .from("discord_links") + .select("discord_id, user_id, primary_arm, created_at") + .order("created_at", { ascending: false }) + .limit(50); + + if (error) throw error; + + const enrichedLinks = await Promise.all( + (links || []).map(async (link) => { + const { data: profile } = await supabase + .from("user_profiles") + .select("username, avatar_url") + .eq("id", link.user_id) + .single(); + + return { + discord_id: link.discord_id.slice(0, 6) + "***", + user_id: link.user_id.slice(0, 8) + "...", + primary_arm: link.primary_arm, + created_at: link.created_at, + profile: profile ? { + username: profile.username, + avatar_url: profile.avatar_url, + } : null, + }; + }) + ); + + res.writeHead(200); + res.end(JSON.stringify({ success: true, links: enrichedLinks, count: enrichedLinks.length })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ success: false, error: error.message })); + } + })(); + return; + } + + // GET /command-stats - Get command usage statistics (requires auth) + if (req.url === "/command-stats") { + if (!checkAdminAuth(req)) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); + return; + } + + (async () => { + try { + const stats = { + commands: COMMANDS_TO_REGISTER.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + options: cmd.options?.length || 0, + })), + totalCommands: COMMANDS_TO_REGISTER.length, + }; + + res.writeHead(200); + res.end(JSON.stringify({ success: true, stats })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ success: false, error: error.message })); + } + })(); + return; + } + + // GET /feed-stats - Get feed bridge statistics (requires auth) + if (req.url === "/feed-stats") { + if (!checkAdminAuth(req)) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); + return; + } + + (async () => { + try { + const { count: totalPosts } = await supabase + .from("community_posts") + .select("*", { count: "exact", head: true }); + + const { count: discordPosts } = await supabase + .from("community_posts") + .select("*", { count: "exact", head: true }) + .eq("source", "discord"); + + const { count: websitePosts } = await supabase + .from("community_posts") + .select("*", { count: "exact", head: true }) + .or("source.is.null,source.neq.discord"); + + const { data: recentPosts } = await supabase + .from("community_posts") + .select("id, content, source, created_at") + .order("created_at", { ascending: false }) + .limit(10); + + res.writeHead(200); + res.end( + JSON.stringify({ + success: true, + stats: { + totalPosts: totalPosts || 0, + discordPosts: discordPosts || 0, + websitePosts: websitePosts || 0, + recentPosts: (recentPosts || []).map(p => ({ + id: p.id, + content: p.content?.slice(0, 100) + (p.content?.length > 100 ? "..." : ""), + source: p.source, + created_at: p.created_at, + })), + }, + }) + ); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ success: false, error: error.message })); + } + })(); + return; + } + + // POST /send-to-discord - Send a post from AeThex to Discord channel + if (req.url === "/send-to-discord" && req.method === "POST") { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", async () => { + try { + // Simple auth check + const authHeader = req.headers.authorization; + const expectedToken = process.env.DISCORD_BRIDGE_TOKEN || "aethex-bridge"; + if (authHeader !== `Bearer ${expectedToken}`) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + + const post = JSON.parse(body); + console.log("[API] Received post to send to Discord:", post.id); + + const result = await sendPostToDiscord(post, post.author); + res.writeHead(result.success ? 200 : 500); + res.end(JSON.stringify(result)); + } catch (error) { + console.error("[API] Error processing send-to-discord:", error); + res.writeHead(500); + res.end(JSON.stringify({ error: error.message })); + } + }); + return; + } + + // GET /bridge-status - Check if bridge is configured + if (req.url === "/bridge-status") { + const channelId = getFeedChannelId(); + res.writeHead(200); + res.end( + JSON.stringify({ + enabled: !!channelId, + channelId: channelId, + botReady: client.isReady(), + }), + ); + return; + } + + if (req.url === "/register-commands") { + if (req.method === "GET") { + if (!checkAdminAuth(req)) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); + return; + } + // Show HTML form with button + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + + Register Discord Commands + + + +
+

🤖 Discord Commands Registration

+

Click the button below to register all Discord slash commands (/verify, /set-realm, /profile, /unlink, /verify-role)

+ + + +
⏳ Registering... please wait...
+
+
+ + + + + `); + return; + } + + if (req.method === "POST") { + // Verify admin token + if (!checkAdminAuth(req)) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); + return; + } + + // Register commands + registerDiscordCommands().then((result) => { + if (result.success) { + res.writeHead(200); + res.end(JSON.stringify(result)); + } else { + res.writeHead(500); + res.end(JSON.stringify(result)); + } + }); + return; + } + } + + res.writeHead(404); + res.end(JSON.stringify({ error: "Not found" })); + }) + .listen(healthPort, () => { + console.log(`���� Health check server running on port ${healthPort}`); + console.log( + `📝 Register commands at: POST http://localhost:${healthPort}/register-commands`, + ); + }); + +// Login with error handling +client.login(process.env.DISCORD_BOT_TOKEN).catch((error) => { + console.error("❌ FATAL ERROR: Failed to login to Discord"); + console.error(` Error Code: ${error.code}`); + console.error(` Error Message: ${error.message}`); + + if (error.code === "TokenInvalid") { + console.error("\n⚠️ DISCORD_BOT_TOKEN is invalid!"); + console.error(" Possible causes:"); + console.error(" 1. Token has been revoked by Discord"); + console.error(" 2. Token has expired"); + console.error(" 3. Token format is incorrect"); + console.error( + "\n Solution: Get a new bot token from Discord Developer Portal", + ); + console.error(" https://discord.com/developers/applications"); + } + + process.exit(1); +}); + +client.once("ready", () => { + console.log(`✅ Bot logged in as ${client.user.tag}`); + console.log(`📡 Listening in ${client.guilds.cache.size} server(s)`); + console.log("ℹ️ Commands are registered via: npm run register-commands"); + + // Set bot status + client.user.setActivity("/verify to link your AeThex account", { + type: "LISTENING", + }); + + // Setup bidirectional feed bridge (AeThex → Discord) + setupFeedListener(client); +}); + +// Error handling +process.on("unhandledRejection", (error) => { + console.error("❌ Unhandled Promise Rejection:", error); +}); + +process.on("uncaughtException", (error) => { + console.error("❌ Uncaught Exception:", error); + process.exit(1); +}); + +module.exports = client; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/help.js b/attached_assets/discord-bot-source/discord-bot/commands/help.js new file mode 100644 index 0000000..324b1dd --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/help.js @@ -0,0 +1,55 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("help") + .setDescription("View all AeThex bot commands and features"), + + async execute(interaction) { + const embed = new EmbedBuilder() + .setColor(0x7289da) + .setTitle("🤖 AeThex Bot Commands") + .setDescription("Here are all the commands you can use with the AeThex Discord bot.") + .addFields( + { + name: "🔗 Account Linking", + value: [ + "`/verify` - Link your Discord account to AeThex", + "`/unlink` - Disconnect your Discord from AeThex", + "`/profile` - View your linked AeThex profile", + ].join("\n"), + }, + { + name: "⚔️ Realm Management", + value: [ + "`/set-realm` - Choose your primary realm (Labs, GameForge, Corp, Foundation, Dev-Link)", + "`/verify-role` - Check your assigned Discord roles", + ].join("\n"), + }, + { + name: "📊 Community", + value: [ + "`/stats` - View your AeThex statistics and activity", + "`/leaderboard` - See the top contributors", + "`/post` - Create a post in the AeThex community feed", + ].join("\n"), + }, + { + name: "ℹ️ Information", + value: "`/help` - Show this help message", + }, + ) + .addFields({ + name: "🔗 Quick Links", + value: [ + "[AeThex Platform](https://aethex.dev)", + "[Creator Directory](https://aethex.dev/creators)", + "[Community Feed](https://aethex.dev/community/feed)", + ].join(" | "), + }) + .setFooter({ text: "AeThex | Build. Create. Connect." }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/leaderboard.js b/attached_assets/discord-bot-source/discord-bot/commands/leaderboard.js new file mode 100644 index 0000000..cbc5b01 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/leaderboard.js @@ -0,0 +1,155 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("leaderboard") + .setDescription("View the top AeThex contributors") + .addStringOption((option) => + option + .setName("category") + .setDescription("Leaderboard category") + .setRequired(false) + .addChoices( + { name: "🔥 Most Active (Posts)", value: "posts" }, + { name: "❤️ Most Liked", value: "likes" }, + { name: "🎨 Top Creators", value: "creators" } + ) + ), + + async execute(interaction, supabase) { + await interaction.deferReply(); + + try { + const category = interaction.options.getString("category") || "posts"; + + let leaderboardData = []; + let title = ""; + let emoji = ""; + + if (category === "posts") { + title = "Most Active Posters"; + emoji = "🔥"; + + const { data: posts } = await supabase + .from("community_posts") + .select("user_id") + .not("user_id", "is", null); + + const postCounts = {}; + posts?.forEach((post) => { + postCounts[post.user_id] = (postCounts[post.user_id] || 0) + 1; + }); + + const sortedUsers = Object.entries(postCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 10); + + for (const [userId, count] of sortedUsers) { + const { data: profile } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", userId) + .single(); + + if (profile) { + leaderboardData.push({ + name: profile.full_name || profile.username || "Anonymous", + value: `${count} posts`, + username: profile.username, + }); + } + } + } else if (category === "likes") { + title = "Most Liked Users"; + emoji = "❤️"; + + const { data: posts } = await supabase + .from("community_posts") + .select("user_id, likes_count") + .not("user_id", "is", null) + .order("likes_count", { ascending: false }); + + const likeCounts = {}; + posts?.forEach((post) => { + likeCounts[post.user_id] = + (likeCounts[post.user_id] || 0) + (post.likes_count || 0); + }); + + const sortedUsers = Object.entries(likeCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 10); + + for (const [userId, count] of sortedUsers) { + const { data: profile } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", userId) + .single(); + + if (profile) { + leaderboardData.push({ + name: profile.full_name || profile.username || "Anonymous", + value: `${count} likes received`, + username: profile.username, + }); + } + } + } else if (category === "creators") { + title = "Top Creators"; + emoji = "🎨"; + + const { data: creators } = await supabase + .from("aethex_creators") + .select("user_id, total_projects, verified, featured") + .order("total_projects", { ascending: false }) + .limit(10); + + for (const creator of creators || []) { + const { data: profile } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", creator.user_id) + .single(); + + if (profile) { + const badges = []; + if (creator.verified) badges.push("✅"); + if (creator.featured) badges.push("⭐"); + + leaderboardData.push({ + name: profile.full_name || profile.username || "Anonymous", + value: `${creator.total_projects || 0} projects ${badges.join(" ")}`, + username: profile.username, + }); + } + } + } + + const embed = new EmbedBuilder() + .setColor(0x7289da) + .setTitle(`${emoji} ${title}`) + .setDescription( + leaderboardData.length > 0 + ? leaderboardData + .map( + (user, index) => + `**${index + 1}.** ${user.name} - ${user.value}` + ) + .join("\n") + : "No data available yet. Be the first to contribute!" + ) + .setFooter({ text: "AeThex Leaderboard | Updated in real-time" }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error("Leaderboard command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription("Failed to fetch leaderboard. Please try again."); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/post.js b/attached_assets/discord-bot-source/discord-bot/commands/post.js new file mode 100644 index 0000000..61057e6 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/post.js @@ -0,0 +1,144 @@ +const { + SlashCommandBuilder, + EmbedBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, +} = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("post") + .setDescription("Create a post in the AeThex community feed") + .addStringOption((option) => + option + .setName("content") + .setDescription("Your post content") + .setRequired(true) + .setMaxLength(500) + ) + .addStringOption((option) => + option + .setName("category") + .setDescription("Post category") + .setRequired(false) + .addChoices( + { name: "💬 General", value: "general" }, + { name: "🚀 Project Update", value: "project_update" }, + { name: "❓ Question", value: "question" }, + { name: "💡 Idea", value: "idea" }, + { name: "🎉 Announcement", value: "announcement" } + ) + ) + .addAttachmentOption((option) => + option + .setName("image") + .setDescription("Attach an image to your post") + .setRequired(false) + ), + + async execute(interaction, supabase, client) { + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: link } = await supabase + .from("discord_links") + .select("user_id, primary_arm") + .eq("discord_id", interaction.user.id) + .single(); + + if (!link) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("❌ Not Linked") + .setDescription( + "You must link your Discord account to AeThex first.\nUse `/verify` to get started." + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + const { data: profile } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", link.user_id) + .single(); + + const content = interaction.options.getString("content"); + const category = interaction.options.getString("category") || "general"; + const attachment = interaction.options.getAttachment("image"); + + let imageUrl = null; + if (attachment && attachment.contentType?.startsWith("image/")) { + imageUrl = attachment.url; + } + + const categoryLabels = { + general: "General", + project_update: "Project Update", + question: "Question", + idea: "Idea", + announcement: "Announcement", + }; + + const { data: post, error } = await supabase + .from("community_posts") + .insert({ + user_id: link.user_id, + content: content, + category: category, + arm_affiliation: link.primary_arm || "general", + image_url: imageUrl, + source: "discord", + discord_message_id: interaction.id, + discord_author_id: interaction.user.id, + discord_author_name: interaction.user.username, + discord_author_avatar: interaction.user.displayAvatarURL(), + }) + .select() + .single(); + + if (error) throw error; + + const successEmbed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle("✅ Post Created!") + .setDescription(content.length > 100 ? content.slice(0, 100) + "..." : content) + .addFields( + { + name: "📁 Category", + value: categoryLabels[category], + inline: true, + }, + { + name: "⚔️ Realm", + value: link.primary_arm || "general", + inline: true, + } + ); + + if (imageUrl) { + successEmbed.setImage(imageUrl); + } + + successEmbed + .addFields({ + name: "🔗 View Post", + value: `[Open in AeThex](https://aethex.dev/community/feed)`, + }) + .setFooter({ text: "Your post is now live on AeThex!" }) + .setTimestamp(); + + await interaction.editReply({ embeds: [successEmbed] }); + } catch (error) { + console.error("Post command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription("Failed to create post. Please try again."); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/profile.js b/attached_assets/discord-bot-source/discord-bot/commands/profile.js new file mode 100644 index 0000000..035f251 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/profile.js @@ -0,0 +1,93 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("profile") + .setDescription("View your AeThex profile in Discord"), + + async execute(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: link } = await supabase + .from("discord_links") + .select("user_id, primary_arm") + .eq("discord_id", interaction.user.id) + .single(); + + if (!link) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("❌ Not Linked") + .setDescription( + "You must link your Discord account to AeThex first.\nUse `/verify` to get started.", + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + const { data: profile } = await supabase + .from("user_profiles") + .select("*") + .eq("id", link.user_id) + .single(); + + if (!profile) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("❌ Profile Not Found") + .setDescription("Your AeThex profile could not be found."); + + return await interaction.editReply({ embeds: [embed] }); + } + + const armEmojis = { + labs: "🧪", + gameforge: "🎮", + corp: "💼", + foundation: "🤝", + devlink: "💻", + }; + + const embed = new EmbedBuilder() + .setColor(0x7289da) + .setTitle(`${profile.full_name || "AeThex User"}'s Profile`) + .setThumbnail( + profile.avatar_url || "https://aethex.dev/placeholder.svg", + ) + .addFields( + { + name: "👤 Username", + value: profile.username || "N/A", + inline: true, + }, + { + name: `${armEmojis[link.primary_arm] || "⚔️"} Primary Realm`, + value: link.primary_arm || "Not set", + inline: true, + }, + { + name: "📊 Role", + value: profile.user_type || "community_member", + inline: true, + }, + { name: "📝 Bio", value: profile.bio || "No bio set", inline: false }, + ) + .addFields({ + name: "🔗 Links", + value: `[Visit Full Profile](https://aethex.dev/creators/${profile.username})`, + }) + .setFooter({ text: "AeThex | Your Web3 Creator Hub" }); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error("Profile command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription("Failed to fetch profile. Please try again."); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/refresh-roles.js b/attached_assets/discord-bot-source/discord-bot/commands/refresh-roles.js new file mode 100644 index 0000000..459bd79 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/refresh-roles.js @@ -0,0 +1,72 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const { assignRoleByArm, getUserArm } = require("../utils/roleManager"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("refresh-roles") + .setDescription( + "Refresh your Discord roles based on your current AeThex settings", + ), + + async execute(interaction, supabase, client) { + await interaction.deferReply({ ephemeral: true }); + + try { + // Check if user is linked + const { data: link } = await supabase + .from("discord_links") + .select("primary_arm") + .eq("discord_id", interaction.user.id) + .maybeSingle(); + + if (!link) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("❌ Not Linked") + .setDescription( + "You must link your Discord account to AeThex first.\nUse `/verify` to get started.", + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + if (!link.primary_arm) { + const embed = new EmbedBuilder() + .setColor(0xffaa00) + .setTitle("⚠️ No Realm Set") + .setDescription( + "You haven't set your primary realm yet.\nUse `/set-realm` to choose one.", + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + // Assign role based on current primary arm + const roleAssigned = await assignRoleByArm( + interaction.guild, + interaction.user.id, + link.primary_arm, + supabase, + ); + + const embed = new EmbedBuilder() + .setColor(roleAssigned ? 0x00ff00 : 0xffaa00) + .setTitle("✅ Roles Refreshed") + .setDescription( + roleAssigned + ? `Your Discord roles have been synced with your AeThex account.\n\nPrimary Realm: **${link.primary_arm}**` + : `Your roles could not be automatically assigned.\n\nPrimary Realm: **${link.primary_arm}**\n\n⚠️ Please contact an admin to set up the role mapping for this server.`, + ); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error("Refresh-roles command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription("Failed to refresh roles. Please try again."); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/set-realm.js b/attached_assets/discord-bot-source/discord-bot/commands/set-realm.js new file mode 100644 index 0000000..c1af120 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/set-realm.js @@ -0,0 +1,139 @@ +const { + SlashCommandBuilder, + EmbedBuilder, + StringSelectMenuBuilder, + ActionRowBuilder, +} = require("discord.js"); +const { assignRoleByArm } = require("../utils/roleManager"); + +const REALMS = [ + { value: "labs", label: "🧪 Labs", description: "Research & Development" }, + { + value: "gameforge", + label: "🎮 GameForge", + description: "Game Development", + }, + { value: "corp", label: "💼 Corp", description: "Enterprise Solutions" }, + { + value: "foundation", + label: "🤝 Foundation", + description: "Community & Education", + }, + { + value: "devlink", + label: "💻 Dev-Link", + description: "Professional Networking", + }, +]; + +module.exports = { + data: new SlashCommandBuilder() + .setName("set-realm") + .setDescription("Set your primary AeThex realm/arm"), + + async execute(interaction, supabase, client) { + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: link } = await supabase + .from("discord_links") + .select("user_id, primary_arm") + .eq("discord_id", interaction.user.id) + .single(); + + if (!link) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("❌ Not Linked") + .setDescription( + "You must link your Discord account to AeThex first.\nUse `/verify` to get started.", + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + const select = new StringSelectMenuBuilder() + .setCustomId("select_realm") + .setPlaceholder("Choose your primary realm") + .addOptions( + REALMS.map((realm) => ({ + label: realm.label, + description: realm.description, + value: realm.value, + default: realm.value === link.primary_arm, + })), + ); + + const row = new ActionRowBuilder().addComponents(select); + + const embed = new EmbedBuilder() + .setColor(0x7289da) + .setTitle("⚔️ Choose Your Realm") + .setDescription( + "Select your primary AeThex realm. This determines your main Discord role.", + ) + .addFields({ + name: "Current Realm", + value: link.primary_arm || "Not set", + }); + + await interaction.editReply({ embeds: [embed], components: [row] }); + + const filter = (i) => + i.user.id === interaction.user.id && i.customId === "select_realm"; + const collector = interaction.channel.createMessageComponentCollector({ + filter, + time: 60000, + }); + + collector.on("collect", async (i) => { + const selectedRealm = i.values[0]; + + await supabase + .from("discord_links") + .update({ primary_arm: selectedRealm }) + .eq("discord_id", interaction.user.id); + + const realm = REALMS.find((r) => r.value === selectedRealm); + + // Assign Discord role based on selected realm + const roleAssigned = await assignRoleByArm( + interaction.guild, + interaction.user.id, + selectedRealm, + supabase, + ); + + const roleStatus = roleAssigned + ? "✅ Discord role assigned!" + : "⚠️ No role mapping found for this realm in this server."; + + const confirmEmbed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle("✅ Realm Set") + .setDescription( + `Your primary realm is now **${realm.label}**\n\n${roleStatus}`, + ); + + await i.update({ embeds: [confirmEmbed], components: [] }); + }); + + collector.on("end", (collected) => { + if (collected.size === 0) { + interaction.editReply({ + content: "Realm selection timed out.", + components: [], + }); + } + }); + } catch (error) { + console.error("Set-realm command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription("Failed to update realm. Please try again."); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/stats.js b/attached_assets/discord-bot-source/discord-bot/commands/stats.js new file mode 100644 index 0000000..fe9814b --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/stats.js @@ -0,0 +1,140 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("stats") + .setDescription("View your AeThex statistics and activity"), + + async execute(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: link } = await supabase + .from("discord_links") + .select("user_id, primary_arm, created_at") + .eq("discord_id", interaction.user.id) + .single(); + + if (!link) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("❌ Not Linked") + .setDescription( + "You must link your Discord account to AeThex first.\nUse `/verify` to get started." + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + const { data: profile } = await supabase + .from("user_profiles") + .select("*") + .eq("id", link.user_id) + .single(); + + const { count: postCount } = await supabase + .from("community_posts") + .select("*", { count: "exact", head: true }) + .eq("user_id", link.user_id); + + const { count: likeCount } = await supabase + .from("community_likes") + .select("*", { count: "exact", head: true }) + .eq("user_id", link.user_id); + + const { count: commentCount } = await supabase + .from("community_comments") + .select("*", { count: "exact", head: true }) + .eq("user_id", link.user_id); + + const { data: creatorProfile } = await supabase + .from("aethex_creators") + .select("verified, featured, total_projects") + .eq("user_id", link.user_id) + .single(); + + const armEmojis = { + labs: "🧪", + gameforge: "🎮", + corp: "💼", + foundation: "🤝", + devlink: "💻", + }; + + const linkedDate = new Date(link.created_at); + const daysSinceLinked = Math.floor( + (Date.now() - linkedDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + const embed = new EmbedBuilder() + .setColor(0x7289da) + .setTitle(`📊 ${profile?.full_name || interaction.user.username}'s Stats`) + .setThumbnail(profile?.avatar_url || interaction.user.displayAvatarURL()) + .addFields( + { + name: `${armEmojis[link.primary_arm] || "⚔️"} Primary Realm`, + value: link.primary_arm || "Not set", + inline: true, + }, + { + name: "👤 Account Type", + value: profile?.user_type || "community_member", + inline: true, + }, + { + name: "📅 Days Linked", + value: `${daysSinceLinked} days`, + inline: true, + } + ) + .addFields( + { + name: "📝 Posts", + value: `${postCount || 0}`, + inline: true, + }, + { + name: "❤️ Likes Given", + value: `${likeCount || 0}`, + inline: true, + }, + { + name: "💬 Comments", + value: `${commentCount || 0}`, + inline: true, + } + ); + + if (creatorProfile) { + embed.addFields({ + name: "🎨 Creator Status", + value: [ + creatorProfile.verified ? "✅ Verified Creator" : "⏳ Pending Verification", + creatorProfile.featured ? "⭐ Featured" : "", + `📁 ${creatorProfile.total_projects || 0} Projects`, + ] + .filter(Boolean) + .join("\n"), + }); + } + + embed + .addFields({ + name: "🔗 Full Profile", + value: `[View on AeThex](https://aethex.dev/creators/${profile?.username || link.user_id})`, + }) + .setFooter({ text: "AeThex | Your Creative Hub" }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error("Stats command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription("Failed to fetch stats. Please try again."); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/unlink.js b/attached_assets/discord-bot-source/discord-bot/commands/unlink.js new file mode 100644 index 0000000..ac06d2a --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/unlink.js @@ -0,0 +1,75 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("unlink") + .setDescription("Unlink your Discord account from AeThex"), + + async execute(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: link } = await supabase + .from("discord_links") + .select("*") + .eq("discord_id", interaction.user.id) + .single(); + + if (!link) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("ℹ️ Not Linked") + .setDescription("Your Discord account is not linked to AeThex."); + + return await interaction.editReply({ embeds: [embed] }); + } + + // Delete the link + await supabase + .from("discord_links") + .delete() + .eq("discord_id", interaction.user.id); + + // Remove Discord roles from user + const guild = interaction.guild; + const member = await guild.members.fetch(interaction.user.id); + + // Find and remove all AeThex-related roles + const rolesToRemove = member.roles.cache.filter( + (role) => + role.name.includes("Labs") || + role.name.includes("GameForge") || + role.name.includes("Corp") || + role.name.includes("Foundation") || + role.name.includes("Dev-Link") || + role.name.includes("Premium") || + role.name.includes("Creator"), + ); + + for (const [, role] of rolesToRemove) { + try { + await member.roles.remove(role); + } catch (e) { + console.warn(`Could not remove role ${role.name}`); + } + } + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle("✅ Account Unlinked") + .setDescription( + "Your Discord account has been unlinked from AeThex.\nAll associated roles have been removed.", + ); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error("Unlink command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription("Failed to unlink account. Please try again."); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/verify-role.js b/attached_assets/discord-bot-source/discord-bot/commands/verify-role.js new file mode 100644 index 0000000..1b7e6b9 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/verify-role.js @@ -0,0 +1,97 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("verify-role") + .setDescription("Check your AeThex-assigned Discord roles"), + + async execute(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: link } = await supabase + .from("discord_links") + .select("user_id, primary_arm") + .eq("discord_id", interaction.user.id) + .single(); + + if (!link) { + const embed = new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle("❌ Not Linked") + .setDescription( + "You must link your Discord account to AeThex first.\nUse `/verify` to get started.", + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + const { data: profile } = await supabase + .from("user_profiles") + .select("user_type") + .eq("id", link.user_id) + .single(); + + const { data: mappings } = await supabase + .from("discord_role_mappings") + .select("discord_role") + .eq("arm", link.primary_arm) + .eq("user_type", profile?.user_type || "community_member"); + + const member = await interaction.guild.members.fetch(interaction.user.id); + const aethexRoles = member.roles.cache.filter( + (role) => + role.name.includes("Labs") || + role.name.includes("GameForge") || + role.name.includes("Corp") || + role.name.includes("Foundation") || + role.name.includes("Dev-Link") || + role.name.includes("Premium") || + role.name.includes("Creator"), + ); + + const embed = new EmbedBuilder() + .setColor(0x7289da) + .setTitle("🔐 Your AeThex Roles") + .addFields( + { + name: "⚔️ Primary Realm", + value: link.primary_arm || "Not set", + inline: true, + }, + { + name: "👤 User Type", + value: profile?.user_type || "community_member", + inline: true, + }, + { + name: "🎭 Discord Roles", + value: + aethexRoles.size > 0 + ? aethexRoles.map((r) => r.name).join(", ") + : "None assigned yet", + }, + { + name: "📋 Expected Roles", + value: + mappings?.length > 0 + ? mappings.map((m) => m.discord_role).join(", ") + : "No mappings found", + }, + ) + .setFooter({ + text: "Roles are assigned automatically based on your AeThex profile", + }); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error("Verify-role command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription("Failed to verify roles. Please try again."); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/commands/verify.js b/attached_assets/discord-bot-source/discord-bot/commands/verify.js new file mode 100644 index 0000000..d9f30e7 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/commands/verify.js @@ -0,0 +1,85 @@ +const { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} = require("discord.js"); +const { syncRolesAcrossGuilds } = require("../utils/roleManager"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("verify") + .setDescription("Link your Discord account to your AeThex account"), + + async execute(interaction, supabase, client) { + await interaction.deferReply({ ephemeral: true }); + + try { + const { data: existingLink } = await supabase + .from("discord_links") + .select("*") + .eq("discord_id", interaction.user.id) + .single(); + + if (existingLink) { + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle("✅ Already Linked") + .setDescription( + `Your Discord account is already linked to AeThex (User ID: ${existingLink.user_id})`, + ); + + return await interaction.editReply({ embeds: [embed] }); + } + + // Generate verification code + const verificationCode = Math.random() + .toString(36) + .substring(2, 8) + .toUpperCase(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + // Store verification code with Discord username + await supabase.from("discord_verifications").insert({ + discord_id: interaction.user.id, + verification_code: verificationCode, + username: interaction.user.username, + expires_at: expiresAt.toISOString(), + }); + + const verifyUrl = `https://aethex.dev/discord-verify?code=${verificationCode}`; + + const embed = new EmbedBuilder() + .setColor(0x7289da) + .setTitle("🔗 Link Your AeThex Account") + .setDescription( + "Click the button below to link your Discord account to AeThex.", + ) + .addFields( + { name: "⏱️ Expires In", value: "15 minutes" }, + { name: "📝 Verification Code", value: `\`${verificationCode}\`` }, + ) + .setFooter({ text: "Your security code will expire in 15 minutes" }); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel("Link Account") + .setStyle(ButtonStyle.Link) + .setURL(verifyUrl), + ); + + await interaction.editReply({ embeds: [embed], components: [row] }); + } catch (error) { + console.error("Verify command error:", error); + const embed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("❌ Error") + .setDescription( + "Failed to generate verification code. Please try again.", + ); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/discloud.config b/attached_assets/discord-bot-source/discord-bot/discloud.config new file mode 100644 index 0000000..fe114e6 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/discloud.config @@ -0,0 +1,10 @@ +TYPE=bot +MAIN=bot.js +NAME=AeThex +AVATAR=https://docs.aethex.tech/~gitbook/image?url=https%3A%2F%2F1143808467-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Forganizations%252FDhUg3jal6kdpG645FzIl%252Fsites%252Fsite_HeOmR%252Flogo%252FqxDYz8Oj2SnwUTa8t3UB%252FAeThex%2520Origin%2520logo.png%3Falt%3Dmedia%26token%3D200e8ea2-0129-4cbe-b516-4a53f60c512b&width=512&dpr=1&quality=100&sign=6c7576ce&sv=2 +RAM=100 +AUTORESTART=true +APT=tool, education, gamedev +START=npm install +BUILD=npm run build +VLAN=true diff --git a/attached_assets/discord-bot-source/discord-bot/events/messageCreate.js b/attached_assets/discord-bot-source/discord-bot/events/messageCreate.js new file mode 100644 index 0000000..75abc33 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/events/messageCreate.js @@ -0,0 +1,180 @@ +const { createClient } = require("@supabase/supabase-js"); + +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE, +); + +// Only sync messages from this specific channel +const FEED_CHANNEL_ID = process.env.DISCORD_MAIN_CHAT_CHANNELS + ? process.env.DISCORD_MAIN_CHAT_CHANNELS.split(",")[0].trim() + : null; + +function getArmAffiliation(message) { + const guildName = message.guild?.name?.toLowerCase() || ""; + const channelName = message.channel?.name?.toLowerCase() || ""; + const searchString = `${guildName} ${channelName}`; + + if (searchString.includes("gameforge")) return "gameforge"; + if (searchString.includes("corp")) return "corp"; + if (searchString.includes("foundation")) return "foundation"; + if (searchString.includes("devlink") || searchString.includes("dev-link")) + return "devlink"; + if (searchString.includes("nexus")) return "nexus"; + if (searchString.includes("staff")) return "staff"; + + return "labs"; +} + +async function syncMessageToFeed(message) { + try { + console.log( + `[Feed Sync] Processing from ${message.author.tag} in #${message.channel.name}`, + ); + + const { data: linkedAccount } = await supabase + .from("discord_links") + .select("user_id") + .eq("discord_id", message.author.id) + .single(); + + let authorId = linkedAccount?.user_id; + let authorInfo = null; + + if (authorId) { + const { data: profile } = await supabase + .from("user_profiles") + .select("id, username, full_name, avatar_url") + .eq("id", authorId) + .single(); + authorInfo = profile; + } + + if (!authorId) { + const discordUsername = `discord-${message.author.id}`; + let { data: guestProfile } = await supabase + .from("user_profiles") + .select("id, username, full_name, avatar_url") + .eq("username", discordUsername) + .single(); + + if (!guestProfile) { + const { data: newProfile, error: createError } = await supabase + .from("user_profiles") + .insert({ + username: discordUsername, + full_name: message.author.displayName || message.author.username, + avatar_url: message.author.displayAvatarURL({ size: 256 }), + }) + .select("id, username, full_name, avatar_url") + .single(); + + if (createError) { + console.error("[Feed Sync] Could not create guest profile:", createError); + return; + } + guestProfile = newProfile; + } + + authorId = guestProfile?.id; + authorInfo = guestProfile; + } + + if (!authorId) { + console.error("[Feed Sync] Could not get author ID"); + return; + } + + let content = message.content || "Shared a message on Discord"; + let mediaUrl = null; + let mediaType = "none"; + + if (message.attachments.size > 0) { + const attachment = message.attachments.first(); + if (attachment) { + mediaUrl = attachment.url; + const attachmentLower = attachment.name.toLowerCase(); + + if ( + [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((ext) => + attachmentLower.endsWith(ext), + ) + ) { + mediaType = "image"; + } else if ( + [".mp4", ".webm", ".mov", ".avi"].some((ext) => + attachmentLower.endsWith(ext), + ) + ) { + mediaType = "video"; + } + } + } + + const armAffiliation = getArmAffiliation(message); + + const postContent = JSON.stringify({ + text: content, + mediaUrl: mediaUrl, + mediaType: mediaType, + source: "discord", + discord_message_id: message.id, + discord_channel_id: message.channelId, + discord_channel_name: message.channel.name, + discord_guild_id: message.guildId, + discord_guild_name: message.guild?.name, + discord_author_id: message.author.id, + discord_author_tag: message.author.tag, + discord_author_avatar: message.author.displayAvatarURL({ size: 256 }), + is_linked_user: !!linkedAccount, + }); + + const { error: insertError } = await supabase + .from("community_posts") + .insert({ + title: content.substring(0, 100) || "Discord Message", + content: postContent, + arm_affiliation: armAffiliation, + author_id: authorId, + tags: ["discord", "feed"], + category: "discord", + is_published: true, + likes_count: 0, + comments_count: 0, + }); + + if (insertError) { + console.error("[Feed Sync] Post creation failed:", insertError); + return; + } + + console.log( + `[Feed Sync] ✅ Synced message from ${message.author.tag} to AeThex feed`, + ); + } catch (error) { + console.error("[Feed Sync] Error:", error); + } +} + +module.exports = { + name: "messageCreate", + async execute(message, client) { + // Ignore bot messages + if (message.author.bot) return; + + // Ignore empty messages + if (!message.content && message.attachments.size === 0) return; + + // Only process messages from the configured feed channel + if (!FEED_CHANNEL_ID) { + return; // No channel configured + } + + if (message.channelId !== FEED_CHANNEL_ID) { + return; // Not the feed channel + } + + // Sync this message to AeThex feed + await syncMessageToFeed(message); + }, +}; diff --git a/attached_assets/discord-bot-source/discord-bot/listeners/feedSync.js b/attached_assets/discord-bot-source/discord-bot/listeners/feedSync.js new file mode 100644 index 0000000..b8168c4 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/listeners/feedSync.js @@ -0,0 +1,239 @@ +const { EmbedBuilder } = require("discord.js"); +const { createClient } = require("@supabase/supabase-js"); + +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE, +); + +const FEED_CHANNEL_ID = process.env.DISCORD_MAIN_CHAT_CHANNELS + ? process.env.DISCORD_MAIN_CHAT_CHANNELS.split(",")[0].trim() + : null; + +const POLL_INTERVAL = 5000; // Check every 5 seconds + +let discordClient = null; +let lastCheckedTime = null; +let pollInterval = null; +let isPolling = false; // Concurrency lock to prevent overlapping polls +const processedPostIds = new Set(); // Track already-processed posts to prevent duplicates + +function getArmColor(arm) { + const colors = { + labs: 0x00d4ff, + gameforge: 0xff6b00, + corp: 0x9945ff, + foundation: 0x14f195, + devlink: 0xf7931a, + nexus: 0xff00ff, + staff: 0xffd700, + }; + return colors[arm] || 0x5865f2; +} + +function getArmEmoji(arm) { + const emojis = { + labs: "🔬", + gameforge: "🎮", + corp: "🏢", + foundation: "🎓", + devlink: "🔗", + nexus: "🌐", + staff: "⭐", + }; + return emojis[arm] || "📝"; +} + +async function sendPostToDiscord(post, authorInfo = null) { + if (!discordClient || !FEED_CHANNEL_ID) { + console.log("[Feed Bridge] No Discord client or channel configured"); + return { success: false, error: "No Discord client or channel configured" }; + } + + try { + const channel = await discordClient.channels.fetch(FEED_CHANNEL_ID); + if (!channel || !channel.isTextBased()) { + console.error("[Feed Bridge] Could not find text channel:", FEED_CHANNEL_ID); + return { success: false, error: "Could not find text channel" }; + } + + let content = {}; + try { + content = typeof post.content === "string" ? JSON.parse(post.content) : post.content; + } catch { + content = { text: post.content }; + } + + if (content.source === "discord") { + return { success: true, skipped: true, reason: "Discord-sourced post" }; + } + + let author = authorInfo; + if (!author && post.author_id) { + const { data } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", post.author_id) + .single(); + author = data; + } + + const authorName = author?.full_name || author?.username || "AeThex User"; + // Discord only accepts HTTP/HTTPS URLs for icons - filter out base64/data URLs + const rawAvatar = author?.avatar_url || ""; + const authorAvatar = rawAvatar.startsWith("http://") || rawAvatar.startsWith("https://") + ? rawAvatar + : "https://aethex.dev/logo.png"; + const arm = post.arm_affiliation || "labs"; + + const embed = new EmbedBuilder() + .setColor(getArmColor(arm)) + .setAuthor({ + name: `${getArmEmoji(arm)} ${authorName}`, + iconURL: authorAvatar, + url: `https://aethex.dev/creators/${author?.username || post.author_id}`, + }) + .setDescription(content.text || post.title || "New post") + .setTimestamp(post.created_at ? new Date(post.created_at) : new Date()) + .setFooter({ + text: `Posted from AeThex • ${arm.charAt(0).toUpperCase() + arm.slice(1)}`, + iconURL: "https://aethex.dev/logo.png", + }); + + if (content.mediaUrl) { + if (content.mediaType === "image") { + embed.setImage(content.mediaUrl); + } else if (content.mediaType === "video") { + embed.addFields({ + name: "🎬 Video", + value: `[Watch Video](${content.mediaUrl})`, + }); + } + } + + if (post.tags && post.tags.length > 0) { + const tagString = post.tags + .filter((t) => t !== "discord" && t !== "main-chat") + .map((t) => `#${t}`) + .join(" "); + if (tagString) { + embed.addFields({ name: "Tags", value: tagString, inline: true }); + } + } + + const postUrl = `https://aethex.dev/community/feed?post=${post.id}`; + embed.addFields({ + name: "🔗 View on AeThex", + value: `[Open Post](${postUrl})`, + inline: true, + }); + + await channel.send({ embeds: [embed] }); + console.log(`[Feed Bridge] ✅ Sent post ${post.id} to Discord`); + return { success: true }; + } catch (error) { + console.error("[Feed Bridge] Error sending to Discord:", error); + return { success: false, error: error.message }; + } +} + +async function checkForNewPosts() { + if (!discordClient || !FEED_CHANNEL_ID) return; + + // Prevent overlapping polls - if already polling, skip this run + if (isPolling) { + console.log("[Feed Bridge] Skipping poll - previous poll still in progress"); + return; + } + + isPolling = true; + + try { + const { data: posts, error } = await supabase + .from("community_posts") + .select("*") + .gt("created_at", lastCheckedTime.toISOString()) + .order("created_at", { ascending: true }); + + if (error) { + console.error("[Feed Bridge] Error fetching new posts:", error); + return; + } + + if (posts && posts.length > 0) { + // Update lastCheckedTime IMMEDIATELY after fetching to prevent re-fetching same posts + lastCheckedTime = new Date(posts[posts.length - 1].created_at); + + // Filter out already-processed posts (double safety) + const newPosts = posts.filter(post => !processedPostIds.has(post.id)); + + if (newPosts.length > 0) { + console.log(`[Feed Bridge] Found ${newPosts.length} new post(s)`); + + for (const post of newPosts) { + // Mark as processed BEFORE sending to prevent duplicates + processedPostIds.add(post.id); + + let content = {}; + try { + content = typeof post.content === "string" ? JSON.parse(post.content) : post.content; + } catch { + content = { text: post.content }; + } + + if (content.source === "discord") { + console.log(`[Feed Bridge] Skipping Discord-sourced post ${post.id}`); + continue; + } + + console.log(`[Feed Bridge] Bridging post ${post.id} to Discord...`); + await sendPostToDiscord(post); + } + } + + // Keep processedPostIds from growing indefinitely - trim old entries + if (processedPostIds.size > 1000) { + const idsArray = Array.from(processedPostIds); + idsArray.slice(0, 500).forEach(id => processedPostIds.delete(id)); + } + } + } catch (error) { + console.error("[Feed Bridge] Poll error:", error); + } finally { + isPolling = false; + } +} + +function setupFeedListener(client) { + discordClient = client; + + if (!FEED_CHANNEL_ID) { + console.log("[Feed Bridge] No DISCORD_MAIN_CHAT_CHANNELS configured - bridge disabled"); + return; + } + + lastCheckedTime = new Date(); + + console.log("[Feed Bridge] Starting polling for new posts (every 5 seconds)..."); + + pollInterval = setInterval(checkForNewPosts, POLL_INTERVAL); + + console.log("[Feed Bridge] ✅ Feed bridge ready (channel: " + FEED_CHANNEL_ID + ")"); +} + +function getDiscordClient() { + return discordClient; +} + +function getFeedChannelId() { + return FEED_CHANNEL_ID; +} + +function cleanup() { + if (pollInterval) { + clearInterval(pollInterval); + console.log("[Feed Bridge] Stopped polling"); + } +} + +module.exports = { setupFeedListener, sendPostToDiscord, getDiscordClient, getFeedChannelId, cleanup }; diff --git a/attached_assets/discord-bot-source/discord-bot/package-lock.json b/attached_assets/discord-bot-source/discord-bot/package-lock.json new file mode 100644 index 0000000..15b33b8 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/package-lock.json @@ -0,0 +1,1157 @@ +{ + "name": "aethex-discord-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aethex-discord-bot", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@discord/embedded-app-sdk": "^2.4.0", + "@supabase/supabase-js": "^2.38.0", + "axios": "^1.6.0", + "discord.js": "^14.13.0", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "nodemon": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@discord/embedded-app-sdk": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@discord/embedded-app-sdk/-/embedded-app-sdk-2.4.0.tgz", + "integrity": "sha512-kIIS79tuVKvu9YC6GIuvBSfUqNa6511UqafD4i3qGjWSRqVulioYuRzZ+M9D9/KZ2wuu0nQ5IWIYlnh1bsy2tg==", + "license": "MIT", + "dependencies": { + "@types/lodash.transform": "^4.6.6", + "@types/uuid": "^10.0.0", + "big-integer": "^1.6.48", + "decimal.js-light": "^2.5.0", + "eventemitter3": "^5.0.0", + "lodash.transform": "^4.6.0", + "uuid": "^11.0.0", + "zod": "^3.9.8" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz", + "integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.1", + "@discordjs/util": "^1.1.1", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.31", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", + "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.1" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", + "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.80.0.tgz", + "integrity": "sha512-q2LyCVJGN4p7d92cOI7scWOoNwxJhZuFRwiimSUGJGI5zX7ubf1WUPznwOmYEn8WVo3Io+MyMinA7era6j5KPw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.80.0.tgz", + "integrity": "sha512-0S/k8LRtoblrbzy4ir9m4WuvU/XTkb1EwL/33/oJexCUHCXtsqaPJ3eKfr1GWtNqTa1zryv6sXs3Fpv7lKCsMQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.80.0.tgz", + "integrity": "sha512-yKzehXlRbDoXIQefdRQnvaI9BEogoWIp/7+y/m5enZDKW2IP9aAgq5tU72sThcwftDJvknnIpEHAABG3qviEng==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.80.0.tgz", + "integrity": "sha512-cXK6Gs4UDylN8oz40omi01QK0cSCBVj0efXC1WodpENTuDnrkUs28W8/eslEnAtlawaVtikC1Q92mpz9+o85Mg==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.80.0.tgz", + "integrity": "sha512-Iepod83h2WoMCaLC9pGb3QOT67Kn3RlUdbXpo3uvbDKfPU8EgytS4RVaPmDjhqDjj8AGaiz9mk/ppd2Q2WS+gw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.80.0.tgz", + "integrity": "sha512-n8pkXQxuo5zCWXX5cbSNZj1vuWS8IVNGWTmP1m31Iq1k0e8lPZ07PF08TRV79HHq3mEPP/Ko//BQuflHvY2o8w==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.80.0", + "@supabase/functions-js": "2.80.0", + "@supabase/postgrest-js": "2.80.0", + "@supabase/realtime-js": "2.80.0", + "@supabase/storage-js": "2.80.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, + "node_modules/@types/lodash.transform": { + "version": "4.6.9", + "resolved": "https://registry.npmjs.org/@types/lodash.transform/-/lodash.transform-4.6.9.tgz", + "integrity": "sha512-1iIn+l7Vrj8hsr2iZLtxRkcV9AtjTafIyxKO9DX2EEcdOgz3Op5dhwKQFhMJgdfIRbYHBUF+SU97Y6P+zyLXNg==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.33", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.33.tgz", + "integrity": "sha512-oau1V7OzrNX8yNi+DfQpoLZCNCv7cTFmvPKwHfMrA/tewsO6iQKrMTzA7pa3iBSj0fED6NlklJ/1B/cC1kI08Q==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.24.2", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.24.2.tgz", + "integrity": "sha512-VMEDbmguRdX/EeMaTsf9Mb0IQA90WdYF2cn4QDfslQFXgQ6LFtmlPn0FSotnS0kcFbFp+JBSIxtnF+bnAHG/hQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.1", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.1.1", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.31", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/lodash.transform": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz", + "integrity": "sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", + "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/attached_assets/discord-bot-source/discord-bot/package.json b/attached_assets/discord-bot-source/discord-bot/package.json new file mode 100644 index 0000000..b65f0ac --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/package.json @@ -0,0 +1,34 @@ +{ + "name": "aethex-discord-bot", + "version": "1.0.0", + "description": "AeThex Discord Bot - Account linking, role management, and realm selection", + "main": "bot.js", + "type": "commonjs", + "scripts": { + "start": "node bot.js", + "dev": "nodemon bot.js", + "register-commands": "node scripts/register-commands.js" + }, + "keywords": [ + "discord", + "bot", + "aethex", + "role-management", + "discord.js" + ], + "author": "AeThex Team", + "license": "MIT", + "dependencies": { + "@discord/embedded-app-sdk": "^2.4.0", + "@supabase/supabase-js": "^2.38.0", + "axios": "^1.6.0", + "discord.js": "^14.13.0", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "nodemon": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/attached_assets/discord-bot-source/discord-bot/scripts/register-commands.js b/attached_assets/discord-bot-source/discord-bot/scripts/register-commands.js new file mode 100644 index 0000000..27ffb8d --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/scripts/register-commands.js @@ -0,0 +1,110 @@ +const { REST, Routes } = require("discord.js"); +const fs = require("fs"); +const path = require("path"); +require("dotenv").config(); + +// Validate environment variables +const requiredEnvVars = ["DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID"]; + +const missingVars = requiredEnvVars.filter((envVar) => !process.env[envVar]); +if (missingVars.length > 0) { + console.error( + "❌ FATAL ERROR: Missing required environment variables:", + missingVars.join(", "), + ); + console.error("\nPlease set these before running command registration:"); + missingVars.forEach((envVar) => { + console.error(` - ${envVar}`); + }); + process.exit(1); +} + +// Load commands from commands directory +const commandsPath = path.join(__dirname, "../commands"); +const commandFiles = fs + .readdirSync(commandsPath) + .filter((file) => file.endsWith(".js")); + +const commands = []; + +for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + const command = require(filePath); + if ("data" in command && "execute" in command) { + commands.push(command.data.toJSON()); + console.log(`✅ Loaded command: ${command.data.name}`); + } +} + +// Register commands with Discord API +async function registerCommands() { + try { + const rest = new REST({ version: "10" }).setToken( + process.env.DISCORD_BOT_TOKEN, + ); + + console.log(`\n📝 Registering ${commands.length} slash commands...`); + console.log( + "⚠️ This will co-exist with Discord's auto-generated Entry Point command.\n", + ); + + try { + const data = await rest.put( + Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), + { body: commands }, + ); + console.log(`✅ Successfully registered ${data.length} slash commands.`); + console.log("\n🎉 Command registration complete!"); + console.log("ℹ️ Your commands are now live in Discord."); + console.log( + "ℹ️ The Entry Point command (for Activities) will be managed by Discord.\n", + ); + } catch (error) { + // Handle Entry Point command conflict + if (error.code === 50240) { + console.warn( + "⚠️ Error 50240: Entry Point command detected (Discord Activity enabled).", + ); + console.warn("Registering commands individually...\n"); + + let successCount = 0; + for (const command of commands) { + try { + await rest.post( + Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), + { body: command }, + ); + successCount++; + } catch (postError) { + if (postError.code === 50045) { + console.warn( + ` ⚠️ ${command.name}: Already registered (skipping)`, + ); + } else { + console.error(` ❌ ${command.name}: ${postError.message}`); + } + } + } + + console.log( + `\n✅ Registered ${successCount} slash commands (individual mode).`, + ); + console.log("🎉 Command registration complete!"); + console.log( + "ℹ️ The Entry Point command will be managed by Discord.\n", + ); + } else { + throw error; + } + } + } catch (error) { + console.error( + "❌ Fatal error registering commands:", + error.message || error, + ); + process.exit(1); + } +} + +// Run registration +registerCommands(); diff --git a/attached_assets/discord-bot-source/discord-bot/utils/roleManager.js b/attached_assets/discord-bot-source/discord-bot/utils/roleManager.js new file mode 100644 index 0000000..27be439 --- /dev/null +++ b/attached_assets/discord-bot-source/discord-bot/utils/roleManager.js @@ -0,0 +1,137 @@ +const { EmbedBuilder } = require("discord.js"); + +/** + * Assign Discord role based on user's arm and type + * @param {Guild} guild - Discord guild + * @param {string} discordId - Discord user ID + * @param {string} arm - User's primary arm (labs, gameforge, corp, foundation, devlink) + * @param {object} supabase - Supabase client + * @returns {Promise} - Success status + */ +async function assignRoleByArm(guild, discordId, arm, supabase) { + try { + // Fetch guild member + const member = await guild.members.fetch(discordId); + if (!member) { + console.warn(`Member not found: ${discordId}`); + return false; + } + + // Get role mapping from Supabase + const { data: mapping, error: mapError } = await supabase + .from("discord_role_mappings") + .select("discord_role") + .eq("arm", arm) + .eq("server_id", guild.id) + .maybeSingle(); + + if (mapError) { + console.error("Error fetching role mapping:", mapError); + return false; + } + + if (!mapping) { + console.warn( + `No role mapping found for arm: ${arm} in server: ${guild.id}`, + ); + return false; + } + + // Find role by name or ID + let roleToAssign = guild.roles.cache.find( + (r) => r.id === mapping.discord_role || r.name === mapping.discord_role, + ); + + if (!roleToAssign) { + console.warn(`Role not found: ${mapping.discord_role}`); + return false; + } + + // Remove old arm roles + const armRoles = member.roles.cache.filter((role) => + ["Labs", "GameForge", "Corp", "Foundation", "Dev-Link"].some((arm) => + role.name.includes(arm), + ), + ); + + for (const [, role] of armRoles) { + try { + if (role.id !== roleToAssign.id) { + await member.roles.remove(role); + } + } catch (e) { + console.warn(`Could not remove role ${role.name}: ${e.message}`); + } + } + + // Assign new role + if (!member.roles.cache.has(roleToAssign.id)) { + await member.roles.add(roleToAssign); + console.log( + `✅ Assigned role ${roleToAssign.name} to ${member.user.tag}`, + ); + return true; + } + + return true; + } catch (error) { + console.error("Error assigning role:", error); + return false; + } +} + +/** + * Get user's primary arm from Supabase + * @param {string} discordId - Discord user ID + * @param {object} supabase - Supabase client + * @returns {Promise} - Primary arm (labs, gameforge, corp, foundation, devlink) + */ +async function getUserArm(discordId, supabase) { + try { + const { data: link, error } = await supabase + .from("discord_links") + .select("primary_arm") + .eq("discord_id", discordId) + .maybeSingle(); + + if (error) { + console.error("Error fetching user arm:", error); + return null; + } + + return link?.primary_arm || null; + } catch (error) { + console.error("Error getting user arm:", error); + return null; + } +} + +/** + * Sync roles for a user across all guilds + * @param {Client} client - Discord client + * @param {string} discordId - Discord user ID + * @param {string} arm - Primary arm + * @param {object} supabase - Supabase client + */ +async function syncRolesAcrossGuilds(client, discordId, arm, supabase) { + try { + for (const [, guild] of client.guilds.cache) { + try { + const member = await guild.members.fetch(discordId); + if (member) { + await assignRoleByArm(guild, discordId, arm, supabase); + } + } catch (e) { + console.warn(`Could not sync roles in guild ${guild.id}: ${e.message}`); + } + } + } catch (error) { + console.error("Error syncing roles across guilds:", error); + } +} + +module.exports = { + assignRoleByArm, + getUserArm, + syncRolesAcrossGuilds, +}; diff --git a/attached_assets/discord-bot_1765148924805.zip b/attached_assets/discord-bot_1765148924805.zip new file mode 100644 index 0000000000000000000000000000000000000000..b89da894904247d72912046d118c78683dbf04a5 GIT binary patch literal 44005 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 literal 0 HcmV?d00001