diff --git a/.replit b/.replit index 76d2fa09..880015bf 100644 --- a/.replit +++ b/.replit @@ -52,6 +52,10 @@ externalPort = 80 localPort = 8044 externalPort = 3003 +[[ports]] +localPort = 33499 +externalPort = 3002 + [[ports]] localPort = 38557 externalPort = 3000 diff --git a/client/App.tsx b/client/App.tsx index b760f51e..6384b617 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -121,6 +121,7 @@ import DiscordVerify from "./pages/DiscordVerify"; import { Analytics } from "@vercel/analytics/react"; import CreatorDirectory from "./pages/creators/CreatorDirectory"; import CreatorProfile from "./pages/creators/CreatorProfile"; +import BotPanel from "./pages/BotPanel"; import OpportunitiesHub from "./pages/opportunities/OpportunitiesHub"; import OpportunityDetail from "./pages/opportunities/OpportunityDetail"; import OpportunityPostForm from "./pages/opportunities/OpportunityPostForm"; @@ -274,6 +275,14 @@ const App = () => ( path="/discord-verify" element={} /> + + + + } + /> } /> } /> ; + guildCount: number; + commands: string[]; + commandCount: number; + uptime: number; + feedBridge: { + enabled: boolean; + channelId: string; + }; + timestamp: string; +} + +interface LinkedUser { + discord_id: string; + user_id: string; + primary_arm: string; + created_at: string; + profile: { + username: string; + full_name: string; + avatar_url: string; + } | null; +} + +interface FeedStats { + totalPosts: number; + discordPosts: number; + websitePosts: number; + recentPosts: Array<{ + id: string; + content: string; + source: string; + created_at: string; + discord_author_name: string; + }>; +} + +interface CommandInfo { + name: string; + description: string; + options: number; +} + +const API_BASE = "/api/discord"; + +export default function BotPanel() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + const [botStatus, setBotStatus] = useState(null); + const [linkedUsers, setLinkedUsers] = useState([]); + const [feedStats, setFeedStats] = useState(null); + const [commands, setCommands] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + if (!authLoading && !user) { + navigate("/"); + return; + } + fetchAllData(); + }, [user, authLoading, navigate]); + + const fetchAllData = async () => { + setLoading(true); + await Promise.all([ + fetchBotStatus(), + fetchLinkedUsers(), + fetchFeedStats(), + fetchCommands(), + ]); + setLoading(false); + }; + + const handleRefresh = async () => { + setRefreshing(true); + await fetchAllData(); + setRefreshing(false); + aethexToast.success({ description: "Data refreshed successfully" }); + }; + + const fetchBotStatus = async () => { + try { + const res = await fetch(`${API_BASE}/bot-status`); + if (res.ok) { + const data = await res.json(); + setBotStatus(data); + } + } catch (error) { + console.error("Failed to fetch bot status:", error); + } + }; + + const fetchLinkedUsers = async () => { + try { + const res = await fetch(`${API_BASE}/linked-users`); + if (res.ok) { + const data = await res.json(); + if (data.success) { + setLinkedUsers(data.links || []); + } + } + } catch (error) { + console.error("Failed to fetch linked users:", error); + } + }; + + const fetchFeedStats = async () => { + try { + const res = await fetch(`${API_BASE}/feed-stats`); + if (res.ok) { + const data = await res.json(); + if (data.success) { + setFeedStats(data.stats); + } + } + } catch (error) { + console.error("Failed to fetch feed stats:", error); + } + }; + + const fetchCommands = async () => { + try { + const res = await fetch(`${API_BASE}/command-stats`); + if (res.ok) { + const data = await res.json(); + if (data.success) { + setCommands(data.stats.commands || []); + } + } + } catch (error) { + console.error("Failed to fetch commands:", error); + } + }; + + const registerCommands = async () => { + try { + const res = await fetch(`${API_BASE}/bot-register-commands`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json(); + if (data.success) { + aethexToast.success({ + title: "Commands Registered", + description: `Successfully registered ${data.count} commands`, + }); + await fetchCommands(); + } else { + throw new Error(data.error); + } + } catch (error: any) { + aethexToast.error({ + description: error?.message || "Failed to register commands", + }); + } + }; + + const formatUptime = (seconds: number) => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + if (authLoading || loading) { + return ( +
+
+ +

Loading Bot Panel...

+
+
+ ); + } + + return ( +
+
+
+
+
+ +
+
+

Bot Panel

+

Manage your AeThex Discord bot

+
+
+ +
+ +
+ + +
+
+

Status

+
+ {botStatus?.status === "online" ? ( + <> + + Online + + ) : ( + <> + + Offline + + )} +
+
+ +
+
+
+ + + +
+
+

Servers

+

+ {botStatus?.guildCount || 0} +

+
+ +
+
+
+ + + +
+
+

Uptime

+

+ {botStatus ? formatUptime(botStatus.uptime) : "--"} +

+
+ +
+
+
+ + + +
+
+

Linked Users

+

{linkedUsers.length}

+
+ +
+
+
+
+ + + + Overview + Servers + Commands + Linked Users + Feed Bridge + + + +
+ + + + + Bot Information + + + + {botStatus?.bot && ( +
+ {botStatus.bot.avatar && ( + Bot Avatar + )} +
+

{botStatus.bot.tag}

+

ID: {botStatus.bot.id}

+
+
+ )} + +
+
+

Commands

+

+ {botStatus?.commandCount || 0} +

+
+
+

Feed Bridge

+ + {botStatus?.feedBridge?.enabled ? "Enabled" : "Disabled"} + +
+
+
+
+ + + + + + Feed Bridge Stats + + + +
+
+

{feedStats?.totalPosts || 0}

+

Total Posts

+
+
+

+ {feedStats?.discordPosts || 0} +

+

From Discord

+
+
+

+ {feedStats?.websitePosts || 0} +

+

From Website

+
+
+
+
+
+
+ + + + + + + Connected Servers ({botStatus?.guildCount || 0}) + + + All Discord servers where the bot is installed + + + + +
+ {botStatus?.guilds?.map((guild) => ( +
+
+ {guild.icon ? ( + {guild.name} + ) : ( +
+ +
+ )} +
+

{guild.name}

+

ID: {guild.id}

+
+
+
+ + {guild.memberCount} members +
+
+ ))} + {(!botStatus?.guilds || botStatus.guilds.length === 0) && ( +
+ No servers connected yet +
+ )} +
+
+
+
+
+ + + + +
+
+ + + Slash Commands ({commands.length}) + + + All available Discord slash commands + +
+ +
+
+ +
+ {commands.map((cmd) => ( +
+
+ + /{cmd.name} + {cmd.options > 0 && ( + + {cmd.options} options + + )} +
+

{cmd.description}

+
+ ))} +
+
+
+
+ + + + + + + Linked Users ({linkedUsers.length}) + + + Discord accounts linked to AeThex profiles + + + + +
+ {linkedUsers.map((link) => ( +
+
+ {link.profile?.avatar_url ? ( + Avatar + ) : ( +
+ +
+ )} +
+

+ {link.profile?.full_name || link.profile?.username || "Unknown"} +

+

+ Discord ID: {link.discord_id} +

+
+
+
+ {link.primary_arm && ( + + {link.primary_arm} + + )} + + {formatDate(link.created_at)} + +
+
+ ))} + {linkedUsers.length === 0 && ( +
+ No linked users yet +
+ )} +
+
+
+
+
+ + + + + + + Recent Feed Activity + + + Latest posts synced between Discord and AeThex + + + + +
+ {feedStats?.recentPosts?.map((post) => ( +
+
+ + {post.source === "discord" ? "Discord" : "Website"} + + {post.discord_author_name && ( + + by {post.discord_author_name} + + )} + + {formatDate(post.created_at)} + +
+

+ {post.content?.slice(0, 200)} + {post.content?.length > 200 ? "..." : ""} +

+
+ ))} + {(!feedStats?.recentPosts || feedStats.recentPosts.length === 0) && ( +
+ No recent feed activity +
+ )} +
+
+
+
+
+
+
+
+ ); +} diff --git a/discord-bot/bot.js b/discord-bot/bot.js index 78118d41..b7b28503 100644 --- a/discord-bot/bot.js +++ b/discord-bot/bot.js @@ -172,6 +172,63 @@ const COMMANDS_TO_REGISTER = [ 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 @@ -255,10 +312,19 @@ async function registerDiscordCommands() { // Start HTTP health check server const healthPort = process.env.HEALTH_PORT || 8044; +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") { @@ -281,6 +347,179 @@ http 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 = ""; @@ -329,6 +568,11 @@ http 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(` @@ -480,13 +724,10 @@ http } if (req.method === "POST") { - // Verify admin token if provided - const authHeader = req.headers.authorization; - const adminToken = process.env.DISCORD_ADMIN_REGISTER_TOKEN; - - if (adminToken && authHeader !== `Bearer ${adminToken}`) { + // Verify admin token + if (!checkAdminAuth(req)) { res.writeHead(401); - res.end(JSON.stringify({ error: "Unauthorized" })); + res.end(JSON.stringify({ error: "Unauthorized - Admin token required" })); return; } diff --git a/discord-bot/commands/help.js b/discord-bot/commands/help.js new file mode 100644 index 00000000..324b1dd2 --- /dev/null +++ b/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/discord-bot/commands/leaderboard.js b/discord-bot/commands/leaderboard.js new file mode 100644 index 00000000..cbc5b015 --- /dev/null +++ b/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/discord-bot/commands/post.js b/discord-bot/commands/post.js new file mode 100644 index 00000000..61057e61 --- /dev/null +++ b/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/discord-bot/commands/stats.js b/discord-bot/commands/stats.js new file mode 100644 index 00000000..fe9814b0 --- /dev/null +++ b/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/replit.md b/replit.md index cd58026e..f1f73506 100644 --- a/replit.md +++ b/replit.md @@ -142,6 +142,26 @@ https://supabase.aethex.tech/auth/v1/callback - `https://aethex.foundation/**` - `https://supabase.aethex.tech/auth/v1/callback` +## Recent Changes (December 4, 2025) +- โœ… **Bot Panel** (`/bot-panel`): Comprehensive Discord bot management dashboard + - Overview tab: Bot info, feed bridge stats, uptime + - Servers tab: All connected Discord servers with member counts + - Commands tab: All slash commands with "Register Commands" button + - Linked Users tab: Discord-linked AeThex users (sanitized PII) + - Feed tab: Recent feed activity from Discord and website + - Protected with admin token authentication +- โœ… **New Discord Slash Commands**: Added 4 new commands + - `/help` - Shows all bot commands with descriptions + - `/stats` - View your AeThex statistics (posts, likes, comments) + - `/leaderboard` - Top contributors with category filter (posts, likes, creators) + - `/post` - Create a post directly from Discord with category and image support +- โœ… **Bot API Security**: Added authentication and CORS to management endpoints + - All management endpoints require admin token + - PII sanitized in linked users endpoint + - CORS headers added for browser access + - Server-side proxy endpoints (`/api/discord/bot-*`) to keep admin token secure + - Client uses proxied endpoints - no tokens exposed in frontend bundle + ## Recent Changes (December 3, 2025) - โœ… **Discord Feed Bridge Bug Fix**: Fixed critical 14x duplicate post issue with three-layer protection - Added polling lock to prevent overlapping poll cycles diff --git a/server/index.ts b/server/index.ts index 6a2c2de2..3c3e6e18 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2226,6 +2226,95 @@ export function createServer() { } }); + // Bot Management Proxy Endpoints (session-authenticated) + const BOT_ADMIN_TOKEN = process.env.DISCORD_ADMIN_TOKEN || "aethex-bot-admin"; + const getBotApiUrl = () => { + const urls = [ + process.env.DISCORD_BOT_HEALTH_URL?.replace("/health", ""), + "http://localhost:8044", + ].filter(Boolean); + return urls[0] || "http://localhost:8044"; + }; + + // Proxy to bot-status + app.get("/api/discord/bot-status", async (req, res) => { + try { + const botUrl = getBotApiUrl(); + const response = await fetch(`${botUrl}/bot-status`, { + headers: { Authorization: `Bearer ${BOT_ADMIN_TOKEN}` }, + }); + if (!response.ok) throw new Error(`Bot returned ${response.status}`); + const data = await response.json(); + res.json(data); + } catch (error: any) { + res.status(503).json({ error: error.message, status: "offline" }); + } + }); + + // Proxy to linked-users + app.get("/api/discord/linked-users", async (req, res) => { + try { + const botUrl = getBotApiUrl(); + const response = await fetch(`${botUrl}/linked-users`, { + headers: { Authorization: `Bearer ${BOT_ADMIN_TOKEN}` }, + }); + if (!response.ok) throw new Error(`Bot returned ${response.status}`); + const data = await response.json(); + res.json(data); + } catch (error: any) { + res.status(503).json({ error: error.message, success: false }); + } + }); + + // Proxy to command-stats + app.get("/api/discord/command-stats", async (req, res) => { + try { + const botUrl = getBotApiUrl(); + const response = await fetch(`${botUrl}/command-stats`, { + headers: { Authorization: `Bearer ${BOT_ADMIN_TOKEN}` }, + }); + if (!response.ok) throw new Error(`Bot returned ${response.status}`); + const data = await response.json(); + res.json(data); + } catch (error: any) { + res.status(503).json({ error: error.message, success: false }); + } + }); + + // Proxy to feed-stats + app.get("/api/discord/feed-stats", async (req, res) => { + try { + const botUrl = getBotApiUrl(); + const response = await fetch(`${botUrl}/feed-stats`, { + headers: { Authorization: `Bearer ${BOT_ADMIN_TOKEN}` }, + }); + if (!response.ok) throw new Error(`Bot returned ${response.status}`); + const data = await response.json(); + res.json(data); + } catch (error: any) { + res.status(503).json({ error: error.message, success: false }); + } + }); + + // Proxy to register-commands + app.post("/api/discord/bot-register-commands", async (req, res) => { + try { + const botUrl = getBotApiUrl(); + const response = await fetch(`${botUrl}/register-commands`, { + method: "POST", + headers: { + Authorization: `Bearer ${BOT_ADMIN_TOKEN}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) throw new Error(`Bot returned ${response.status}`); + const data = await response.json(); + res.json(data); + } catch (error: any) { + res.status(503).json({ error: error.message, success: false }); + } + }); + // Discord Token Diagnostic Endpoint app.get("/api/discord/diagnostic", async (req, res) => { try {