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`; }