From d227bce0a891fdf6add106115e5ce78b8fa20cac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 20:12:12 +0000 Subject: [PATCH] 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 --- .../scripts/discord-channel-rename.js | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 services/arbiter-3.0/scripts/discord-channel-rename.js diff --git a/services/arbiter-3.0/scripts/discord-channel-rename.js b/services/arbiter-3.0/scripts/discord-channel-rename.js new file mode 100644 index 0000000..dbca808 --- /dev/null +++ b/services/arbiter-3.0/scripts/discord-channel-rename.js @@ -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();