Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions modules/discord-server-status/module.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
12 changes: 12 additions & 0 deletions modules/discord-server-status/src/commands/serverstatus/index.js
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
@@ -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,
});
}
26 changes: 26 additions & 0 deletions modules/discord-server-status/src/hooks/discord-status/index.js
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading