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:
Claude
2026-04-09 20:12:12 +00:00
parent 47a600eeb5
commit d227bce0a8

View 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();