Add Roblox integration: /roblox commands gated behind /config roblox

This commit is contained in:
mrpiglr 2026-03-22 18:50:53 +00:00
parent 1d69c3b9dc
commit bc96272b4a
4 changed files with 382 additions and 0 deletions

View file

@ -1,5 +1,6 @@
const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
const { getServerMode, setServerMode, getEmbedColor, getModeDisplayName, getModeEmoji, EMBED_COLORS } = require('../utils/modeHelper'); const { getServerMode, setServerMode, getEmbedColor, getModeDisplayName, getModeEmoji, EMBED_COLORS } = require('../utils/modeHelper');
const { setRobloxConfig, getRobloxConfig } = require('../utils/robloxConfig');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@ -78,6 +79,13 @@ module.exports = {
.addSubcommand(sub => .addSubcommand(sub =>
sub.setName('mode') sub.setName('mode')
.setDescription('Switch between Federation and Standalone 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) { async execute(interaction, supabase, client) {
@ -213,6 +221,34 @@ module.exports = {
autorole: 'auto_role', 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') { if (subcommand === 'levelrole') {
const role = interaction.options.getRole('role'); const role = interaction.options.getRole('role');
const level = interaction.options.getInteger('level'); const level = interaction.options.getInteger('level');

View file

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

View file

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

View file

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