diff --git a/modules/discord-server-status/module.json b/modules/discord-server-status/module.json new file mode 100644 index 0000000..6dd3bca --- /dev/null +++ b/modules/discord-server-status/module.json @@ -0,0 +1,106 @@ +{ + "name": "discord-server-status", + "author": "Takaro", + "description": "Post lightweight game-server status to Discord. Includes a Discord !status reply, an optional scheduled Discord status post, and an in-game /serverstatus command. The module reports Takaro-visible server name and online player count/list; host CPU/RAM/process stats require panel/core support and are intentionally not promised here.", + "version": "latest", + "supportedGames": [ + "all" + ], + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "discordChannelId": { + "type": "string", + "default": "", + "maxLength": 40, + "description": "Discord channel ID used by the scheduled status cronjob. Discord command replies use the hook channel when this is empty." + }, + "discordTriggers": { + "type": "array", + "description": "Exact Discord messages that trigger a status reply. Matching is case-insensitive after trimming.", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "default": ["!status", "!serverstatus", "!players"] + }, + "includePlayerNames": { + "type": "boolean", + "default": true, + "description": "Include online player names in status responses. Disable if you only want counts." + }, + "maxPlayerNames": { + "type": "integer", + "minimum": 0, + "maximum": 25, + "default": 10, + "description": "Maximum number of online player names to include when includePlayerNames is enabled." + }, + "statusTemplate": { + "type": "string", + "default": "{serverName}: {onlineCount} {playerNoun} online. {playerListText}", + "maxLength": 400, + "description": "Status response template. Supports {serverName}, {onlineCount}, {playerNoun}, {playerList}, {playerListText}, and {generatedAt}." + }, + "emptyPlayerListText": { + "type": "string", + "default": "Nobody is online right now.", + "maxLength": 160, + "description": "Text for {playerListText} when no players are online." + }, + "playerListPrefix": { + "type": "string", + "default": "Players: ", + "maxLength": 80, + "description": "Prefix for {playerListText} when players are online." + }, + "sendCronWhenEmpty": { + "type": "boolean", + "default": true, + "description": "If false, the scheduled Discord status cronjob skips posts when nobody is online. Manual Discord triggers still reply." + }, + "updateStatusMessage": { + "type": "boolean", + "default": false, + "description": "If true, the scheduled cronjob edits one persistent Discord status message instead of posting a new message every run. If the saved message cannot be edited, the module posts a replacement and stores its ID." + } + }, + "required": [], + "additionalProperties": false + }, + "uiSchema": {}, + "permissions": [], + "commands": { + "serverstatus": { + "trigger": "serverstatus", + "description": "Show the current Takaro-visible server status and online player count.", + "helpText": "Usage: /serverstatus — shows server name, online player count, and optionally player names.", + "function": "src/commands/serverstatus/index.js", + "arguments": [] + } + }, + "hooks": { + "discordStatus": { + "eventType": "discord-message", + "description": "Reply in Discord when a configured status trigger such as !status is posted.", + "function": "src/hooks/discord-status/index.js" + } + }, + "cronJobs": { + "postDiscordStatus": { + "temporalValue": "*/15 * * * *", + "description": "Optionally post the current status to a configured Discord channel.", + "function": "src/cronjobs/post-discord-status/index.js" + } + }, + "functions": { + "discord-server-status-helpers": { + "function": "src/functions/discord-server-status-helpers.js" + }, + "discord-server-status-pure": { + "function": "src/functions/discord-server-status-pure.js" + } + } +} diff --git a/modules/discord-server-status/src/commands/serverstatus/index.js b/modules/discord-server-status/src/commands/serverstatus/index.js new file mode 100644 index 0000000..6e92ff7 --- /dev/null +++ b/modules/discord-server-status/src/commands/serverstatus/index.js @@ -0,0 +1,12 @@ +import { data } from '@takaro/helpers'; +import { buildCurrentStatus } from './discord-server-status-helpers.js'; + +async function main() { + const { gameServerId, module: mod, pog } = data; + const config = mod.userConfig ?? {}; + const result = await buildCurrentStatus(gameServerId, config); + console.log(`discord-server-status: game command requested, server=${result.serverName}, online=${result.onlineCount}`); + await pog.pm(result.message); +} + +await main(); diff --git a/modules/discord-server-status/src/cronjobs/post-discord-status/index.js b/modules/discord-server-status/src/cronjobs/post-discord-status/index.js new file mode 100644 index 0000000..d780bad --- /dev/null +++ b/modules/discord-server-status/src/cronjobs/post-discord-status/index.js @@ -0,0 +1,30 @@ +import { data, takaro } from '@takaro/helpers'; +import { buildCurrentStatus, getDiscordChannelId, sendOrUpdateDiscordStatusMessage } from './discord-server-status-helpers.js'; + +async function main() { + const { gameServerId, module: mod } = data; + const config = mod.userConfig ?? {}; + const discordChannelId = getDiscordChannelId(mod); + + if (!discordChannelId) { + console.log('discord-server-status: no discordChannelId configured, skipping scheduled status post'); + return; + } + + const result = await buildCurrentStatus(gameServerId, config); + if (result.onlineCount === 0 && config.sendCronWhenEmpty === false) { + console.log('discord-server-status: no players online and sendCronWhenEmpty=false, skipping scheduled status post'); + return; + } + + if (config.updateStatusMessage === true) { + const delivery = await sendOrUpdateDiscordStatusMessage(gameServerId, discordChannelId, result.message); + console.log(`discord-server-status: scheduled Discord status ${delivery.action}, server=${result.serverName}, online=${result.onlineCount}`); + return; + } + + await takaro.discord.discordControllerSendMessage(discordChannelId, { message: result.message }); + console.log(`discord-server-status: scheduled Discord status sent, server=${result.serverName}, online=${result.onlineCount}`); +} + +await main(); diff --git a/modules/discord-server-status/src/functions/discord-server-status-helpers.js b/modules/discord-server-status/src/functions/discord-server-status-helpers.js new file mode 100644 index 0000000..b027a70 --- /dev/null +++ b/modules/discord-server-status/src/functions/discord-server-status-helpers.js @@ -0,0 +1,130 @@ +import { takaro } from '@takaro/helpers'; +import { buildStatusMessage, trimOrEmpty } from './discord-server-status-pure.js'; + +export async function fetchOnlinePlayers(gameServerId) { + let allPlayers = []; + let page = 0; + const limit = 100; + + while (true) { + if (page > 100) { + throw new Error('discord-server-status: reached pagination safety limit while fetching online players'); + } + + const res = await takaro.playerOnGameserver.playerOnGameServerControllerSearch({ + filters: { + gameServerId: [gameServerId], + online: [true], + }, + page, + limit, + }); + + const batch = res.data.data ?? []; + allPlayers = allPlayers.concat(batch); + const total = res.data.meta?.total; + if (typeof total === 'number' && allPlayers.length >= total) break; + if (batch.length < limit) break; + page += 1; + } + + const seen = new Set(); + return allPlayers.filter((player) => { + const id = trimOrEmpty(player?.playerId || player?.id || player?.gameId); + if (id === '' || seen.has(id)) return false; + seen.add(id); + return true; + }); +} + +export async function getServerName(gameServerId) { + try { + const res = await takaro.gameserver.gameServerControllerGetOne(gameServerId); + return trimOrEmpty(res.data.data?.name) || 'Unknown Server'; + } catch (err) { + console.error(`discord-server-status: failed to fetch server name for ${gameServerId}: ${err}`); + return 'Unknown Server'; + } +} + +export function getDiscordChannelId(mod, eventData) { + return trimOrEmpty( + mod?.userConfig?.discordChannelId || + mod?.systemConfig?.hooks?.discordStatus?.discordChannelId || + mod?.systemConfig?.hooks?.DiscordStatus?.discordChannelId || + eventData?.discordChannelId || + eventData?.channelId, + ); +} + +export async function buildCurrentStatus(gameServerId, config) { + const [serverName, onlinePlayers] = await Promise.all([ + getServerName(gameServerId), + fetchOnlinePlayers(gameServerId), + ]); + + return { + message: buildStatusMessage({ serverName, onlinePlayers, config }), + onlineCount: onlinePlayers.length, + serverName, + }; +} + +function statusMessageVariableKey(channelId) { + return `discord-server-status:status-message:${channelId}`; +} + +async function getSavedStatusMessage(gameServerId, channelId) { + const key = statusMessageVariableKey(channelId); + const res = await takaro.variable.variableControllerSearch({ + filters: { key: [key], gameServerId: [gameServerId] }, + limit: 1, + }); + const existing = res.data.data?.[0]; + if (!existing) return null; + + return { + id: existing.id, + key, + messageId: trimOrEmpty(existing.value), + }; +} + +async function saveStatusMessage(gameServerId, channelId, messageId) { + const key = statusMessageVariableKey(channelId); + const existing = await getSavedStatusMessage(gameServerId, channelId); + if (existing) { + await takaro.variable.variableControllerUpdate(existing.id, { value: messageId }); + return; + } + + await takaro.variable.variableControllerCreate({ + key, + value: messageId, + gameServerId, + }); +} + +export async function sendOrUpdateDiscordStatusMessage(gameServerId, channelId, message) { + const saved = await getSavedStatusMessage(gameServerId, channelId); + + if (saved?.messageId) { + try { + const updated = await takaro.discord.discordControllerUpdateMessage(channelId, saved.messageId, { message }); + const updatedMessageId = trimOrEmpty(updated.data.data?.id) || saved.messageId; + if (updatedMessageId !== saved.messageId) { + await saveStatusMessage(gameServerId, channelId, updatedMessageId); + } + return { action: 'updated', messageId: updatedMessageId }; + } catch (err) { + console.error(`discord-server-status: failed to update saved Discord status message ${saved.messageId}, posting a replacement: ${err}`); + } + } + + const sent = await takaro.discord.discordControllerSendMessage(channelId, { message }); + const messageId = trimOrEmpty(sent.data.data?.id); + if (messageId) { + await saveStatusMessage(gameServerId, channelId, messageId); + } + return { action: 'sent', messageId }; +} diff --git a/modules/discord-server-status/src/functions/discord-server-status-pure.js b/modules/discord-server-status/src/functions/discord-server-status-pure.js new file mode 100644 index 0000000..32f4046 --- /dev/null +++ b/modules/discord-server-status/src/functions/discord-server-status-pure.js @@ -0,0 +1,73 @@ +/** + * Pure helpers for discord-server-status. No @takaro/helpers imports so tests can + * exercise the exact formatting logic without a Takaro sandbox. + */ + +export function trimOrEmpty(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); +} + +export function normalizeTriggers(value) { + const source = Array.isArray(value) && value.length > 0 ? value : ['!status', '!serverstatus', '!players']; + return [...new Set(source + .map((trigger) => trimOrEmpty(trigger).toLowerCase()) + .filter((trigger) => trigger !== ''))]; +} + +export function getPlayerDisplayName(player) { + return trimOrEmpty(player?.name || player?.playerName || player?.gameId || player?.playerId || player?.id || 'Unknown Player'); +} + +export function formatPlayerList(players, { includePlayerNames = true, maxPlayerNames = 10 } = {}) { + const onlinePlayers = Array.isArray(players) ? players : []; + if (!includePlayerNames || maxPlayerNames <= 0 || onlinePlayers.length === 0) return ''; + + const names = onlinePlayers + .map((player) => getPlayerDisplayName(player)) + .filter((name) => name !== '') + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + + const visible = names.slice(0, maxPlayerNames); + const hiddenCount = Math.max(0, names.length - visible.length); + const suffix = hiddenCount > 0 ? `, +${hiddenCount} more` : ''; + return `${visible.join(', ')}${suffix}`; +} + +export function renderTemplate(template, placeholders) { + const source = trimOrEmpty(template) || '{serverName}: {onlineCount} {playerNoun} online. {playerListText}'; + return source.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key) => { + if (Object.prototype.hasOwnProperty.call(placeholders, key)) { + return String(placeholders[key]); + } + return `{${key}}`; + }).replace(/\s+/g, ' ').trim(); +} + +export function buildStatusMessage({ + serverName = 'Unknown Server', + onlinePlayers = [], + config = {}, + generatedAt = new Date().toISOString(), +} = {}) { + const players = Array.isArray(onlinePlayers) ? onlinePlayers : []; + const onlineCount = players.length; + const includePlayerNames = config.includePlayerNames !== false; + const maxPlayerNames = Number.isInteger(config.maxPlayerNames) ? config.maxPlayerNames : 10; + const playerList = formatPlayerList(players, { includePlayerNames, maxPlayerNames }); + const emptyText = trimOrEmpty(config.emptyPlayerListText) || 'Nobody is online right now.'; + const prefix = config.playerListPrefix === undefined || config.playerListPrefix === null + ? 'Players: ' + : String(config.playerListPrefix); + const playerListText = playerList === '' ? emptyText : `${prefix}${playerList}`; + const playerNoun = onlineCount === 1 ? 'player' : 'players'; + + return renderTemplate(config.statusTemplate, { + serverName: trimOrEmpty(serverName) || 'Unknown Server', + onlineCount, + playerNoun, + playerList, + playerListText, + generatedAt, + }); +} diff --git a/modules/discord-server-status/src/hooks/discord-status/index.js b/modules/discord-server-status/src/hooks/discord-status/index.js new file mode 100644 index 0000000..a6b3311 --- /dev/null +++ b/modules/discord-server-status/src/hooks/discord-status/index.js @@ -0,0 +1,26 @@ +import { data, takaro } from '@takaro/helpers'; +import { buildCurrentStatus, getDiscordChannelId } from './discord-server-status-helpers.js'; +import { normalizeTriggers, trimOrEmpty } from './discord-server-status-pure.js'; + +async function main() { + const { gameServerId, module: mod, eventData } = data; + const config = mod.userConfig ?? {}; + + if (eventData?.author?.isBot) return; + + const message = trimOrEmpty(eventData?.msg || eventData?.message || eventData?.content).toLowerCase(); + const triggers = normalizeTriggers(config.discordTriggers); + if (!triggers.includes(message)) return; + + const discordChannelId = getDiscordChannelId(mod, eventData); + if (!discordChannelId) { + console.warn('discord-server-status: trigger matched but no Discord channel ID was available'); + return; + } + + const result = await buildCurrentStatus(gameServerId, config); + await takaro.discord.discordControllerSendMessage(discordChannelId, { message: result.message }); + console.log(`discord-server-status: Discord reply sent, server=${result.serverName}, online=${result.onlineCount}`); +} + +await main(); diff --git a/modules/discord-server-status/test/discord-server-status.test.ts b/modules/discord-server-status/test/discord-server-status.test.ts new file mode 100644 index 0000000..7111ec2 --- /dev/null +++ b/modules/discord-server-status/test/discord-server-status.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const purePath = path.resolve(__dirname, '../src/functions/discord-server-status-pure.js'); +const { + buildStatusMessage, + formatPlayerList, + normalizeTriggers, +} = await import(purePath); + +describe('discord-server-status: pure helper tests', () => { + it('renders default status with sorted online player names', () => { + const message = buildStatusMessage({ + serverName: 'Survival', + onlinePlayers: [{ name: 'Zed' }, { name: 'alice' }], + config: {}, + generatedAt: '2026-07-17T00:00:00Z', + }); + + assert.equal(message, 'Survival: 2 players online. Players: alice, Zed'); + }); + + it('renders an empty-server message', () => { + const message = buildStatusMessage({ + serverName: 'Build', + onlinePlayers: [], + config: {}, + generatedAt: '2026-07-17T00:00:00Z', + }); + + assert.equal(message, 'Build: 0 players online. Nobody is online right now.'); + }); + + it('can hide player names for count-only Discord status', () => { + const message = buildStatusMessage({ + serverName: 'PvP', + onlinePlayers: [{ name: 'A' }, { name: 'B' }], + config: { + includePlayerNames: false, + statusTemplate: '{serverName}: {onlineCount} {playerNoun} online.', + }, + }); + + assert.equal(message, 'PvP: 2 players online.'); + }); + + it('limits visible player names and reports the hidden count', () => { + const list = formatPlayerList( + [{ name: 'Delta' }, { name: 'Bravo' }, { name: 'Charlie' }, { name: 'Alpha' }], + { includePlayerNames: true, maxPlayerNames: 2 }, + ); + + assert.equal(list, 'Alpha, Bravo, +2 more'); + }); + + it('normalizes Discord triggers', () => { + assert.deepEqual(normalizeTriggers([' !Status ', '!status', '', null, '!Players']), ['!status', '!players']); + }); +});