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:
Claude
2026-04-10 15:39:04 +00:00
parent 0acea3b95f
commit 811e3046cf
2 changed files with 242 additions and 0 deletions

View File

@@ -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);

View 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
};