Add Roblox integration: /roblox commands gated behind /config roblox
This commit is contained in:
parent
1d69c3b9dc
commit
bc96272b4a
4 changed files with 382 additions and 0 deletions
|
|
@ -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');
|
||||
|
|
|
|||
266
aethex-bot/commands/roblox.js
Normal file
266
aethex-bot/commands/roblox.js
Normal 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`;
|
||||
}
|
||||
32
aethex-bot/migrations/roblox_integration.sql
Normal file
32
aethex-bot/migrations/roblox_integration.sql
Normal 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;
|
||||
48
aethex-bot/utils/robloxConfig.js
Normal file
48
aethex-bot/utils/robloxConfig.js
Normal 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 };
|
||||
Loading…
Reference in a new issue