From b0adf16c1a98f8f15261c554637b9734f958c8ec Mon Sep 17 00:00:00 2001 From: demassimo <87481244+demassimo@users.noreply.github.com> Date: Sat, 27 Sep 2025 00:02:17 +0200 Subject: [PATCH] Enhance Discord bot status automation --- backend/src/db/mysql.js | 59 ++- backend/src/db/sqlite.js | 54 ++- backend/src/discord-bot-service.js | 649 +++++++++++++++++++++++++---- backend/src/index.js | 8 +- 4 files changed, 679 insertions(+), 91 deletions(-) diff --git a/backend/src/db/mysql.js b/backend/src/db/mysql.js index c3ac12c..6ef2b56 100644 --- a/backend/src/db/mysql.js +++ b/backend/src/db/mysql.js @@ -139,10 +139,12 @@ function createApi(pool, dialect) { bot_token TEXT NULL, guild_id VARCHAR(64) NULL, channel_id VARCHAR(64) NULL, + status_message_id VARCHAR(64) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_server_discord FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE ) ENGINE=InnoDB;`); + await ensureColumn('ALTER TABLE server_discord_integrations ADD COLUMN status_message_id VARCHAR(64) NULL'); }, async countUsers(){ const r = await exec('SELECT COUNT(*) c FROM users'); const row = Array.isArray(r)?r[0]:r; return row.c ?? row['COUNT(*)']; }, async createUser(u){ @@ -293,7 +295,7 @@ function createApi(pool, dialect) { if (!Number.isFinite(serverIdNum)) return []; return await exec(` SELECT sp.server_id, sp.steamid, sp.display_name, sp.forced_display_name, sp.first_seen, sp.last_seen, - sp.last_ip, sp.last_port, + sp.last_ip, sp.last_port, p.persona, p.avatar, p.country, p.profileurl, p.vac_banned, p.game_bans, p.last_ban_days, p.visibility, p.rust_playtime_minutes, p.playtime_updated_at, p.updated_at FROM server_players sp @@ -303,6 +305,50 @@ function createApi(pool, dialect) { LIMIT ? OFFSET ? `,[serverIdNum,limit,offset]); }, + async searchServerPlayers(serverId, query, { limit = 10 } = {}){ + const serverIdNum = Number(serverId); + if (!Number.isFinite(serverIdNum)) return []; + const term = typeof query === 'string' ? query.trim() : ''; + if (!term) return []; + const escapeLike = (value) => String(value).replace(/[\\%_]/g, (m) => `\\${m}`); + const likeTerm = `%${escapeLike(term)}%`; + const limitValue = Number(limit); + const limitNum = Number.isFinite(limitValue) && limitValue > 0 ? Math.min(Math.floor(limitValue), 25) : 10; + return await exec(` + SELECT sp.server_id, sp.steamid, sp.display_name, sp.forced_display_name, sp.first_seen, sp.last_seen, + sp.last_ip, sp.last_port, + p.persona, p.avatar, p.country, p.profileurl, p.vac_banned, p.game_bans, + p.last_ban_days, p.visibility, p.rust_playtime_minutes, p.playtime_updated_at, p.updated_at + FROM server_players sp + LEFT JOIN players p ON p.steamid = sp.steamid + WHERE sp.server_id=? + AND ( + sp.steamid = ? OR + sp.display_name LIKE ? ESCAPE '\\' OR + sp.forced_display_name LIKE ? ESCAPE '\\' OR + p.persona LIKE ? ESCAPE '\\' + ) + ORDER BY sp.last_seen DESC + LIMIT ? + `,[serverIdNum, term, likeTerm, likeTerm, likeTerm, limitNum]); + }, + async getServerPlayer(serverId, steamid){ + const serverIdNum = Number(serverId); + if (!Number.isFinite(serverIdNum)) return null; + const sid = typeof steamid === 'string' ? steamid.trim() : ''; + if (!sid) return null; + const rows = await exec(` + SELECT sp.server_id, sp.steamid, sp.display_name, sp.forced_display_name, sp.first_seen, sp.last_seen, + sp.last_ip, sp.last_port, + p.persona, p.avatar, p.country, p.profileurl, p.vac_banned, p.game_bans, + p.last_ban_days, p.visibility, p.rust_playtime_minutes, p.playtime_updated_at, p.updated_at + FROM server_players sp + LEFT JOIN players p ON p.steamid = sp.steamid + WHERE sp.server_id=? AND sp.steamid=? + LIMIT 1 + `,[serverIdNum, sid]); + return rows?.[0] ?? null; + }, async setServerPlayerDisplayName({ server_id, steamid, display_name = null }){ const serverIdNum = Number(server_id); if (!Number.isFinite(serverIdNum)) return 0; @@ -363,15 +409,16 @@ function createApi(pool, dialect) { ); return rows?.[0] ?? null; }, - async saveServerDiscordIntegration(serverId,{ bot_token=null,guild_id=null,channel_id=null }){ + async saveServerDiscordIntegration(serverId,{ bot_token=null,guild_id=null,channel_id=null,status_message_id=null }){ await exec(` - INSERT INTO server_discord_integrations(server_id, bot_token, guild_id, channel_id) - VALUES(?,?,?,?) + INSERT INTO server_discord_integrations(server_id, bot_token, guild_id, channel_id, status_message_id) + VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE bot_token=VALUES(bot_token), guild_id=VALUES(guild_id), - channel_id=VALUES(channel_id) - `,[serverId, bot_token, guild_id, channel_id]); + channel_id=VALUES(channel_id), + status_message_id=VALUES(status_message_id) + `,[serverId, bot_token, guild_id, channel_id, status_message_id]); }, async deleteServerDiscordIntegration(serverId){ const result = await exec('DELETE FROM server_discord_integrations WHERE server_id=?',[serverId]); diff --git a/backend/src/db/sqlite.js b/backend/src/db/sqlite.js index 65709e8..0b88d40 100644 --- a/backend/src/db/sqlite.js +++ b/backend/src/db/sqlite.js @@ -116,11 +116,16 @@ function createApi(dbh, dialect) { bot_token TEXT, guild_id TEXT, channel_id TEXT, + status_message_id TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY(server_id) REFERENCES servers(id) ON DELETE CASCADE ); `); + const discordCols = await dbh.all("PRAGMA table_info('server_discord_integrations')"); + if (!discordCols.some((c) => c.name === 'status_message_id')) { + await dbh.run("ALTER TABLE server_discord_integrations ADD COLUMN status_message_id TEXT"); + } const userCols = await dbh.all("PRAGMA table_info('users')"); if (!userCols.some((c) => c.name === 'role')) { await dbh.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); @@ -355,6 +360,49 @@ function createApi(dbh, dialect) { LIMIT ? OFFSET ? `,[serverIdNum,limit,offset]); }, + async searchServerPlayers(serverId, query, { limit = 10 } = {}) { + const serverIdNum = Number(serverId); + if (!Number.isFinite(serverIdNum)) return []; + const term = typeof query === 'string' ? query.trim() : ''; + if (!term) return []; + const escapeLike = (value) => String(value).replace(/[\\%_]/g, (m) => `\\${m}`); + const likeTerm = `%${escapeLike(term)}%`; + const limitValue = Number(limit); + const limitNum = Number.isFinite(limitValue) && limitValue > 0 ? Math.min(Math.floor(limitValue), 25) : 10; + return await dbh.all(` + SELECT sp.server_id, sp.steamid, sp.display_name, sp.forced_display_name, sp.first_seen, sp.last_seen, + sp.last_ip, sp.last_port, + p.persona, p.avatar, p.country, p.profileurl, p.vac_banned, p.game_bans, + p.last_ban_days, p.visibility, p.rust_playtime_minutes, p.playtime_updated_at, p.updated_at + FROM server_players sp + LEFT JOIN players p ON p.steamid = sp.steamid + WHERE sp.server_id=? + AND ( + sp.steamid = ? OR + sp.display_name LIKE ? ESCAPE '\\' OR + sp.forced_display_name LIKE ? ESCAPE '\\' OR + p.persona LIKE ? ESCAPE '\\' + ) + ORDER BY sp.last_seen DESC + LIMIT ? + `, [serverIdNum, term, likeTerm, likeTerm, likeTerm, limitNum]); + }, + async getServerPlayer(serverId, steamid) { + const serverIdNum = Number(serverId); + if (!Number.isFinite(serverIdNum)) return null; + const sid = typeof steamid === 'string' ? steamid.trim() : ''; + if (!sid) return null; + return await dbh.get(` + SELECT sp.server_id, sp.steamid, sp.display_name, sp.forced_display_name, sp.first_seen, sp.last_seen, + sp.last_ip, sp.last_port, + p.persona, p.avatar, p.country, p.profileurl, p.vac_banned, p.game_bans, + p.last_ban_days, p.visibility, p.rust_playtime_minutes, p.playtime_updated_at, p.updated_at + FROM server_players sp + LEFT JOIN players p ON p.steamid = sp.steamid + WHERE sp.server_id=? AND sp.steamid=? + LIMIT 1 + `, [serverIdNum, sid]); + }, async setServerPlayerDisplayName({ server_id, steamid, display_name = null }){ const serverIdNum = Number(server_id); if (!Number.isFinite(serverIdNum)) return 0; @@ -409,10 +457,10 @@ function createApi(dbh, dialect) { [serverId] ); }, - async saveServerDiscordIntegration(serverId,{ bot_token=null,guild_id=null,channel_id=null }){ + async saveServerDiscordIntegration(serverId,{ bot_token=null,guild_id=null,channel_id=null,status_message_id=null }){ await dbh.run( - "INSERT INTO server_discord_integrations(server_id,bot_token,guild_id,channel_id,created_at,updated_at) VALUES(?,?,?,?,datetime('now'),datetime('now')) ON CONFLICT(server_id) DO UPDATE SET bot_token=excluded.bot_token, guild_id=excluded.guild_id, channel_id=excluded.channel_id, updated_at=excluded.updated_at", - [serverId, bot_token, guild_id, channel_id] + "INSERT INTO server_discord_integrations(server_id,bot_token,guild_id,channel_id,status_message_id,created_at,updated_at) VALUES(?,?,?,?,?,datetime('now'),datetime('now')) ON CONFLICT(server_id) DO UPDATE SET bot_token=excluded.bot_token, guild_id=excluded.guild_id, channel_id=excluded.channel_id, status_message_id=excluded.status_message_id, updated_at=excluded.updated_at", + [serverId, bot_token, guild_id, channel_id, status_message_id] ); }, async deleteServerDiscordIntegration(serverId){ diff --git a/backend/src/discord-bot-service.js b/backend/src/discord-bot-service.js index f991bda..a3f69a0 100644 --- a/backend/src/discord-bot-service.js +++ b/backend/src/discord-bot-service.js @@ -1,13 +1,28 @@ import 'dotenv/config'; import { setTimeout as delay } from 'node:timers/promises'; import process from 'node:process'; -import { Client, GatewayIntentBits, ActivityType } from 'discord.js'; +import { + Client, + GatewayIntentBits, + ActivityType, + EmbedBuilder, + ApplicationCommandOptionType, + PermissionFlagsBits, + ChannelType, + escapeMarkdown +} from 'discord.js'; import { initDb, db } from './db/index.js'; const MIN_REFRESH_MS = 10000; const DEFAULT_REFRESH_MS = 60000; const DEFAULT_STALE_MS = 5 * 60 * 1000; +const STATUS_COLORS = { + online: 0x57f287, + offline: 0xed4245, + stale: 0xfee75c +}; + const refreshInterval = Math.max( Number(process.env.DISCORD_BOT_REFRESH_MS ?? DEFAULT_REFRESH_MS) || DEFAULT_REFRESH_MS, MIN_REFRESH_MS @@ -34,6 +49,24 @@ function sanitizeId(value) { return String(value).trim(); } +function safeNumber(value) { + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function formatCount(value, fallback = '—') { + const num = safeNumber(value); + if (num == null) return fallback; + return String(num); +} + +function formatDiscordTimestamp(date, style = 'R') { + const parsed = parseDate(date); + if (!parsed) return 'unknown'; + const seconds = Math.floor(parsed.getTime() / 1000); + return ``; +} + async function loadIntegrations() { if (typeof db.listServerDiscordIntegrations === 'function') { return await db.listServerDiscordIntegrations(); @@ -52,6 +85,113 @@ async function loadIntegrations() { return integrations; } +function createDiscordClient(state) { + const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] }); + + client.on('ready', async () => { + state.ready = true; + state.cooldownMs = MIN_REFRESH_MS; + state.cooldownUntil = 0; + const username = client.user?.tag ?? '(unknown)'; + console.log(`discord bot ready for server ${state.serverId} as ${username}`); + try { + await registerCommands(state); + } catch (err) { + console.error(`failed to register slash commands for server ${state.serverId}`, err); + } + }); + + client.on('error', (err) => { + console.error(`discord client error (server ${state.serverId})`, err); + }); + + client.on('shardError', (err) => { + console.error(`discord shard error (server ${state.serverId})`, err); + }); + + client.on('interactionCreate', (interaction) => { + handleInteraction(state, interaction).catch((err) => { + console.error(`failed to handle interaction for server ${state.serverId}`, err); + }); + }); + + return client; +} + +async function registerCommands(state) { + if (!state.client?.application || !state.guildId) return; + const commands = [ + { + name: 'ruststatus', + description: 'Manage the Rust server status message', + dm_permission: false, + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: 'status', + description: 'Show the latest status snapshot' + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'setchannel', + description: 'Select the channel used for status updates', + options: [ + { + type: ApplicationCommandOptionType.Channel, + name: 'channel', + description: 'Channel to post the status message in', + channel_types: [ChannelType.GuildText, ChannelType.GuildAnnouncement], + required: false + } + ] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'refresh', + description: 'Force an immediate status refresh' + } + ] + }, + { + name: 'rustlookup', + description: 'Lookup player information from the control panel database', + dm_permission: false, + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: 'player', + description: 'Search for players by name', + options: [ + { + type: ApplicationCommandOptionType.String, + name: 'query', + description: 'Partial name or SteamID64 to search for', + required: true, + min_length: 2 + } + ] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'steamid', + description: 'Lookup a specific player by SteamID64', + options: [ + { + type: ApplicationCommandOptionType.String, + name: 'id', + description: 'SteamID64 to lookup', + required: true, + min_length: 5 + } + ] + } + ] + } + ]; + + await state.client.application.commands.set(commands, state.guildId); +} + async function shutdownBot(serverId) { const state = bots.get(serverId); if (!state) return; @@ -63,6 +203,27 @@ async function shutdownBot(serverId) { } } +async function persistIntegration(state) { + if (typeof db.saveServerDiscordIntegration !== 'function') return; + try { + await db.saveServerDiscordIntegration(state.serverId, { + bot_token: state.token, + guild_id: state.guildId, + channel_id: state.channelId, + status_message_id: state.statusMessageId || null + }); + state.integration = { + ...(state.integration || {}), + bot_token: state.token, + guild_id: state.guildId, + channel_id: state.channelId, + status_message_id: state.statusMessageId || null + }; + } catch (err) { + console.error(`failed to persist discord integration for server ${state.serverId}`, err); + } +} + async function ensureBot(integration) { const serverId = Number(integration?.server_id ?? integration?.serverId); if (!Number.isFinite(serverId)) return null; @@ -70,6 +231,7 @@ async function ensureBot(integration) { const token = sanitizeId(integration?.bot_token ?? integration?.botToken); const guildId = sanitizeId(integration?.guild_id ?? integration?.guildId); const channelId = sanitizeId(integration?.channel_id ?? integration?.channelId); + const statusMessageId = sanitizeId(integration?.status_message_id ?? integration?.statusMessageId); if (!token || !guildId || !channelId) { await shutdownBot(serverId); @@ -88,40 +250,33 @@ async function ensureBot(integration) { token, guildId, channelId, - client: new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] }), + client: null, ready: false, connectPromise: null, cooldownMs: MIN_REFRESH_MS, cooldownUntil: 0, channel: null, + statusMessageId, lastPresenceKey: null, - lastAnnouncement: null + lastStatusEmbedKey: null, + integration }; - - state.client.on('ready', () => { - state.ready = true; - state.cooldownMs = MIN_REFRESH_MS; - state.cooldownUntil = 0; - const username = state.client.user?.tag ?? '(unknown)'; - console.log(`discord bot ready for server ${serverId} as ${username}`); - }); - - state.client.on('error', (err) => { - console.error(`discord client error (server ${serverId})`, err); - }); - - state.client.on('shardError', (err) => { - console.error(`discord shard error (server ${serverId})`, err); - }); - + state.client = createDiscordClient(state); bots.set(serverId, state); } + state.token = token; state.guildId = guildId; if (state.channelId !== channelId) { state.channelId = channelId; state.channel = null; + state.statusMessageId = statusMessageId; + state.lastStatusEmbedKey = null; + } else if (state.statusMessageId !== statusMessageId) { + state.statusMessageId = statusMessageId; + state.lastStatusEmbedKey = null; } + state.integration = integration; const now = Date.now(); if (state.cooldownUntil > now) { @@ -144,20 +299,7 @@ async function ensureBot(integration) { const nextCooldown = Math.min(state.cooldownMs * 2, 5 * 60 * 1000); state.cooldownMs = Math.max(nextCooldown, MIN_REFRESH_MS); state.cooldownUntil = Date.now() + state.cooldownMs; - state.client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] }); - state.client.on('ready', () => { - state.ready = true; - state.cooldownMs = MIN_REFRESH_MS; - state.cooldownUntil = 0; - const username = state.client.user?.tag ?? '(unknown)'; - console.log(`discord bot ready for server ${serverId} as ${username}`); - }); - state.client.on('error', (innerErr) => { - console.error(`discord client error (server ${serverId})`, innerErr); - }); - state.client.on('shardError', (innerErr) => { - console.error(`discord shard error (server ${serverId})`, innerErr); - }); + state.client = createDiscordClient(state); throw err; } })().finally(() => { @@ -203,38 +345,7 @@ async function ensureChannel(state) { } } -function formatPresence(isOnline, players, maxPlayers) { - if (!isOnline) { - return { - status: 'dnd', - activity: 'Server offline' - }; - } - const maxPart = Number.isFinite(maxPlayers) ? `/${maxPlayers}` : ''; - return { - status: 'online', - activity: `${players}${maxPart} players` - }; -} - -function formatAnnouncement(isOnline, serverName, players, maxPlayers, queued, sleepers) { - if (!isOnline) { - return `❌ **${serverName}** is offline.`; - } - const lines = [`✅ **${serverName}** is online.`]; - const maxPart = Number.isFinite(maxPlayers) ? `${maxPlayers}` : 'unknown'; - lines.push(`• Players: ${players}/${maxPart}`); - if (Number.isFinite(queued) && queued > 0) { - lines.push(`• Queued: ${queued}`); - } - if (Number.isFinite(sleepers) && sleepers > 0) { - lines.push(`• Sleepers: ${sleepers}`); - } - return lines.join('\n'); -} - -async function updateBot(state, integration) { - const serverId = state.serverId; +async function loadServerStatus(serverId) { let serverName = `Server ${serverId}`; try { if (typeof db.getServer === 'function') { @@ -260,8 +371,11 @@ async function updateBot(state, integration) { let maxPlayers = null; let queued = null; let sleepers = null; + let joining = null; + let fps = null; let recordedAt = null; let onlineFlag = null; + if (stats) { const playerCount = Number(stats.player_count ?? stats.playerCount); if (Number.isFinite(playerCount) && playerCount >= 0) { @@ -279,41 +393,414 @@ async function updateBot(state, integration) { if (Number.isFinite(sleeperCount) && sleeperCount >= 0) { sleepers = sleeperCount; } + const joiningCount = Number(stats.joining); + if (Number.isFinite(joiningCount) && joiningCount >= 0) { + joining = joiningCount; + } + const fpsValue = Number(stats.fps); + if (Number.isFinite(fpsValue) && fpsValue >= 0) { + fps = fpsValue; + } recordedAt = parseDate(stats.recorded_at ?? stats.recordedAt); const onlineRaw = stats.online ?? stats.is_online ?? stats.onlineFlag; if (typeof onlineRaw === 'boolean') onlineFlag = onlineRaw; else if (onlineRaw != null) onlineFlag = Number(onlineRaw) !== 0; } + const hasStats = stats != null; const isRecent = recordedAt ? (Date.now() - recordedAt.getTime()) <= staleThreshold : false; - const isOnline = stats != null && isRecent && (onlineFlag == null ? true : onlineFlag); + const stale = hasStats && !isRecent; + const isOnline = hasStats && !stale && (onlineFlag == null ? true : Boolean(onlineFlag)); + + return { + serverId, + serverName, + players, + maxPlayers, + queued, + sleepers, + joining, + fps, + recordedAt, + isOnline, + stale, + hasStats + }; +} + +function formatPresence(status) { + if (!status?.hasStats) { + return { status: 'idle', activity: 'Waiting for data' }; + } + + const countPart = Number.isFinite(status.maxPlayers) + ? `(${status.players}/${status.maxPlayers})` + : `(${status.players})`; + + if (status.stale) { + return { status: 'idle', activity: `${countPart} playing (stale)` }; + } + + if (!status.isOnline) { + return { status: 'dnd', activity: 'Server offline' }; + } + + const joiningPart = Number.isFinite(status.joining) && status.joining > 0 + ? `joining (${status.joining})` + : 'playing'; + return { + status: 'online', + activity: `${countPart} ${joiningPart}` + }; +} + +function buildStatusEmbed(status) { + const embed = new EmbedBuilder() + .setTitle(status.serverName) + .setTimestamp(status.recordedAt ?? new Date()); + + if (!status.hasStats) { + embed + .setColor(STATUS_COLORS.stale) + .setDescription('No recent status data has been recorded yet.'); + return embed; + } + + if (status.stale) { + embed + .setColor(STATUS_COLORS.stale) + .setDescription('⚠️ The latest data is stale; the server may be restarting.'); + } else if (!status.isOnline) { + embed + .setColor(STATUS_COLORS.offline) + .setDescription('❌ The server appears to be offline or unreachable.'); + } else { + embed + .setColor(STATUS_COLORS.online) + .setDescription('✅ The server is online and reporting live data.'); + } + + const maxPart = Number.isFinite(status.maxPlayers) ? `${status.maxPlayers}` : 'unknown'; + embed.addFields({ + name: 'Players', + value: `**${status.players}** / ${maxPart}`, + inline: true + }); + + if (Number.isFinite(status.joining)) { + embed.addFields({ name: 'Joining', value: formatCount(status.joining, '0'), inline: true }); + } + + if (Number.isFinite(status.queued)) { + embed.addFields({ name: 'Queued', value: formatCount(status.queued, '0'), inline: true }); + } + + if (Number.isFinite(status.sleepers)) { + embed.addFields({ name: 'Sleepers', value: formatCount(status.sleepers, '0'), inline: true }); + } + + if (Number.isFinite(status.fps)) { + embed.addFields({ name: 'Server FPS', value: status.fps.toFixed(1), inline: true }); + } + + if (status.recordedAt) { + embed.addFields({ name: 'Last Update', value: formatDiscordTimestamp(status.recordedAt, 'R'), inline: true }); + } + + return embed; +} + +function embedKeyFromBuilder(embed) { + try { + return JSON.stringify(embed.toJSON()); + } catch (err) { + console.error('failed to serialise status embed', err); + return `${Date.now()}-${Math.random()}`; + } +} + +async function ensureStatusMessage(state, embed) { + const channel = await ensureChannel(state); + if (!channel) return; + + const embedKey = embedKeyFromBuilder(embed); + if (state.lastStatusEmbedKey === embedKey && state.statusMessageId) { + return; + } + + let message = null; + if (state.statusMessageId) { + try { + message = await channel.messages.fetch(state.statusMessageId); + } catch (err) { + if (err?.code !== 10008) { + console.error(`failed to fetch status message ${state.statusMessageId} for server ${state.serverId}`, err); + } + state.statusMessageId = null; + await persistIntegration(state); + } + } + + try { + if (message) { + await message.edit({ embeds: [embed] }); + } else { + const sent = await channel.send({ embeds: [embed] }); + state.statusMessageId = sent.id; + await persistIntegration(state); + } + state.lastStatusEmbedKey = embedKey; + } catch (err) { + console.error(`failed to update status embed for server ${state.serverId}`, err); + } +} + +async function updateBot(state, integration) { + state.integration = integration; + let status; + try { + status = await loadServerStatus(state.serverId); + } catch (err) { + console.error(`failed to load status for server ${state.serverId}`, err); + return; + } - const presence = formatPresence(isOnline, players, maxPlayers); + const presence = formatPresence(status); const presenceKey = `${presence.status}|${presence.activity}`; if (state.client?.user && state.lastPresenceKey !== presenceKey) { try { await state.client.user.setPresence({ status: presence.status, - activities: [{ name: presence.activity, type: ActivityType.Watching }] + activities: [{ name: presence.activity, type: ActivityType.Playing }] }); state.lastPresenceKey = presenceKey; } catch (err) { - console.error(`failed to update presence for server ${serverId}`, err); + console.error(`failed to update presence for server ${state.serverId}`, err); } } - const statusKey = isOnline ? 'online' : 'offline'; - if (state.lastAnnouncement !== statusKey) { - const channel = await ensureChannel(state); - if (channel) { - const message = formatAnnouncement(isOnline, serverName, players, maxPlayers, queued, sleepers); - try { - await channel.send({ content: message }); - state.lastAnnouncement = statusKey; - } catch (err) { - console.error(`failed to send announcement for server ${serverId}`, err); - } + const embed = buildStatusEmbed(status); + await ensureStatusMessage(state, embed); +} + +function requireManageGuild(interaction) { + return interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild); +} + +function buildLookupListEntry(row) { + const displayName = row.forced_display_name ?? row.forcedDisplayName ?? row.display_name ?? row.displayName ?? row.persona ?? row.steamid; + const safeName = escapeMarkdown(displayName || 'Unknown player'); + const profileUrl = row.profileurl ?? row.profileUrl; + const namePart = profileUrl ? `[${safeName}](${profileUrl})` : `**${safeName}**`; + const lastSeen = parseDate(row.last_seen ?? row.lastSeen); + const lastSeenText = lastSeen ? formatDiscordTimestamp(lastSeen, 'R') : 'unknown'; + const extras = []; + if (row.country) extras.push(`Country: ${String(row.country).toUpperCase()}`); + const vacBanned = row.vac_banned ?? row.vacBanned; + if (vacBanned != null) extras.push(`VAC: ${Number(vacBanned) ? 'banned' : 'clean'}`); + const gameBans = Number(row.game_bans ?? row.gameBans); + if (Number.isFinite(gameBans) && gameBans > 0) extras.push(`${gameBans} game bans`); + return `${namePart} • \`${row.steamid}\`\nLast seen ${lastSeenText}${extras.length ? ` • ${extras.join(' • ')}` : ''}`; +} + +function buildDetailedPlayerEmbed(row) { + const displayName = row.forced_display_name ?? row.forcedDisplayName ?? row.display_name ?? row.displayName ?? row.persona ?? row.steamid; + const safeName = escapeMarkdown(displayName || 'Unknown player'); + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(safeName); + + const profileUrl = row.profileurl ?? row.profileUrl; + if (profileUrl) embed.setURL(profileUrl); + if (row.avatar) embed.setThumbnail(row.avatar); + + if (row.persona && row.persona !== displayName) { + embed.setDescription(`Persona: ${escapeMarkdown(row.persona)}`); + } + + embed.addFields({ name: 'SteamID', value: `\`${row.steamid}\``, inline: true }); + + const lastSeen = parseDate(row.last_seen ?? row.lastSeen); + if (lastSeen) embed.addFields({ name: 'Last Seen', value: formatDiscordTimestamp(lastSeen, 'R'), inline: true }); + + const firstSeen = parseDate(row.first_seen ?? row.firstSeen); + if (firstSeen) embed.addFields({ name: 'First Seen', value: formatDiscordTimestamp(firstSeen, 'R'), inline: true }); + + if (row.country) embed.addFields({ name: 'Country', value: String(row.country).toUpperCase(), inline: true }); + + const vacBanned = row.vac_banned ?? row.vacBanned; + if (vacBanned != null) { + embed.addFields({ name: 'VAC Banned', value: Number(vacBanned) ? 'Yes' : 'No', inline: true }); + } + + const gameBans = Number(row.game_bans ?? row.gameBans); + if (Number.isFinite(gameBans)) { + embed.addFields({ name: 'Game Bans', value: String(gameBans), inline: true }); + } + + const playtimeMinutes = Number(row.rust_playtime_minutes ?? row.rustPlaytimeMinutes); + if (Number.isFinite(playtimeMinutes) && playtimeMinutes > 0) { + const hours = Math.round(playtimeMinutes / 60); + embed.addFields({ name: 'Rust Playtime', value: `${hours}h (${playtimeMinutes}m)`, inline: true }); + } + + const lastIp = row.last_ip ?? row.lastIp; + if (lastIp) embed.addFields({ name: 'Last IP', value: `\`${lastIp}\``, inline: true }); + + const lastPort = row.last_port ?? row.lastPort; + if (lastPort) embed.addFields({ name: 'Last Port', value: formatCount(lastPort), inline: true }); + + if (row.note) { + embed.setFooter({ text: row.note }); + } + + return embed; +} + +async function handleRustStatusCommand(state, interaction) { + const sub = interaction.options.getSubcommand(); + + if (sub === 'status') { + const status = await loadServerStatus(state.serverId); + const embed = buildStatusEmbed(status); + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } + + if (!requireManageGuild(interaction)) { + await interaction.reply({ + content: 'You need the **Manage Server** permission to use this subcommand.', + ephemeral: true + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + + if (sub === 'setchannel') { + const requested = interaction.options.getChannel('channel', false); + const channel = requested ?? interaction.channel; + if (!channel?.isTextBased?.()) { + await interaction.editReply('The selected channel is not text-based.'); + return; + } + if (channel.guildId && channel.guildId !== state.guildId) { + await interaction.editReply('That channel belongs to a different guild.'); + return; + } + + state.channelId = channel.id; + state.channel = channel; + state.statusMessageId = null; + state.lastStatusEmbedKey = null; + await persistIntegration(state); + await updateBot(state, state.integration); + + await interaction.editReply(`Status updates will now be posted in ${channel}.`); + return; + } + + if (sub === 'refresh') { + state.lastStatusEmbedKey = null; + await updateBot(state, state.integration); + await interaction.editReply('Triggered a manual refresh.'); + return; + } + + await interaction.editReply('Unknown subcommand.'); +} + +async function handleRustLookupCommand(state, interaction) { + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply({ ephemeral: true }); + } + + if (!state.guildId || interaction.guildId !== state.guildId) { + await interaction.editReply('This command can only be used in the configured guild.'); + return; + } + + const sub = interaction.options.getSubcommand(); + + if (sub === 'player') { + const queryRaw = interaction.options.getString('query', true); + const query = queryRaw.trim(); + if (!query) { + await interaction.editReply('Please provide a search query.'); + return; + } + if (typeof db.searchServerPlayers !== 'function') { + await interaction.editReply('Player search is not supported by the current database driver.'); + return; + } + + const results = await db.searchServerPlayers(state.serverId, query, { limit: 10 }); + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`Player search: ${escapeMarkdown(query)}`); + + if (!results || results.length === 0) { + embed.setDescription('No matching players were found.'); + } else { + const lines = results.map((row) => buildLookupListEntry(row)).join('\n\n'); + embed.setDescription(lines.slice(0, 4000)); + } + + await interaction.editReply({ embeds: [embed] }); + return; + } + + if (sub === 'steamid') { + const idRaw = interaction.options.getString('id', true); + const id = idRaw.trim(); + if (!id) { + await interaction.editReply('Please provide a SteamID64.'); + return; + } + + let row = null; + if (typeof db.getServerPlayer === 'function') { + row = await db.getServerPlayer(state.serverId, id); + } + if (!row && typeof db.getPlayer === 'function') { + row = await db.getPlayer(id); + } + if (!row) { + await interaction.editReply('No player was found for that SteamID64.'); + return; + } + + if (!row.steamid) row.steamid = id; + const embed = buildDetailedPlayerEmbed(row); + await interaction.editReply({ embeds: [embed] }); + return; + } + + await interaction.editReply('Unknown subcommand.'); +} + +async function handleInteraction(state, interaction) { + if (!interaction.isChatInputCommand()) return; + if (interaction.guildId && state.guildId && interaction.guildId !== state.guildId) { + return; + } + + try { + if (interaction.commandName === 'ruststatus') { + await handleRustStatusCommand(state, interaction); + } else if (interaction.commandName === 'rustlookup') { + await handleRustLookupCommand(state, interaction); + } + } catch (err) { + console.error(`interaction handler error for server ${state.serverId}`, err); + if (interaction.deferred || interaction.replied) { + await interaction.editReply('An unexpected error occurred while processing the command.'); + } else { + await interaction.reply({ + content: 'An unexpected error occurred while processing the command.', + ephemeral: true + }); } } } diff --git a/backend/src/index.js b/backend/src/index.js index 3e6c9e4..702eea9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1515,6 +1515,7 @@ function projectDiscordIntegration(row) { serverId: Number.isFinite(serverId) ? serverId : null, guildId: row.guild_id || row.guildId || null, channelId: row.channel_id || row.channelId || null, + statusMessageId: row.status_message_id || row.statusMessageId || null, createdAt: row.created_at || row.createdAt || null, updatedAt: row.updated_at || row.updatedAt || null, hasToken: Boolean(row.bot_token) @@ -2061,10 +2062,15 @@ app.post('/api/servers/:id/discord', auth, async (req, res) => { if (existingToken) botToken = existingToken; else return res.status(400).json({ error: 'missing_bot_token' }); } + let statusMessageId = existing?.status_message_id ?? existing?.statusMessageId ?? null; + if (existing?.channel_id && existing.channel_id !== channelId) { + statusMessageId = null; + } await db.saveServerDiscordIntegration(id, { bot_token: botToken, guild_id: guildId, - channel_id: channelId + channel_id: channelId, + status_message_id: statusMessageId }); const integration = await db.getServerDiscordIntegration(id); res.json({