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 { 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');
|
||||||
|
|
|
||||||
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