Add discord-channel-rename.js script
- 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
This commit is contained in:
338
services/arbiter-3.0/scripts/discord-channel-rename.js
Normal file
338
services/arbiter-3.0/scripts/discord-channel-rename.js
Normal file
@@ -0,0 +1,338 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user