diff --git a/aethex-bot/commands/config.js b/aethex-bot/commands/config.js index 44ce4ea..fdd0e6c 100644 --- a/aethex-bot/commands/config.js +++ b/aethex-bot/commands/config.js @@ -1,5 +1,6 @@ const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); const { getServerMode, setServerMode, getEmbedColor, getModeDisplayName, getModeEmoji, EMBED_COLORS } = require('../utils/modeHelper'); +const { setRobloxConfig, getRobloxConfig } = require('../utils/robloxConfig'); module.exports = { data: new SlashCommandBuilder() @@ -78,6 +79,13 @@ module.exports = { .addSubcommand(sub => sub.setName('mode') .setDescription('Switch between Federation and Standalone mode') + ) + .addSubcommand(sub => + sub.setName('roblox') + .setDescription('Set up Roblox game integration') + .addStringOption(o => o.setName('universe_id').setDescription('Roblox Universe ID').setRequired(true)) + .addStringOption(o => o.setName('open_cloud_key').setDescription('Roblox Open Cloud API key').setRequired(true)) + .addStringOption(o => o.setName('group_id').setDescription('Roblox Group ID (for rank lookups)').setRequired(false)) ), async execute(interaction, supabase, client) { @@ -213,6 +221,34 @@ module.exports = { autorole: 'auto_role', }; + if (subcommand === 'roblox') { + const universeId = interaction.options.getString('universe_id'); + const openCloudKey = interaction.options.getString('open_cloud_key'); + const groupId = interaction.options.getString('group_id') || null; + + const ok = await setRobloxConfig(supabase, interaction.guildId, { + universe_id: universeId, + open_cloud_key: openCloudKey, + roblox_group_id: groupId, + }); + + if (!ok) return interaction.editReply('❌ Failed to save Roblox config. Check Supabase.'); + + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x22c55e) + .setTitle('✅ Roblox Integration Enabled') + .addFields( + { name: 'Universe ID', value: universeId, inline: true }, + { name: 'Group ID', value: groupId || '—', inline: true }, + { name: 'Commands', value: '`/roblox ban` `//roblox unban` `/roblox kick` `/roblox rank` `/roblox stats`', inline: false }, + ) + .setFooter({ text: 'Open Cloud key stored securely.' }) + ], + }); + } + if (subcommand === 'levelrole') { const role = interaction.options.getRole('role'); const level = interaction.options.getInteger('level'); diff --git a/aethex-bot/commands/roblox.js b/aethex-bot/commands/roblox.js new file mode 100644 index 0000000..a66bce0 --- /dev/null +++ b/aethex-bot/commands/roblox.js @@ -0,0 +1,266 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); +const { getRobloxConfig, resolveRobloxUser, getRobloxGroupRole } = require('../utils/robloxConfig'); + +const NOT_CONFIGURED = '❌ Roblox integration is not set up on this server.\nAn admin can enable it with `/config roblox`.'; + +module.exports = { + data: new SlashCommandBuilder() + .setName('roblox') + .setDescription('Roblox game integration — moderation & stats') + .addSubcommand(sub => + sub.setName('ban') + .setDescription('Ban a player from the Roblox game') + .addStringOption(o => o.setName('username').setDescription('Roblox username').setRequired(true)) + .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(true)) + .addStringOption(o => + o.setName('duration').setDescription('Ban duration').setRequired(false) + .addChoices( + { name: 'Permanent', value: '0' }, + { name: '24 Hours', value: '86400' }, + { name: '3 Days', value: '259200' }, + { name: '7 Days', value: '604800' }, + { name: '30 Days', value: '2592000' }, + ) + ) + ) + .addSubcommand(sub => + sub.setName('unban') + .setDescription('Unban a player from the Roblox game') + .addStringOption(o => o.setName('username').setDescription('Roblox username').setRequired(true)) + .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(false)) + ) + .addSubcommand(sub => + sub.setName('kick') + .setDescription('Kick a player from the Roblox game') + .addStringOption(o => o.setName('username').setDescription('Roblox username').setRequired(true)) + .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(true)) + ) + .addSubcommand(sub => + sub.setName('rank') + .setDescription('Look up a player\'s Roblox group rank') + .addStringOption(o => o.setName('username').setDescription('Roblox username').setRequired(true)) + ) + .addSubcommand(sub => + sub.setName('stats') + .setDescription('Show live game stats') + ), + + async execute(interaction, supabase) { + const config = await getRobloxConfig(supabase, interaction.guildId); + const sub = interaction.options.getSubcommand(); + + // Stats is available without full config (just needs universe_id) + if (sub === 'stats') { + await interaction.deferReply(); + if (!config?.universe_id) return interaction.editReply(NOT_CONFIGURED); + try { + const r = await fetch(`https://games.roblox.com/v1/games?universeIds=${config.universe_id}`); + const d = await r.json(); + const game = d?.data?.[0]; + if (!game) return interaction.editReply('Could not fetch game stats.'); + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xD4891A) + .setTitle(game.name) + .addFields( + { name: '🟢 Playing', value: (game.playing || 0).toLocaleString(), inline: true }, + { name: '👁️ Visits', value: fmtNum(game.visits || 0), inline: true }, + { name: '⭐ Favorites', value: (game.favoritedCount || 0).toLocaleString(), inline: true }, + ) + .setTimestamp(), + ], + }); + } catch { return interaction.editReply('Failed to fetch stats.'); } + } + + // All other subcommands require full config with open_cloud_key + if (!config?.universe_id || !config?.open_cloud_key) { + return interaction.reply({ content: NOT_CONFIGURED, ephemeral: true }); + } + + // Mod commands require BanMembers permission + if (!interaction.member.permissions.has(PermissionFlagsBits.BanMembers)) { + return interaction.reply({ content: '❌ You need the **Ban Members** permission to use Roblox mod commands.', ephemeral: true }); + } + + await interaction.deferReply(); + + const username = interaction.options.getString('username'); + const reason = interaction.options.getString('reason') || 'No reason provided'; + + // Resolve Roblox user + const robloxUser = await resolveRobloxUser(username); + if (!robloxUser) { + return interaction.editReply(`❌ Could not find Roblox user **${username}**.`); + } + + const universeId = config.universe_id; + const apiKey = config.open_cloud_key; + const apiBase = `https://apis.roblox.com/cloud/v2/universes/${universeId}`; + + if (sub === 'ban') { + const duration = parseInt(interaction.options.getString('duration') || '0'); + try { + const res = await fetch(`${apiBase}/user-restrictions/${robloxUser.id}`, { + method: 'PATCH', + headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + gameJoinRestriction: { + active: true, + duration: duration > 0 ? `${duration}s` : null, + privateReason: reason, + displayReason: reason, + }, + }), + }); + if (!res.ok) throw new Error(await res.text()); + + // Log to Supabase + await supabase?.from('roblox_mod_logs').insert({ + guild_id: interaction.guildId, + type: 'ban', + target_username: robloxUser.name, + target_roblox_id: robloxUser.id, + reason, + duration_seconds: duration, + mod_discord_id: interaction.user.id, + mod_username: interaction.user.username, + }); + + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xe53535) + .setTitle('🔨 Game Ban') + .addFields( + { name: 'Player', value: robloxUser.name, inline: true }, + { name: 'Duration', value: duration > 0 ? fmtDuration(duration) : 'Permanent', inline: true }, + { name: 'Moderator', value: `<@${interaction.user.id}>`, inline: true }, + { name: 'Reason', value: reason }, + ) + .setFooter({ text: `Roblox ID: ${robloxUser.id}` }) + .setTimestamp(), + ], + }); + } catch (err) { + return interaction.editReply(`❌ Ban failed: ${err.message}`); + } + } + + if (sub === 'unban') { + try { + const res = await fetch(`${apiBase}/user-restrictions/${robloxUser.id}`, { + method: 'PATCH', + headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ gameJoinRestriction: { active: false } }), + }); + if (!res.ok) throw new Error(await res.text()); + + await supabase?.from('roblox_mod_logs').insert({ + guild_id: interaction.guildId, + type: 'unban', + target_username: robloxUser.name, + target_roblox_id: robloxUser.id, + reason, + mod_discord_id: interaction.user.id, + mod_username: interaction.user.username, + }); + + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x22c55e) + .setTitle('✅ Game Unban') + .addFields( + { name: 'Player', value: robloxUser.name, inline: true }, + { name: 'Moderator', value: `<@${interaction.user.id}>`, inline: true }, + { name: 'Reason', value: reason }, + ) + .setTimestamp(), + ], + }); + } catch (err) { + return interaction.editReply(`❌ Unban failed: ${err.message}`); + } + } + + if (sub === 'kick') { + // Roblox Open Cloud doesn't have a direct kick API — kick via messaging service + // The game must listen for a "KickPlayer" message on a BindableEvent/MessagingService + try { + const res = await fetch(`https://apis.roblox.com/cloud/v2/universes/${universeId}/messaging-service:publish`, { + method: 'POST', + headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + topic: 'KickPlayer', + message: JSON.stringify({ userId: robloxUser.id, reason }), + }), + }); + if (!res.ok) throw new Error(await res.text()); + + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xf59e0b) + .setTitle('👢 Game Kick') + .addFields( + { name: 'Player', value: robloxUser.name, inline: true }, + { name: 'Moderator', value: `<@${interaction.user.id}>`, inline: true }, + { name: 'Reason', value: reason }, + ) + .setFooter({ text: 'Game must have KickPlayer messaging handler' }) + .setTimestamp(), + ], + }); + } catch (err) { + return interaction.editReply(`❌ Kick failed: ${err.message}`); + } + } + + if (sub === 'rank') { + try { + const groupId = config.roblox_group_id; + const avatarRes = await fetch( + `https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds=${robloxUser.id}&size=150x150&format=Png` + ); + const avatarData = await avatarRes.json(); + const avatarUrl = avatarData?.data?.[0]?.imageUrl || null; + + let groupRole = null; + if (groupId) { + groupRole = await getRobloxGroupRole(robloxUser.id, groupId); + } + + const embed = new EmbedBuilder() + .setColor(0xD4891A) + .setTitle(robloxUser.name) + .setURL(`https://www.roblox.com/users/${robloxUser.id}/profile`) + .addFields( + { name: 'Roblox ID', value: String(robloxUser.id), inline: true }, + { name: 'Display Name', value: robloxUser.displayName || robloxUser.name, inline: true }, + { name: 'Group Role', value: groupRole ? `${groupRole.name} (Rank ${groupRole.rank})` : '—', inline: true }, + ) + .setTimestamp(); + + if (avatarUrl) embed.setThumbnail(avatarUrl); + return interaction.editReply({ embeds: [embed] }); + } catch (err) { + return interaction.editReply(`❌ Lookup failed: ${err.message}`); + } + } + }, +}; + +function fmtNum(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + return String(n); +} + +function fmtDuration(seconds) { + if (seconds >= 2592000) return `${Math.floor(seconds / 2592000)}d`; + if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d`; + if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 60)}m`; +} diff --git a/aethex-bot/migrations/roblox_integration.sql b/aethex-bot/migrations/roblox_integration.sql new file mode 100644 index 0000000..4c1ca97 --- /dev/null +++ b/aethex-bot/migrations/roblox_integration.sql @@ -0,0 +1,32 @@ +-- ============================================================ +-- AeThex Bot — Roblox Integration Tables +-- Run in Supabase SQL editor +-- ============================================================ + +-- Per-server Roblox config (set via /config roblox) +create table if not exists roblox_server_configs ( + id uuid default gen_random_uuid() primary key, + guild_id text unique not null, + universe_id text not null, + open_cloud_key text not null, + roblox_group_id text, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +-- Roblox mod action log (game bans/unbans per server) +create table if not exists roblox_mod_logs ( + id uuid default gen_random_uuid() primary key, + guild_id text not null, + type text not null, -- ban | unban | kick + target_username text, + target_roblox_id bigint, + reason text, + duration_seconds int default 0, + mod_discord_id text, + mod_username text, + created_at timestamptz default now() +); + +alter table roblox_server_configs disable row level security; +alter table roblox_mod_logs disable row level security; diff --git a/aethex-bot/utils/robloxConfig.js b/aethex-bot/utils/robloxConfig.js new file mode 100644 index 0000000..7c4657c --- /dev/null +++ b/aethex-bot/utils/robloxConfig.js @@ -0,0 +1,48 @@ +/** + * robloxConfig.js + * Per-server Roblox integration config fetched from Supabase. + * Returns null if the server hasn't configured Roblox integration. + */ + +async function getRobloxConfig(supabase, guildId) { + if (!supabase) return null; + const { data } = await supabase + .from('roblox_server_configs') + .select('universe_id, open_cloud_key, place_id, staff_role_ids') + .eq('guild_id', guildId) + .single(); + return data || null; +} + +async function setRobloxConfig(supabase, guildId, config) { + if (!supabase) return false; + const { error } = await supabase + .from('roblox_server_configs') + .upsert({ guild_id: guildId, ...config }, { onConflict: 'guild_id' }); + return !error; +} + +// Resolve Roblox user ID from username +async function resolveRobloxUser(username) { + try { + const res = await fetch('https://users.roblox.com/v1/usernames/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ usernames: [username], excludeBannedUsers: false }), + }); + const data = await res.json(); + return data?.data?.[0] || null; // { id, name, displayName } + } catch { return null; } +} + +// Get Roblox group role for a user +async function getRobloxGroupRole(robloxUserId, groupId) { + try { + const res = await fetch(`https://groups.roblox.com/v2/users/${robloxUserId}/groups/roles`); + const data = await res.json(); + const membership = data?.data?.find(g => g.group?.id === parseInt(groupId)); + return membership?.role || null; + } catch { return null; } +} + +module.exports = { getRobloxConfig, setRobloxConfig, resolveRobloxUser, getRobloxGroupRole };