diff --git a/modules/reboot-warnings/module.json b/modules/reboot-warnings/module.json new file mode 100644 index 0000000..85a668d --- /dev/null +++ b/modules/reboot-warnings/module.json @@ -0,0 +1,110 @@ +{ + "name": "test-reboot-warnings", + "description": "Broadcasts fixed countdown warning messages before scheduled server reboots. Each warning has its own cronjob so admins can schedule exact 60/30/15/5/1 minute notices without needing game console commands.", + "version": "latest", + "supportedGames": ["all"], + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "warning60": { + "$ref": "#/definitions/warning", + "default": { + "enabled": true, + "message": "Server reboot in 60 minutes. Please plan accordingly." + } + }, + "warning30": { + "$ref": "#/definitions/warning", + "default": { + "enabled": true, + "message": "Server reboot in 30 minutes." + } + }, + "warning15": { + "$ref": "#/definitions/warning", + "default": { + "enabled": true, + "message": "Server reboot in 15 minutes. Please get somewhere safe." + } + }, + "warning5": { + "$ref": "#/definitions/warning", + "default": { + "enabled": true, + "message": "Server reboot in 5 minutes. Log out soon to avoid losing progress." + } + }, + "warning1": { + "$ref": "#/definitions/warning", + "default": { + "enabled": true, + "message": "Server reboot in 1 minute. Log out now." + } + }, + "skipWhenNoPlayersOnline": { + "type": "boolean", + "default": true, + "description": "When enabled, warning cronjobs skip without sending if no players are online." + } + }, + "definitions": { + "warning": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether this warning cronjob should send a message when triggered." + }, + "message": { + "type": "string", + "minLength": 0, + "maxLength": 500, + "description": "Message to broadcast. Supports {minutesRemaining}, {serverName}, and {playerCount}. Leave blank to skip this warning." + } + }, + "required": ["enabled", "message"], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "uiSchema": {}, + "permissions": [], + "commands": {}, + "hooks": {}, + "cronJobs": { + "warning60": { + "temporalValue": "0 3 * * *", + "description": "Broadcasts the 60-minute reboot warning. Edit this cron schedule to match your server reboot time.", + "function": "src/cronjobs/warning60/index.js" + }, + "warning30": { + "temporalValue": "30 3 * * *", + "description": "Broadcasts the 30-minute reboot warning. Edit this cron schedule to match your server reboot time.", + "function": "src/cronjobs/warning30/index.js" + }, + "warning15": { + "temporalValue": "45 3 * * *", + "description": "Broadcasts the 15-minute reboot warning. Edit this cron schedule to match your server reboot time.", + "function": "src/cronjobs/warning15/index.js" + }, + "warning5": { + "temporalValue": "55 3 * * *", + "description": "Broadcasts the 5-minute reboot warning. Edit this cron schedule to match your server reboot time.", + "function": "src/cronjobs/warning5/index.js" + }, + "warning1": { + "temporalValue": "59 3 * * *", + "description": "Broadcasts the 1-minute reboot warning. Edit this cron schedule to match your server reboot time.", + "function": "src/cronjobs/warning1/index.js" + } + }, + "functions": { + "reboot-warnings-helpers": { + "function": "src/functions/reboot-warnings-helpers.js" + } + } +} diff --git a/modules/reboot-warnings/src/cronjobs/warning1/index.js b/modules/reboot-warnings/src/cronjobs/warning1/index.js new file mode 100644 index 0000000..d030cfd --- /dev/null +++ b/modules/reboot-warnings/src/cronjobs/warning1/index.js @@ -0,0 +1,3 @@ +import { sendRebootWarning } from './reboot-warnings-helpers.js'; + +await sendRebootWarning('warning1'); diff --git a/modules/reboot-warnings/src/cronjobs/warning15/index.js b/modules/reboot-warnings/src/cronjobs/warning15/index.js new file mode 100644 index 0000000..dbf9b87 --- /dev/null +++ b/modules/reboot-warnings/src/cronjobs/warning15/index.js @@ -0,0 +1,3 @@ +import { sendRebootWarning } from './reboot-warnings-helpers.js'; + +await sendRebootWarning('warning15'); diff --git a/modules/reboot-warnings/src/cronjobs/warning30/index.js b/modules/reboot-warnings/src/cronjobs/warning30/index.js new file mode 100644 index 0000000..9a73003 --- /dev/null +++ b/modules/reboot-warnings/src/cronjobs/warning30/index.js @@ -0,0 +1,3 @@ +import { sendRebootWarning } from './reboot-warnings-helpers.js'; + +await sendRebootWarning('warning30'); diff --git a/modules/reboot-warnings/src/cronjobs/warning5/index.js b/modules/reboot-warnings/src/cronjobs/warning5/index.js new file mode 100644 index 0000000..0040509 --- /dev/null +++ b/modules/reboot-warnings/src/cronjobs/warning5/index.js @@ -0,0 +1,3 @@ +import { sendRebootWarning } from './reboot-warnings-helpers.js'; + +await sendRebootWarning('warning5'); diff --git a/modules/reboot-warnings/src/cronjobs/warning60/index.js b/modules/reboot-warnings/src/cronjobs/warning60/index.js new file mode 100644 index 0000000..b4d9526 --- /dev/null +++ b/modules/reboot-warnings/src/cronjobs/warning60/index.js @@ -0,0 +1,3 @@ +import { sendRebootWarning } from './reboot-warnings-helpers.js'; + +await sendRebootWarning('warning60'); diff --git a/modules/reboot-warnings/src/functions/reboot-warnings-helpers.js b/modules/reboot-warnings/src/functions/reboot-warnings-helpers.js new file mode 100644 index 0000000..de1980d --- /dev/null +++ b/modules/reboot-warnings/src/functions/reboot-warnings-helpers.js @@ -0,0 +1,118 @@ +import { data, takaro } from '@takaro/helpers'; + +const DEFAULT_WARNINGS = { + warning60: { + minutesRemaining: 60, + message: 'Server reboot in 60 minutes. Please plan accordingly.', + }, + warning30: { + minutesRemaining: 30, + message: 'Server reboot in 30 minutes.', + }, + warning15: { + minutesRemaining: 15, + message: 'Server reboot in 15 minutes. Please get somewhere safe.', + }, + warning5: { + minutesRemaining: 5, + message: 'Server reboot in 5 minutes. Log out soon to avoid losing progress.', + }, + warning1: { + minutesRemaining: 1, + message: 'Server reboot in 1 minute. Log out now.', + }, +}; + +const KNOWN_PLACEHOLDERS = new Set(['minutesRemaining', 'serverName', 'playerCount']); + +function getWarningConfig(config, warningKey) { + const defaults = DEFAULT_WARNINGS[warningKey]; + const userWarning = config?.[warningKey] ?? {}; + return { + enabled: userWarning.enabled ?? true, + message: userWarning.message ?? defaults.message, + minutesRemaining: defaults.minutesRemaining, + }; +} + +async function getOnlinePlayerCount(gameServerId) { + const onlineRes = await takaro.playerOnGameserver.playerOnGameServerControllerSearch({ + filters: { + gameServerId: [gameServerId], + online: [true], + }, + limit: 1, + }); + return onlineRes.data.meta.total; +} + +async function getServerName(gameServerId) { + try { + const serverRes = await takaro.gameserver.gameServerControllerGetOne(gameServerId); + return serverRes.data.data?.name ?? ''; + } catch (err) { + console.error(`reboot-warnings: failed to fetch server name, leaving {serverName} unchanged. Error: ${err}`); + return ''; + } +} + +function warnUnknownPlaceholders(text) { + const remaining = text.match(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g); + if (!remaining || remaining.length === 0) return; + + const unknown = [...new Set(remaining.map((token) => token.slice(1, -1)).filter((name) => !KNOWN_PLACEHOLDERS.has(name)))]; + if (unknown.length > 0) { + console.warn(`reboot-warnings: unrecognised placeholders in message text: ${unknown.map((name) => `{${name}}`).join(', ')}`); + } +} + +async function renderMessage(rawMessage, { minutesRemaining, playerCount, gameServerId }) { + let message = String(rawMessage ?? ''); + message = message.replace(/\{minutesRemaining\}/g, String(minutesRemaining)); + message = message.replace(/\{playerCount\}/g, String(playerCount)); + + if (message.includes('{serverName}')) { + const serverName = await getServerName(gameServerId); + if (serverName) { + message = message.replace(/\{serverName\}/g, serverName); + } + } + + warnUnknownPlaceholders(message); + return message; +} + +export async function sendRebootWarning(warningKey) { + const { gameServerId, module: mod } = data; + const config = mod.userConfig ?? {}; + const warning = getWarningConfig(config, warningKey); + + if (!warning.enabled) { + console.log(`reboot-warnings: ${warningKey} disabled, skipping`); + return; + } + + if (!warning.message || !warning.message.trim()) { + console.log(`reboot-warnings: ${warningKey} message is blank, skipping`); + return; + } + + const playerCount = await getOnlinePlayerCount(gameServerId); + if ((config.skipWhenNoPlayersOnline ?? true) && playerCount === 0) { + console.log(`reboot-warnings: no players online, skipping ${warningKey}`); + return; + } + + const message = await renderMessage(warning.message, { + minutesRemaining: warning.minutesRemaining, + playerCount, + gameServerId, + }); + + console.log(`reboot-warnings: sending ${warningKey} (${warning.minutesRemaining} minutes remaining): ${message}`); + await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { + message, + opts: {}, + }); + console.log(`reboot-warnings: ${warningKey} broadcast complete`); +} diff --git a/modules/reboot-warnings/test/reboot-warnings.test.ts b/modules/reboot-warnings/test/reboot-warnings.test.ts new file mode 100644 index 0000000..bc0c92a --- /dev/null +++ b/modules/reboot-warnings/test/reboot-warnings.test.ts @@ -0,0 +1,159 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { Client, EventSearchInputAllowedFiltersEventNameEnum } from '@takaro/apiclient'; +import { createClient } from '../../../test/helpers/client.js'; +import { startMockServer, stopMockServer, MockServerContext } from '../../../test/helpers/mock-server.js'; +import { waitForEvent } from '../../../test/helpers/events.js'; +import { + pushModule, + installModule, + uninstallModule, + deleteModule, + cleanupTestModules, + cleanupTestGameServers, +} from '../../../test/helpers/modules.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MODULE_DIR = path.resolve(__dirname, '..'); + +describe('reboot-warnings: fixed warning cronjobs', () => { + let client: Client; + let ctx: MockServerContext | undefined; + let moduleId: string; + let versionId: string; + let cronjobIdsByName: Record; + + before(async () => { + client = await createClient(); + await cleanupTestModules(client); + await cleanupTestGameServers(client); + + ctx = await startMockServer(client); + + const mod = await pushModule(client, MODULE_DIR); + moduleId = mod.id; + versionId = mod.latestVersion.id; + cronjobIdsByName = Object.fromEntries(mod.latestVersion.cronJobs.map((cronjob) => [cronjob.name, cronjob.id])); + + for (const expectedName of ['warning60', 'warning30', 'warning15', 'warning5', 'warning1']) { + assert.ok(cronjobIdsByName[expectedName], `Expected cronjob ${expectedName} to be imported`); + } + }); + + after(async () => { + if (!ctx) return; + try { + await uninstallModule(client, moduleId, ctx.gameServer.id); + } catch (_err) { + // Ignore — individual tests may already have uninstalled it. + } + try { + await deleteModule(client, moduleId); + } catch (err) { + console.error('Cleanup: failed to delete module:', err); + } + await stopMockServer(ctx.server, client, ctx.gameServer.id); + }); + + async function installWithConfig(userConfig: Record): Promise { + await installModule(client, versionId, ctx!.gameServer.id, { userConfig }); + } + + async function triggerCronjob(cronjobName: string): Promise<{ success: boolean; logs: string[] }> { + const triggerBefore = new Date(); + await client.cronjob.cronJobControllerTrigger({ + gameServerId: ctx!.gameServer.id, + cronjobId: cronjobIdsByName[cronjobName], + moduleId, + }); + + const event = await waitForEvent(client, { + eventName: EventSearchInputAllowedFiltersEventNameEnum.CronjobExecuted, + gameserverId: ctx!.gameServer.id, + after: triggerBefore, + timeout: 30000, + }); + + const meta = event.meta as { result?: { success?: boolean; logs?: Array<{ msg: string }> } }; + return { + success: meta?.result?.success ?? false, + logs: (meta?.result?.logs ?? []).map((l) => l.msg), + }; + } + + it('sends the configured warning for the triggered cronjob only', async () => { + await installWithConfig({ + warning60: { + enabled: true, + message: 'Restart in {minutesRemaining} minutes; {playerCount} player(s) online.', + }, + warning30: { + enabled: true, + message: 'This should not be sent by warning60.', + }, + skipWhenNoPlayersOnline: true, + }); + + try { + const { success, logs } = await triggerCronjob('warning60'); + assert.equal(success, true, `Expected warning60 cronjob to succeed, logs: ${JSON.stringify(logs)}`); + assert.ok( + logs.some((log) => log.includes('sending warning60') && log.includes('Restart in 60 minutes')), + `Expected warning60 rendered message in logs, got: ${JSON.stringify(logs)}`, + ); + assert.ok( + logs.every((log) => !log.includes('This should not be sent')), + `warning60 should not send warning30 message, got: ${JSON.stringify(logs)}`, + ); + } finally { + await uninstallModule(client, moduleId, ctx!.gameServer.id); + } + }); + + it('disabled warning succeeds quietly without broadcasting', async () => { + await installWithConfig({ + warning30: { + enabled: false, + message: 'Restart in 30 minutes.', + }, + }); + + try { + const { success, logs } = await triggerCronjob('warning30'); + assert.equal(success, true, `Expected disabled warning30 cronjob to succeed, logs: ${JSON.stringify(logs)}`); + assert.ok( + logs.some((log) => log.includes('warning30 disabled, skipping')), + `Expected disabled skip log, got: ${JSON.stringify(logs)}`, + ); + assert.ok( + logs.every((log) => !log.includes('broadcast complete')), + `Disabled warning should not broadcast, got: ${JSON.stringify(logs)}`, + ); + } finally { + await uninstallModule(client, moduleId, ctx!.gameServer.id); + } + }); + + it('blank message succeeds quietly without broadcasting', async () => { + await installWithConfig({ + warning5: { + enabled: true, + message: ' ', + }, + }); + + try { + const { success, logs } = await triggerCronjob('warning5'); + assert.equal(success, true, `Expected blank warning5 cronjob to succeed, logs: ${JSON.stringify(logs)}`); + assert.ok( + logs.some((log) => log.includes('warning5 message is blank, skipping')), + `Expected blank-message skip log, got: ${JSON.stringify(logs)}`, + ); + } finally { + await uninstallModule(client, moduleId, ctx!.gameServer.id); + } + }); +});