diff --git a/services/arbiter-3.0/src/index.js b/services/arbiter-3.0/src/index.js index 9c88865..640da88 100644 --- a/services/arbiter-3.0/src/index.js +++ b/services/arbiter-3.0/src/index.js @@ -142,6 +142,11 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN) initCron(); console.log('✅ Hourly sync cron initialized.'); +// Initialize Server Status Poller (updates Discord status channels every 5 minutes) +const serverStatusPoller = require('./services/serverStatusPoller'); +serverStatusPoller.start(5); // 5 minute interval +console.log('✅ Server status poller initialized (5 min interval).'); + // Error handling process.on('unhandledRejection', error => { console.error('❌ Unhandled promise rejection:', error); diff --git a/services/arbiter-3.0/src/services/serverStatusPoller.js b/services/arbiter-3.0/src/services/serverStatusPoller.js new file mode 100644 index 0000000..b780fe1 --- /dev/null +++ b/services/arbiter-3.0/src/services/serverStatusPoller.js @@ -0,0 +1,237 @@ +/** + * Server Status Poller Service + * + * Posts and updates server status embeds in Discord channels. + * Each game server category has a -status channel that shows live status. + * + * Created: April 10, 2026 (Chronicler #75) + */ + +const axios = require('axios'); +const db = require('../database'); + +// Discord API +const DISCORD_API = 'https://discord.com/api/v10'; +const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN; + +// Servers API (Cloudflare Worker that proxies Pterodactyl) +const SERVERS_API = 'https://servers-api.firefrostgaming.workers.dev'; + +// Map server names to their status channel IDs +// Key: lowercase server name (from Pterodactyl) +// Value: Discord channel ID +const SERVER_CHANNEL_MAP = { + 'stoneblock 4': '1492186819918565468', + 'society: sunlit valley': '1492186823332729073', + 'society sunlit valley': '1492186823332729073', + 'all the mods 10: to the sky': '1492186826746757322', + 'atm10: to the sky': '1492186826746757322', + 'all the mons': '1492186832560193618', + 'mythcraft 5': '1492186836422889643', + 'beyond depth': '1492186839979786440', + 'beyond ascension': '1492186845742891038', + 'otherworld': '1492186850545111090', + 'deceasedcraft': '1492186854320242820', + 'submerged 2': '1492186858413883525', + 'sneak\'s pirate pack': '1492186863774204144', + 'sneaks pirate pack': '1492186863774204144', + 'cottage witch': '1492186867372785855', + 'farm crossing 5': '1492186871382409337', + 'homestead': '1492186875019005963', + 'wold\'s vaults': '1492186878269587528', + 'wolds vaults': '1492186878269587528' +}; + +// Store message IDs so we can edit instead of posting new +// In-memory for now, persists to DB for restarts +const statusMessageIds = {}; + +/** + * Initialize - load stored message IDs from database + */ +async function init() { + try { + // Create table if not exists + await db.query(` + CREATE TABLE IF NOT EXISTS discord_status_messages ( + channel_id VARCHAR(64) PRIMARY KEY, + message_id VARCHAR(64) NOT NULL, + server_name VARCHAR(128), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + + // Load existing message IDs + const result = await db.query('SELECT channel_id, message_id FROM discord_status_messages'); + for (const row of result.rows) { + statusMessageIds[row.channel_id] = row.message_id; + } + + console.log(`[StatusPoller] Loaded ${result.rows.length} existing status message IDs`); + } catch (err) { + console.error('[StatusPoller] Init error:', err.message); + } +} + +/** + * Build status embed for a server + */ +function buildEmbed(server) { + const isOnline = server.status === 'Online'; + + return { + embeds: [{ + title: `${isOnline ? '🟢' : '🔴'} ${server.name}`, + color: isOnline ? 0x00FF00 : 0xFF0000, + fields: [ + { + name: 'Status', + value: server.status, + inline: true + }, + { + name: 'Players', + value: isOnline ? `${server.players || 0}` : '-', + inline: true + } + ], + footer: { + text: `Last updated` + }, + timestamp: new Date().toISOString() + }] + }; +} + +/** + * Post or update a status message in a channel + */ +async function postOrUpdateStatus(channelId, server) { + const embed = buildEmbed(server); + const existingMessageId = statusMessageIds[channelId]; + + try { + if (existingMessageId) { + // Try to edit existing message + const response = await axios.patch( + `${DISCORD_API}/channels/${channelId}/messages/${existingMessageId}`, + embed, + { headers: { 'Authorization': `Bot ${DISCORD_TOKEN}`, 'Content-Type': 'application/json' } } + ); + return { success: true, action: 'updated', messageId: response.data.id }; + } else { + // Post new message + const response = await axios.post( + `${DISCORD_API}/channels/${channelId}/messages`, + embed, + { headers: { 'Authorization': `Bot ${DISCORD_TOKEN}`, 'Content-Type': 'application/json' } } + ); + + const messageId = response.data.id; + statusMessageIds[channelId] = messageId; + + // Store in database + await db.query(` + INSERT INTO discord_status_messages (channel_id, message_id, server_name) + VALUES ($1, $2, $3) + ON CONFLICT (channel_id) DO UPDATE SET message_id = $2, server_name = $3, updated_at = NOW() + `, [channelId, messageId, server.name]); + + return { success: true, action: 'posted', messageId }; + } + } catch (err) { + // If edit fails (message deleted?), try posting new + if (err.response?.status === 404 && existingMessageId) { + delete statusMessageIds[channelId]; + return postOrUpdateStatus(channelId, server); + } + + console.error(`[StatusPoller] Error posting to ${channelId}:`, err.message); + return { success: false, error: err.message }; + } +} + +/** + * Find channel ID for a server name + */ +function findChannelForServer(serverName) { + const normalized = serverName.toLowerCase().trim(); + + // Direct match + if (SERVER_CHANNEL_MAP[normalized]) { + return SERVER_CHANNEL_MAP[normalized]; + } + + // Partial match + for (const [key, channelId] of Object.entries(SERVER_CHANNEL_MAP)) { + if (normalized.includes(key) || key.includes(normalized)) { + return channelId; + } + } + + return null; +} + +/** + * Poll all servers and update Discord status messages + */ +async function pollAndUpdate() { + console.log('[StatusPoller] Polling server status...'); + + try { + // Fetch server status from Cloudflare Worker + const response = await axios.get(SERVERS_API); + const servers = response.data.servers || []; + + console.log(`[StatusPoller] Got ${servers.length} servers`); + + let updated = 0; + let skipped = 0; + + for (const server of servers) { + const channelId = findChannelForServer(server.name); + + if (!channelId) { + console.log(`[StatusPoller] No channel mapping for: ${server.name}`); + skipped++; + continue; + } + + const result = await postOrUpdateStatus(channelId, server); + if (result.success) { + updated++; + } + + // Rate limiting - 1 request per 500ms + await new Promise(resolve => setTimeout(resolve, 500)); + } + + console.log(`[StatusPoller] Updated ${updated} channels, skipped ${skipped}`); + + } catch (err) { + console.error('[StatusPoller] Poll error:', err.message); + } +} + +/** + * Start the poller with interval + */ +function start(intervalMinutes = 5) { + console.log(`[StatusPoller] Starting with ${intervalMinutes} minute interval`); + + // Initialize and run immediately + init().then(() => { + pollAndUpdate(); + }); + + // Then run on interval + setInterval(pollAndUpdate, intervalMinutes * 60 * 1000); +} + +module.exports = { + init, + pollAndUpdate, + start, + findChannelForServer, + SERVER_CHANNEL_MAP +};