- Maps Pterodactyl servers to Discord categories - Renames channels to consistent naming convention - DRY_RUN=true by default (preview mode) - 1.5s rate limit delay between operations - Snart Doctrine: built to adapt when reality hits
339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Discord Channel Rename Script
|
|
*
|
|
* Renames existing server channels to match consistent naming convention
|
|
* based on Pterodactyl server names.
|
|
*
|
|
* Expected naming:
|
|
* Category: 🎮 {Server Name}
|
|
* Text: {base-name}-chat
|
|
* Text: {base-name}-in-game
|
|
* Forum: {base-name}-forum
|
|
* Voice: {Server Name}
|
|
*
|
|
* Usage:
|
|
* DRY_RUN=true node discord-channel-rename.js # Preview changes
|
|
* DRY_RUN=false node discord-channel-rename.js # Execute changes
|
|
*
|
|
* Created: April 9, 2026
|
|
* Chronicler: #74
|
|
* Principle: "Make the plan. Execute the plan. Expect the plan to go off the rails."
|
|
*/
|
|
|
|
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
|
const { Client, GatewayIntentBits, ChannelType } = require('discord.js');
|
|
|
|
// ============================================================================
|
|
// CONFIGURATION
|
|
// ============================================================================
|
|
|
|
const DRY_RUN = process.env.DRY_RUN !== 'false'; // Default to dry run
|
|
const RATE_LIMIT_DELAY = 1500; // 1.5 seconds between operations
|
|
|
|
// ============================================================================
|
|
// HELPERS
|
|
// ============================================================================
|
|
|
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
/**
|
|
* Convert server name to base channel name
|
|
* "Homestead - A Cozy Survival Experience" → "homestead"
|
|
* "All The Mons (Private) - TX" → "all-the-mons"
|
|
* "Stoneblock 4" → "stoneblock-4"
|
|
*/
|
|
function toBaseName(serverName) {
|
|
return serverName
|
|
.split(' - ')[0] // Take part before " - " subtitle
|
|
.replace(/\s*\([^)]*\)\s*/g, '') // Remove parentheticals
|
|
.replace(/\s*\[[^\]]*\]\s*/g, '') // Remove brackets like [Dungeons & Dragons]
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s]/g, '') // Remove special chars except spaces
|
|
.replace(/\s+/g, '-') // Spaces to hyphens
|
|
.replace(/-+/g, '-') // Multiple hyphens to single
|
|
.replace(/^-|-$/g, '') // Trim leading/trailing hyphens
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Convert server name to voice channel display name
|
|
* Same as base but keeps proper case and removes subtitles/parentheticals
|
|
*/
|
|
function toVoiceName(serverName) {
|
|
return serverName
|
|
.split(' - ')[0]
|
|
.replace(/\s*\([^)]*\)\s*/g, '')
|
|
.replace(/\s*\[[^\]]*\]\s*/g, '')
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Fetch Minecraft servers from Pterodactyl
|
|
*/
|
|
async function fetchPterodactylServers() {
|
|
const PANEL_URL = process.env.PANEL_URL;
|
|
const API_KEY = process.env.PANEL_APPLICATION_KEY;
|
|
const NEST_IDS = (process.env.MINECRAFT_NEST_IDS || '1,5').split(',').map(n => parseInt(n.trim()));
|
|
|
|
const response = await fetch(`${PANEL_URL}/api/application/servers?per_page=100`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${API_KEY}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Pterodactyl API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Filter to Minecraft servers only
|
|
return data.data
|
|
.filter(s => NEST_IDS.includes(s.attributes.nest))
|
|
.map(s => ({
|
|
name: s.attributes.name,
|
|
identifier: s.attributes.identifier,
|
|
baseName: toBaseName(s.attributes.name),
|
|
voiceName: toVoiceName(s.attributes.name)
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Find the best matching Discord category for a server
|
|
*/
|
|
function findCategoryForServer(server, categories) {
|
|
const serverBase = server.baseName;
|
|
const serverVoice = server.voiceName.toLowerCase();
|
|
|
|
for (const cat of categories) {
|
|
const catName = cat.name.replace(/^🎮\s*/, '').toLowerCase();
|
|
const catBase = toBaseName(cat.name);
|
|
|
|
// Exact match on base name
|
|
if (catBase === serverBase) return cat;
|
|
|
|
// Category contains server voice name
|
|
if (catName.includes(serverVoice)) return cat;
|
|
|
|
// Server voice name contains category name
|
|
if (serverVoice.includes(catBase)) return cat;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Determine what a channel should be renamed to based on its type
|
|
*/
|
|
function getExpectedChannelName(channel, server) {
|
|
const baseName = server.baseName;
|
|
const voiceName = server.voiceName;
|
|
const currentName = channel.name.toLowerCase();
|
|
|
|
switch (channel.type) {
|
|
case ChannelType.GuildVoice:
|
|
return voiceName;
|
|
|
|
case ChannelType.GuildForum:
|
|
return `${baseName}-forum`;
|
|
|
|
case ChannelType.GuildText:
|
|
// Determine if this is chat or in-game based on current name
|
|
if (currentName.includes('in-game') || currentName.includes('ingame')) {
|
|
return `${baseName}-in-game`;
|
|
} else if (currentName.includes('chat') || currentName.includes('general')) {
|
|
return `${baseName}-chat`;
|
|
} else {
|
|
// Default text channel to chat
|
|
return `${baseName}-chat`;
|
|
}
|
|
|
|
default:
|
|
return null; // Don't rename unknown types
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN
|
|
// ============================================================================
|
|
|
|
async function main() {
|
|
console.log('');
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
console.log('🔧 Discord Channel Rename Script');
|
|
console.log(` Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will rename channels)'}`);
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
console.log('');
|
|
|
|
// Initialize Discord client
|
|
const client = new Client({
|
|
intents: [GatewayIntentBits.Guilds]
|
|
});
|
|
|
|
try {
|
|
// Login
|
|
console.log('📡 Connecting to Discord...');
|
|
await client.login(process.env.DISCORD_BOT_TOKEN);
|
|
await new Promise(resolve => client.once('ready', resolve));
|
|
console.log(` Logged in as ${client.user.tag}`);
|
|
|
|
// Get guild
|
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
|
if (!guild) {
|
|
throw new Error(`Guild not found: ${process.env.GUILD_ID}`);
|
|
}
|
|
console.log(` Guild: ${guild.name}`);
|
|
console.log('');
|
|
|
|
// Fetch Pterodactyl servers
|
|
console.log('📋 Fetching Pterodactyl servers...');
|
|
const servers = await fetchPterodactylServers();
|
|
console.log(` Found ${servers.length} Minecraft servers`);
|
|
console.log('');
|
|
|
|
// Get all 🎮 categories
|
|
const categories = guild.channels.cache
|
|
.filter(ch => ch.type === ChannelType.GuildCategory && ch.name.includes('🎮'))
|
|
.map(ch => ch);
|
|
console.log(` Found ${categories.length} game categories in Discord`);
|
|
console.log('');
|
|
|
|
// Stats
|
|
const stats = {
|
|
matched: 0,
|
|
unmatched: [],
|
|
renames: [],
|
|
alreadyCorrect: 0,
|
|
errors: []
|
|
};
|
|
|
|
// Process each server
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
console.log('PROCESSING SERVERS');
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
|
|
for (const server of servers) {
|
|
console.log(`\n📦 ${server.name}`);
|
|
console.log(` Base: ${server.baseName} | Voice: ${server.voiceName}`);
|
|
|
|
// Find matching category
|
|
const category = findCategoryForServer(server, categories);
|
|
|
|
if (!category) {
|
|
console.log(` ⚠️ No matching category found`);
|
|
stats.unmatched.push(server.name);
|
|
continue;
|
|
}
|
|
|
|
console.log(` 📁 Matched to: ${category.name}`);
|
|
stats.matched++;
|
|
|
|
// Get children of this category
|
|
const children = guild.channels.cache.filter(ch => ch.parentId === category.id);
|
|
|
|
// Check each child channel
|
|
for (const [, channel] of children) {
|
|
const expectedName = getExpectedChannelName(channel, server);
|
|
|
|
if (!expectedName) {
|
|
console.log(` ⏭️ Skipping ${channel.name} (unknown type: ${channel.type})`);
|
|
continue;
|
|
}
|
|
|
|
if (channel.name === expectedName) {
|
|
console.log(` ✓ ${channel.name} (already correct)`);
|
|
stats.alreadyCorrect++;
|
|
} else {
|
|
console.log(` 🔄 ${channel.name} → ${expectedName}`);
|
|
stats.renames.push({
|
|
from: channel.name,
|
|
to: expectedName,
|
|
channelId: channel.id,
|
|
type: ChannelType[channel.type]
|
|
});
|
|
|
|
if (!DRY_RUN) {
|
|
try {
|
|
await channel.setName(expectedName, 'Discord channel rename script - consistent naming');
|
|
console.log(` ✅ Renamed!`);
|
|
await sleep(RATE_LIMIT_DELAY);
|
|
} catch (err) {
|
|
console.log(` ❌ Error: ${err.message}`);
|
|
stats.errors.push({ channel: channel.name, error: err.message });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if category itself needs renaming
|
|
const expectedCatName = `🎮 ${server.voiceName}`;
|
|
if (category.name !== expectedCatName) {
|
|
console.log(` 🔄 Category: ${category.name} → ${expectedCatName}`);
|
|
stats.renames.push({
|
|
from: category.name,
|
|
to: expectedCatName,
|
|
channelId: category.id,
|
|
type: 'Category'
|
|
});
|
|
|
|
if (!DRY_RUN) {
|
|
try {
|
|
await category.setName(expectedCatName, 'Discord channel rename script - consistent naming');
|
|
console.log(` ✅ Renamed!`);
|
|
await sleep(RATE_LIMIT_DELAY);
|
|
} catch (err) {
|
|
console.log(` ❌ Error: ${err.message}`);
|
|
stats.errors.push({ channel: category.name, error: err.message });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
console.log('');
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
console.log('SUMMARY');
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
console.log(` Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
|
|
console.log(` Servers matched: ${stats.matched}`);
|
|
console.log(` Already correct: ${stats.alreadyCorrect}`);
|
|
console.log(` Renames ${DRY_RUN ? 'needed' : 'executed'}: ${stats.renames.length}`);
|
|
|
|
if (stats.unmatched.length > 0) {
|
|
console.log('');
|
|
console.log(' ⚠️ Unmatched servers (no Discord category found):');
|
|
stats.unmatched.forEach(s => console.log(` - ${s}`));
|
|
}
|
|
|
|
if (stats.errors.length > 0) {
|
|
console.log('');
|
|
console.log(' ❌ Errors:');
|
|
stats.errors.forEach(e => console.log(` - ${e.channel}: ${e.error}`));
|
|
}
|
|
|
|
if (DRY_RUN && stats.renames.length > 0) {
|
|
console.log('');
|
|
console.log(' 📝 Pending renames:');
|
|
stats.renames.forEach(r => console.log(` ${r.type}: ${r.from} → ${r.to}`));
|
|
console.log('');
|
|
console.log(' Run with DRY_RUN=false to execute these changes.');
|
|
}
|
|
|
|
console.log('');
|
|
|
|
} catch (error) {
|
|
console.error('');
|
|
console.error('❌ FATAL ERROR:', error.message);
|
|
if (error.stack) {
|
|
console.error(error.stack);
|
|
}
|
|
} finally {
|
|
client.destroy();
|
|
console.log('👋 Disconnected from Discord.');
|
|
}
|
|
}
|
|
|
|
main();
|