feat: Add server status poller for Discord channels (#107)
New service that polls Pterodactyl (via servers-api Worker) every 5 min and posts/updates status embeds in each game server's -status channel. - Creates discord_status_messages table to persist message IDs - Maps server names to channel IDs for all 15 game servers - Posts new embed or edits existing one (no spam) - Handles message deletion gracefully (re-posts if needed) Status channels created: - stoneblock-4-status, society-sunlit-valley-status, atm10-tts-status - all-the-mons-status, mythcraft-5-status, beyond-depth-status - beyond-ascension-status, otherworld-status, deceasedcraft-status - submerged-2-status, sneaks-pirate-pack-status, cottage-witch-status - farm-crossing-5-status, homestead-status, wolds-vaults-status Chronicler #75
This commit is contained in:
@@ -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);
|
||||
|
||||
237
services/arbiter-3.0/src/services/serverStatusPoller.js
Normal file
237
services/arbiter-3.0/src/services/serverStatusPoller.js
Normal file
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user