From d47cd20c611ae3a4abbe29cfb24f1d8720ec90bb Mon Sep 17 00:00:00 2001 From: Harambe Date: Sun, 19 Jul 2026 06:10:05 +0000 Subject: [PATCH 1/3] feat: add 7d2d discord status bridge module --- .../discord-7d2d-status-bridge/module.json | 56 ++++++ .../src/commands/serverstats/index.js | 12 ++ .../src/cronjobs/blood-moon-monitor/index.js | 29 ++++ .../src/cronjobs/update-status/index.js | 14 ++ .../functions/discord-7d2d-status-helpers.js | 163 ++++++++++++++++++ .../src/hooks/discord-chat-relay/index.js | 18 ++ .../src/hooks/game-chat-relay/index.js | 16 ++ .../src/hooks/monitor-death/index.js | 14 ++ .../src/hooks/monitor-join/index.js | 12 ++ .../src/hooks/monitor-leave/index.js | 12 ++ .../test/helpers.test.ts | 61 +++++++ 11 files changed, 407 insertions(+) create mode 100644 modules/discord-7d2d-status-bridge/module.json create mode 100644 modules/discord-7d2d-status-bridge/src/commands/serverstats/index.js create mode 100644 modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js create mode 100644 modules/discord-7d2d-status-bridge/src/cronjobs/update-status/index.js create mode 100644 modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js create mode 100644 modules/discord-7d2d-status-bridge/src/hooks/discord-chat-relay/index.js create mode 100644 modules/discord-7d2d-status-bridge/src/hooks/game-chat-relay/index.js create mode 100644 modules/discord-7d2d-status-bridge/src/hooks/monitor-death/index.js create mode 100644 modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js create mode 100644 modules/discord-7d2d-status-bridge/src/hooks/monitor-leave/index.js create mode 100644 modules/discord-7d2d-status-bridge/test/helpers.test.ts diff --git a/modules/discord-7d2d-status-bridge/module.json b/modules/discord-7d2d-status-bridge/module.json new file mode 100644 index 0000000..b943c61 --- /dev/null +++ b/modules/discord-7d2d-status-bridge/module.json @@ -0,0 +1,56 @@ +{ + "name": "discord-7d2d-status-bridge", + "description": "7 Days to Die Discord bridge with chat relay, monitoring events, blood moon notices, and an editable Polish server status message.", + "version": "latest", + "supportedGames": ["7 days to die"], + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "chatChannelId": { "type": "string", "default": "", "description": "Discord channel ID for player chat relay." }, + "monitoringChannelId": { "type": "string", "default": "", "description": "Discord channel ID for system/death/join/leave/blood moon/status messages." }, + "relayDiscordToGame": { "type": "boolean", "default": true, "description": "Relay messages from the Discord chat channel into game chat." }, + "relayGameToDiscord": { "type": "boolean", "default": true, "description": "Relay in-game chat messages to the Discord chat channel." }, + "monitorJoins": { "type": "boolean", "default": true }, + "monitorLeaves": { "type": "boolean", "default": true }, + "monitorDeaths": { "type": "boolean", "default": true }, + "updateStatusMessage": { "type": "boolean", "default": true, "description": "Update one persistent bot message with current server status instead of posting a new status every tick." }, + "statusTemplate": { "type": "string", "maxLength": 500, "default": "{onlinePlayers} os. ⭔ Dzień {day} ⭔ {time}{bloodMoonIcon}", "description": "Server status message. Placeholders: {onlinePlayers}, {day}, {time}, {bloodMoonIcon}, {serverName}." }, + "bloodMoonIcon": { "type": "string", "maxLength": 50, "default": " ⭔ 🩸" }, + "timeConsoleCommand": { "type": "string", "enum": ["gettime"], "default": "gettime" }, + "hordeIntervalDays": { "type": "integer", "minimum": 1, "default": 7 }, + "firstHordeDay": { "type": "integer", "minimum": 1, "default": 7 }, + "bloodMoonStartHour": { "type": "integer", "minimum": 0, "maximum": 23, "default": 22 }, + "bloodMoonEndHour": { "type": "integer", "minimum": 0, "maximum": 23, "default": 4 }, + "bloodMoonTodayMessage": { "type": "string", "maxLength": 500, "default": "Dzisiaj zapowiadają Krwawy Księżyc..." }, + "bloodMoonStartingMessage": { "type": "string", "maxLength": 500, "default": "Krwawy Księżyc wschodzi..." }, + "bloodMoonEndingMessage": { "type": "string", "maxLength": 500, "default": "Krwawy Księżyc zachodzi..." } + }, + "required": [], + "additionalProperties": false + }, + "uiSchema": {}, + "permissions": [], + "commands": { + "serverstats": { + "trigger": "serverstats", + "description": "Show current 7D2D server stats.", + "helpText": "Shows online player count, current day, time, and blood moon indicator.", + "function": "src/commands/serverstats/index.js" + } + }, + "hooks": { + "discordChatRelay": { "eventType": "discord-message", "description": "Relays Discord chat channel messages into game chat.", "function": "src/hooks/discord-chat-relay/index.js" }, + "gameChatRelay": { "eventType": "chat-message", "description": "Relays in-game chat messages to Discord chat channel.", "function": "src/hooks/game-chat-relay/index.js" }, + "monitorJoin": { "eventType": "player-connected", "description": "Sends player join messages to the monitoring channel.", "function": "src/hooks/monitor-join/index.js" }, + "monitorLeave": { "eventType": "player-disconnected", "description": "Sends player leave messages to the monitoring channel.", "function": "src/hooks/monitor-leave/index.js" }, + "monitorDeath": { "eventType": "player-death", "description": "Sends player death messages to the monitoring channel.", "function": "src/hooks/monitor-death/index.js" } + }, + "cronJobs": { + "updateStatus": { "temporalValue": "*/5 * * * *", "description": "Updates the persistent Discord server status message.", "function": "src/cronjobs/update-status/index.js" }, + "bloodMoonMonitor": { "temporalValue": "*/5 * * * *", "description": "Checks 7D2D time and sends blood moon day/start/end notices.", "function": "src/cronjobs/blood-moon-monitor/index.js" } + }, + "functions": { + "discord-7d2d-status-helpers": { "function": "src/functions/discord-7d2d-status-helpers.js" } + } +} diff --git a/modules/discord-7d2d-status-bridge/src/commands/serverstats/index.js b/modules/discord-7d2d-status-bridge/src/commands/serverstats/index.js new file mode 100644 index 0000000..45a3326 --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/commands/serverstats/index.js @@ -0,0 +1,12 @@ +import { data } from '@takaro/helpers'; +import { getStatus, renderTemplate } from './discord-7d2d-status-helpers.js'; + +async function main() { + const { gameServerId, module: mod, player } = data; + const config = mod.userConfig; + const status = await getStatus(gameServerId, config); + const message = renderTemplate(config.statusTemplate, status, config); + await player.pm(message); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js b/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js new file mode 100644 index 0000000..9c6017c --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js @@ -0,0 +1,29 @@ +import { data } from '@takaro/helpers'; +import { BLOOD_STATE_KEY, getDiscordChannelFromHook, getStatus, isBloodMoonDay, readVariable, sendDiscord, writeVariable } from './discord-7d2d-status-helpers.js'; + +function phaseFor(status, config) { + const parsed = status.parsed; + if (!parsed) return { key: 'unknown', message: null }; + const first = config.firstHordeDay ?? 7; + const interval = config.hordeIntervalDays ?? 7; + const start = config.bloodMoonStartHour ?? 22; + const end = config.bloodMoonEndHour ?? 4; + if (isBloodMoonDay(parsed.day, first, interval) && parsed.hour !== null && parsed.hour >= start) return { key: `start:${parsed.day}`, message: config.bloodMoonStartingMessage }; + if (parsed.hour !== null && parsed.hour < end && parsed.day > 1 && isBloodMoonDay(parsed.day - 1, first, interval)) return { key: `start:${parsed.day - 1}`, message: null }; + if (parsed.hour !== null && parsed.hour >= end && parsed.day > 1 && isBloodMoonDay(parsed.day - 1, first, interval)) return { key: `end:${parsed.day - 1}`, message: config.bloodMoonEndingMessage }; + if (isBloodMoonDay(parsed.day, first, interval)) return { key: `today:${parsed.day}`, message: config.bloodMoonTodayMessage }; + return { key: `normal:${parsed.day}`, message: null }; +} + +async function main() { + const { gameServerId, module: mod } = data; + const config = mod.userConfig; + const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'bloodMoonMonitor'); + const status = await getStatus(gameServerId, config); + const phase = phaseFor(status, config); + const prev = await readVariable(gameServerId, mod.moduleId, BLOOD_STATE_KEY, null); + if (phase.message && prev !== phase.key) await sendDiscord(channelId, phase.message); + await writeVariable(gameServerId, mod.moduleId, BLOOD_STATE_KEY, phase.key); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/src/cronjobs/update-status/index.js b/modules/discord-7d2d-status-bridge/src/cronjobs/update-status/index.js new file mode 100644 index 0000000..5c07ad4 --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/cronjobs/update-status/index.js @@ -0,0 +1,14 @@ +import { data } from '@takaro/helpers'; +import { getStatus, getDiscordChannelFromHook, renderTemplate, sendDiscord, updatePersistentDiscordMessage } from './discord-7d2d-status-helpers.js'; + +async function main() { + const { gameServerId, module: mod } = data; + const config = mod.userConfig; + const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'updateStatus'); + const status = await getStatus(gameServerId, config); + const message = renderTemplate(config.statusTemplate, status, config); + if (config.updateStatusMessage ?? true) await updatePersistentDiscordMessage(gameServerId, mod.moduleId, channelId, message); + else await sendDiscord(channelId, message); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js new file mode 100644 index 0000000..890f3ae --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js @@ -0,0 +1,163 @@ +import { takaro } from '@takaro/helpers'; + +export const STATUS_MESSAGE_KEY_PREFIX = 'discord7d2d:statusMessage:'; +export const BLOOD_STATE_KEY = 'discord7d2d:bloodState'; + +export function parse7d2dTime(raw) { + const text = String(raw ?? ''); + const dayMatch = text.match(/\bDay\s*:?\s*(\d+)\b/i) || text.match(/\bD\s*:?\s*(\d+)\b/i) || text.match(/(?:Game|World)\s*time.*?\b(\d+)\b/i); + if (!dayMatch) return null; + const timeMatch = text.match(/\b(\d{1,2}):(\d{2})\b/); + const day = Number(dayMatch[1]); + const hour = timeMatch ? Number(timeMatch[1]) : null; + const minute = timeMatch ? Number(timeMatch[2]) : null; + if (!Number.isFinite(day) || day < 1) return null; + if (hour !== null && (hour < 0 || hour > 23 || minute < 0 || minute > 59)) return null; + return { day, hour, minute, time: timeMatch ? `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` : '?' }; +} + +export function nextHordeDay(currentDay, firstHordeDay = 7, interval = 7) { + if (currentDay <= firstHordeDay) return firstHordeDay; + return firstHordeDay + Math.ceil((currentDay - firstHordeDay) / interval) * interval; +} + +export function isBloodMoonDay(day, firstHordeDay = 7, interval = 7) { + return nextHordeDay(day, firstHordeDay, interval) === day; +} + +export function isBloodMoonActive(parsed, firstHordeDay = 7, interval = 7, startHour = 22, endHour = 4) { + if (!parsed || parsed.hour === null) return false; + if (isBloodMoonDay(parsed.day, firstHordeDay, interval) && parsed.hour >= startHour) return true; + if (parsed.hour < endHour && parsed.day > 1 && isBloodMoonDay(parsed.day - 1, firstHordeDay, interval)) return true; + return false; +} + +export async function getOnlineCount(gameServerId) { + const res = await takaro.playerOnGameserver.playerOnGameServerControllerSearch({ + filters: { gameServerId: [gameServerId], online: [true] }, + limit: 1, + }); + return res.data.meta?.total ?? res.data.data.length; +} + +export async function getServerName(gameServerId) { + try { + const res = await takaro.gameserver.gameServerControllerGetOne(gameServerId); + return res.data.data?.name ?? 'Server'; + } catch (err) { + console.error(`discord-7d2d-status: failed to fetch server name: ${err}`); + return 'Server'; + } +} + +export async function executeTimeCommand(gameServerId, command = 'gettime') { + const res = await takaro.gameserver.gameServerControllerExecuteCommand(gameServerId, { command }); + const data = res?.data?.data ?? res?.data ?? res; + if (typeof data === 'string') return data; + if (data?.response) return data.response; + if (data?.result) return data.result; + if (data?.message) return data.message; + return JSON.stringify(data); +} + +export async function getStatus(gameServerId, config) { + const [onlinePlayers, serverName, rawTime] = await Promise.all([ + getOnlineCount(gameServerId), + getServerName(gameServerId), + executeTimeCommand(gameServerId, config.timeConsoleCommand ?? 'gettime'), + ]); + const parsed = parse7d2dTime(rawTime); + const day = parsed?.day ?? '?'; + const time = parsed?.time ?? '?'; + const active = isBloodMoonActive(parsed, config.firstHordeDay ?? 7, config.hordeIntervalDays ?? 7, config.bloodMoonStartHour ?? 22, config.bloodMoonEndHour ?? 4); + return { onlinePlayers, serverName, rawTime, parsed, day, time, bloodMoonActive: active }; +} + +export function renderTemplate(template, status, config) { + const bloodMoonIcon = status.bloodMoonActive ? (config.bloodMoonIcon ?? ' ⭔ 🩸') : ''; + return String(template ?? '{onlinePlayers} os. ⭔ Dzień {day} ⭔ {time}{bloodMoonIcon}') + .replace(/\{onlinePlayers\}/g, String(status.onlinePlayers)) + .replace(/\{serverName\}/g, String(status.serverName)) + .replace(/\{day\}/g, String(status.day)) + .replace(/\{time\}/g, String(status.time)) + .replace(/\{bloodMoonIcon\}/g, bloodMoonIcon); +} + +async function findVariable(gameServerId, moduleId, key) { + const res = await takaro.variable.variableControllerSearch({ filters: { key: [key], gameServerId: [gameServerId], moduleId: [moduleId] }, limit: 1 }); + return res.data.data[0] ?? null; +} + +export async function readVariable(gameServerId, moduleId, key, fallback = null) { + const record = await findVariable(gameServerId, moduleId, key); + if (!record) return fallback; + try { return JSON.parse(record.value); } catch (_err) { return fallback; } +} + +export async function writeVariable(gameServerId, moduleId, key, value) { + const existing = await findVariable(gameServerId, moduleId, key); + const serialized = JSON.stringify(value); + if (existing) await takaro.variable.variableControllerUpdate(existing.id, { value: serialized }); + else await takaro.variable.variableControllerCreate({ key, value: serialized, gameServerId, moduleId }); +} + +export function getDiscordChannelFromHook(data, configuredChannelId, hookName) { + if (configuredChannelId) return configuredChannelId; + return data?.module?.systemConfig?.hooks?.[hookName]?.discordChannelId || data?.discordChannelId || data?.eventData?.discordChannelId || data?.eventData?.channelId || ''; +} + +export async function sendDiscord(channelId, message) { + if (!channelId) { + console.log(`discord-7d2d-status: no Discord channel configured, skipped message: ${message}`); + return null; + } + return takaro.discord.discordControllerSendMessage(channelId, { message: sanitizeDiscordMessage(message) }); +} + +export function sanitizeDiscordMessage(message) { + const withoutMentions = String(message ?? '') + .replace(/@everyone/g, '@\u200beveryone') + .replace(/@here/g, '@\u200bhere') + .replace(/<@!?(\d+)>/g, '<@\u200b$1>') + .replace(/<@&(\d+)>/g, '<@&\u200b$1>'); + if (withoutMentions.length <= 1900) return withoutMentions; + return `${withoutMentions.slice(0, 1897)}...`; +} + +export function isGlobalChatMessage(eventData) { + if (!eventData) return false; + if (eventData.isPrivate === true || eventData.private === true) return false; + if (eventData.recipient || eventData.target || eventData.to || eventData.whisperTarget) return false; + const rawScope = eventData.channel ?? eventData.chatChannel ?? eventData.chatType ?? eventData.type ?? eventData.scope ?? eventData.chatMessage?.channel ?? eventData.chatMessage?.type; + if (rawScope === undefined || rawScope === null || rawScope === '') return false; + return ['global', 'public', 'all'].includes(String(rawScope).toLowerCase()); +} + +export async function updatePersistentDiscordMessage(gameServerId, moduleId, channelId, message) { + const safeMessage = sanitizeDiscordMessage(message); + const key = `${STATUS_MESSAGE_KEY_PREFIX}${channelId}`; + const existingId = await readVariable(gameServerId, moduleId, key, null); + if (existingId) { + try { + await takaro.discord.discordControllerUpdateMessage(channelId, existingId, { message: safeMessage }); + return existingId; + } catch (err) { + console.error(`discord-7d2d-status: failed to update Discord status message ${existingId}, sending replacement: ${err}`); + } + } + const sent = await sendDiscord(channelId, safeMessage); + const messageId = sent?.data?.data?.id; + if (messageId) await writeVariable(gameServerId, moduleId, key, messageId); + return messageId ?? null; +} + +export function pickText(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +export function pickAuthorName(eventData) { + return pickText(eventData?.author?.username, eventData?.author?.globalName, eventData?.username, eventData?.userName, eventData?.member?.displayName, 'Discord'); +} diff --git a/modules/discord-7d2d-status-bridge/src/hooks/discord-chat-relay/index.js b/modules/discord-7d2d-status-bridge/src/hooks/discord-chat-relay/index.js new file mode 100644 index 0000000..7e06337 --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/hooks/discord-chat-relay/index.js @@ -0,0 +1,18 @@ +import { data, takaro } from '@takaro/helpers'; +import { getDiscordChannelFromHook, pickAuthorName, pickText } from './discord-7d2d-status-helpers.js'; + +async function main() { + const { gameServerId, eventData, module: mod } = data; + const config = mod.userConfig; + if (config.relayDiscordToGame === false) return; + const expectedChannelId = getDiscordChannelFromHook(data, config.chatChannelId, 'discordChatRelay'); + const eventChannelId = eventData?.discordChannelId || eventData?.channelId || eventData?.channel?.id; + if (expectedChannelId && eventChannelId && expectedChannelId !== eventChannelId) return; + if (eventData?.author?.isBot || eventData?.author?.isTakaroBot || eventData?.author?.bot || eventData?.isBot || eventData?.bot) return; + const text = pickText(eventData?.msg, eventData?.message, eventData?.content, eventData?.text); + if (!text) return; + const author = pickAuthorName(eventData); + await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { message: `[Discord] ${author}: ${text}`, opts: {} }); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/src/hooks/game-chat-relay/index.js b/modules/discord-7d2d-status-bridge/src/hooks/game-chat-relay/index.js new file mode 100644 index 0000000..43b091b --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/hooks/game-chat-relay/index.js @@ -0,0 +1,16 @@ +import { data } from '@takaro/helpers'; +import { getDiscordChannelFromHook, isGlobalChatMessage, pickText, sendDiscord } from './discord-7d2d-status-helpers.js'; + +async function main() { + const { eventData, player, module: mod } = data; + const config = mod.userConfig; + if (config.relayGameToDiscord === false) return; + if (!isGlobalChatMessage(eventData)) return; + const channelId = getDiscordChannelFromHook(data, config.chatChannelId, 'gameChatRelay'); + const text = pickText(eventData?.msg, eventData?.message, eventData?.text, eventData?.chatMessage?.message); + if (!text) return; + const name = player?.name || eventData?.player?.name || 'Game'; + await sendDiscord(channelId, `**${name}:** ${text}`); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/src/hooks/monitor-death/index.js b/modules/discord-7d2d-status-bridge/src/hooks/monitor-death/index.js new file mode 100644 index 0000000..2c65a43 --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/hooks/monitor-death/index.js @@ -0,0 +1,14 @@ +import { data } from '@takaro/helpers'; +import { getDiscordChannelFromHook, pickText, sendDiscord } from './discord-7d2d-status-helpers.js'; + +async function main() { + const { eventData, player, module: mod } = data; + const config = mod.userConfig; + if (config.monitorDeaths === false) return; + const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'monitorDeath'); + const reason = pickText(eventData?.reason, eventData?.cause, eventData?.message); + const victim = player?.name || eventData?.player?.name || 'Player'; + await sendDiscord(channelId, reason ? `☠️ ${victim} zginął: ${reason}` : `☠️ ${victim} zginął.`); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js b/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js new file mode 100644 index 0000000..7314ae1 --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js @@ -0,0 +1,12 @@ +import { data } from '@takaro/helpers'; +import { getDiscordChannelFromHook, sendDiscord } from './discord-7d2d-status-helpers.js'; + +async function main() { + const { player, module: mod } = data; + const config = mod.userConfig; + if (config.monitorJoins === false) return; + const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'monitorJoin'); + await sendDiscord(channelId, `✅ ${player?.name || 'Player'} dołączył do serwera.`); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/src/hooks/monitor-leave/index.js b/modules/discord-7d2d-status-bridge/src/hooks/monitor-leave/index.js new file mode 100644 index 0000000..ac6063a --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/hooks/monitor-leave/index.js @@ -0,0 +1,12 @@ +import { data } from '@takaro/helpers'; +import { getDiscordChannelFromHook, sendDiscord } from './discord-7d2d-status-helpers.js'; + +async function main() { + const { player, module: mod } = data; + const config = mod.userConfig; + if (config.monitorLeaves === false) return; + const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'monitorLeave'); + await sendDiscord(channelId, `👋 ${player?.name || 'Player'} opuścił serwer.`); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/test/helpers.test.ts b/modules/discord-7d2d-status-bridge/test/helpers.test.ts new file mode 100644 index 0000000..ab9c670 --- /dev/null +++ b/modules/discord-7d2d-status-bridge/test/helpers.test.ts @@ -0,0 +1,61 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { readFileSync } from 'node:fs'; +import * as vm from 'node:vm'; + +interface HelperExports { + parse7d2dTime: (raw: string) => { day: number; hour: number | null; minute: number | null; time: string } | null; + nextHordeDay: (currentDay: number, firstHordeDay?: number, interval?: number) => number; + isBloodMoonDay: (day: number, firstHordeDay?: number, interval?: number) => boolean; + isBloodMoonActive: (parsed: { day: number; hour: number | null }, firstHordeDay?: number, interval?: number, startHour?: number, endHour?: number) => boolean; + renderTemplate: (template: string, status: Record, config: Record) => string; + sanitizeDiscordMessage: (message: string) => string; + isGlobalChatMessage: (eventData: Record) => boolean; +} + +const helperPath = new URL('../src/functions/discord-7d2d-status-helpers.js', import.meta.url); +let source = readFileSync(helperPath, 'utf8'); +source = source.replace("import { takaro } from '@takaro/helpers';", 'const takaro = {};'); +source = source.replace(/export /g, ''); +source += '\nmodule.exports = { parse7d2dTime, nextHordeDay, isBloodMoonDay, isBloodMoonActive, renderTemplate, sanitizeDiscordMessage, isGlobalChatMessage };'; +const sandbox: { module: { exports: HelperExports | Record }; console: Console } = { module: { exports: {} }, console }; +vm.runInNewContext(source, sandbox, { filename: helperPath.pathname }); +const { parse7d2dTime, nextHordeDay, isBloodMoonDay, isBloodMoonActive, renderTemplate, sanitizeDiscordMessage, isGlobalChatMessage } = sandbox.module.exports as HelperExports; + +describe('discord-7d2d-status-helpers', () => { + it('parses active 7D2D gettime output', () => { + assert.equal(JSON.stringify(parse7d2dTime('Day 4, 16:52')), JSON.stringify({ day: 4, hour: 16, minute: 52, time: '16:52' })); + assert.equal(JSON.stringify(parse7d2dTime('Day: 7, 22:01')), JSON.stringify({ day: 7, hour: 22, minute: 1, time: '22:01' })); + }); + + it('calculates horde day and active blood moon window', () => { + assert.equal(nextHordeDay(4, 7, 7), 7); + assert.equal(nextHordeDay(8, 7, 7), 14); + assert.equal(isBloodMoonDay(14, 7, 7), true); + const start = parse7d2dTime('Day 7, 22:00'); + const lateNight = parse7d2dTime('Day 8, 03:30'); + const over = parse7d2dTime('Day 8, 04:00'); + if (!start || !lateNight || !over) throw new Error('test setup failed to parse 7D2D time'); + assert.equal(isBloodMoonActive(start, 7, 7, 22, 4), true); + assert.equal(isBloodMoonActive(lateNight, 7, 7, 22, 4), true); + assert.equal(isBloodMoonActive(over, 7, 7, 22, 4), false); + }); + + it('renders Polish status template', () => { + const status = { onlinePlayers: 5, day: 60, time: '14:43', bloodMoonActive: true, serverName: '7D2D' }; + assert.equal(renderTemplate('{onlinePlayers} os. ⭔ Dzień {day} ⭔ {time}{bloodMoonIcon}', status, { bloodMoonIcon: ' ⭔ 🩸' }), '5 os. ⭔ Dzień 60 ⭔ 14:43 ⭔ 🩸'); + }); + + it('sanitizes Discord mention spam', () => { + assert.equal(sanitizeDiscordMessage('@everyone @here <@123> <@!234> <@&456>'), '@\u200beveryone @\u200bhere <@\u200b123> <@\u200b234> <@&\u200b456>'); + }); + + it('only relays explicit global/public game chat', () => { + assert.equal(isGlobalChatMessage({ channel: 'global' }), true); + assert.equal(isGlobalChatMessage({ chatType: 'public' }), true); + assert.equal(isGlobalChatMessage({ scope: 'all' }), true); + assert.equal(isGlobalChatMessage({ channel: 'team' }), false); + assert.equal(isGlobalChatMessage({ type: 'whisper', recipient: 'player2' }), false); + assert.equal(isGlobalChatMessage({ msg: 'missing scope defaults private' }), false); + }); +}); From fe70509ddc553dd308d07aa145e163f0680c9d69 Mon Sep 17 00:00:00 2001 From: "koji-assistant[bot]" Date: Mon, 20 Jul 2026 05:09:53 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20task-000:=20suppress=20Disco?= =?UTF-8?q?rd=20relay=20echo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/functions/discord-7d2d-status-helpers.js | 5 +++++ .../src/hooks/game-chat-relay/index.js | 3 ++- .../discord-7d2d-status-bridge/test/helpers.test.ts | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js index 890f3ae..a07fecd 100644 --- a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +++ b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js @@ -133,6 +133,11 @@ export function isGlobalChatMessage(eventData) { return ['global', 'public', 'all'].includes(String(rawScope).toLowerCase()); } +export function isDiscordRelayEcho(eventData) { + const text = pickText(eventData?.msg, eventData?.message, eventData?.text, eventData?.chatMessage?.message); + return /^\[discord\]\s+/i.test(text); +} + export async function updatePersistentDiscordMessage(gameServerId, moduleId, channelId, message) { const safeMessage = sanitizeDiscordMessage(message); const key = `${STATUS_MESSAGE_KEY_PREFIX}${channelId}`; diff --git a/modules/discord-7d2d-status-bridge/src/hooks/game-chat-relay/index.js b/modules/discord-7d2d-status-bridge/src/hooks/game-chat-relay/index.js index 43b091b..df55db8 100644 --- a/modules/discord-7d2d-status-bridge/src/hooks/game-chat-relay/index.js +++ b/modules/discord-7d2d-status-bridge/src/hooks/game-chat-relay/index.js @@ -1,11 +1,12 @@ import { data } from '@takaro/helpers'; -import { getDiscordChannelFromHook, isGlobalChatMessage, pickText, sendDiscord } from './discord-7d2d-status-helpers.js'; +import { getDiscordChannelFromHook, isDiscordRelayEcho, isGlobalChatMessage, pickText, sendDiscord } from './discord-7d2d-status-helpers.js'; async function main() { const { eventData, player, module: mod } = data; const config = mod.userConfig; if (config.relayGameToDiscord === false) return; if (!isGlobalChatMessage(eventData)) return; + if (isDiscordRelayEcho(eventData)) return; const channelId = getDiscordChannelFromHook(data, config.chatChannelId, 'gameChatRelay'); const text = pickText(eventData?.msg, eventData?.message, eventData?.text, eventData?.chatMessage?.message); if (!text) return; diff --git a/modules/discord-7d2d-status-bridge/test/helpers.test.ts b/modules/discord-7d2d-status-bridge/test/helpers.test.ts index ab9c670..4422c24 100644 --- a/modules/discord-7d2d-status-bridge/test/helpers.test.ts +++ b/modules/discord-7d2d-status-bridge/test/helpers.test.ts @@ -11,16 +11,17 @@ interface HelperExports { renderTemplate: (template: string, status: Record, config: Record) => string; sanitizeDiscordMessage: (message: string) => string; isGlobalChatMessage: (eventData: Record) => boolean; + isDiscordRelayEcho: (eventData: Record) => boolean; } const helperPath = new URL('../src/functions/discord-7d2d-status-helpers.js', import.meta.url); let source = readFileSync(helperPath, 'utf8'); source = source.replace("import { takaro } from '@takaro/helpers';", 'const takaro = {};'); source = source.replace(/export /g, ''); -source += '\nmodule.exports = { parse7d2dTime, nextHordeDay, isBloodMoonDay, isBloodMoonActive, renderTemplate, sanitizeDiscordMessage, isGlobalChatMessage };'; +source += '\nmodule.exports = { parse7d2dTime, nextHordeDay, isBloodMoonDay, isBloodMoonActive, renderTemplate, sanitizeDiscordMessage, isGlobalChatMessage, isDiscordRelayEcho };'; const sandbox: { module: { exports: HelperExports | Record }; console: Console } = { module: { exports: {} }, console }; vm.runInNewContext(source, sandbox, { filename: helperPath.pathname }); -const { parse7d2dTime, nextHordeDay, isBloodMoonDay, isBloodMoonActive, renderTemplate, sanitizeDiscordMessage, isGlobalChatMessage } = sandbox.module.exports as HelperExports; +const { parse7d2dTime, nextHordeDay, isBloodMoonDay, isBloodMoonActive, renderTemplate, sanitizeDiscordMessage, isGlobalChatMessage, isDiscordRelayEcho } = sandbox.module.exports as HelperExports; describe('discord-7d2d-status-helpers', () => { it('parses active 7D2D gettime output', () => { @@ -58,4 +59,10 @@ describe('discord-7d2d-status-helpers', () => { assert.equal(isGlobalChatMessage({ type: 'whisper', recipient: 'player2' }), false); assert.equal(isGlobalChatMessage({ msg: 'missing scope defaults private' }), false); }); + + it('detects Discord-to-game relay echoes', () => { + assert.equal(isDiscordRelayEcho({ msg: '[Discord] Alice: hello', channel: 'global' }), true); + assert.equal(isDiscordRelayEcho({ message: ' [discord] Bob: hello', chatType: 'public' }), true); + assert.equal(isDiscordRelayEcho({ text: 'normal game chat', channel: 'global' }), false); + }); }); From 7985e6443ca42a7f90bfc29e010ae7a9d4bdd4ca Mon Sep 17 00:00:00 2001 From: El-Limon Date: Tue, 21 Jul 2026 22:28:44 +0200 Subject: [PATCH 3/3] feat: complete 7d2d discord status bridge Customize bridge wording, add server status notifications and player-list status output, and cover the module through the real Takaro integration harness. --- .../discord-7d2d-status-bridge/module.json | 24 +- .../src/cronjobs/blood-moon-monitor/index.js | 16 +- .../functions/discord-7d2d-status-helpers.js | 118 ++++- .../src/hooks/discord-chat-relay/index.js | 4 +- .../src/hooks/monitor-death/index.js | 7 +- .../src/hooks/monitor-join/index.js | 5 +- .../src/hooks/monitor-leave/index.js | 5 +- .../src/hooks/monitor-server-status/index.js | 24 + .../test/bridge.test.ts | 426 ++++++++++++++++++ .../test/helpers.test.ts | 68 --- 10 files changed, 589 insertions(+), 108 deletions(-) create mode 100644 modules/discord-7d2d-status-bridge/src/hooks/monitor-server-status/index.js create mode 100644 modules/discord-7d2d-status-bridge/test/bridge.test.ts delete mode 100644 modules/discord-7d2d-status-bridge/test/helpers.test.ts diff --git a/modules/discord-7d2d-status-bridge/module.json b/modules/discord-7d2d-status-bridge/module.json index b943c61..af3ca2a 100644 --- a/modules/discord-7d2d-status-bridge/module.json +++ b/modules/discord-7d2d-status-bridge/module.json @@ -1,12 +1,13 @@ { "name": "discord-7d2d-status-bridge", - "description": "7 Days to Die Discord bridge with chat relay, monitoring events, blood moon notices, and an editable Polish server status message.", + "description": "7 Days to Die Discord bridge with chat relay, localized monitoring events, blood moon notices, and a persistent customizable server status message.", "version": "latest", "supportedGames": ["7 days to die"], "config": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { + "language": { "type": "string", "enum": ["en", "pl"], "default": "en", "description": "Built-in message preset. Any non-empty message override takes precedence." }, "chatChannelId": { "type": "string", "default": "", "description": "Discord channel ID for player chat relay." }, "monitoringChannelId": { "type": "string", "default": "", "description": "Discord channel ID for system/death/join/leave/blood moon/status messages." }, "relayDiscordToGame": { "type": "boolean", "default": true, "description": "Relay messages from the Discord chat channel into game chat." }, @@ -14,17 +15,25 @@ "monitorJoins": { "type": "boolean", "default": true }, "monitorLeaves": { "type": "boolean", "default": true }, "monitorDeaths": { "type": "boolean", "default": true }, + "joinMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional join-message override. Placeholder: {player}." }, + "leaveMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional leave-message override. Placeholder: {player}." }, + "deathMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional death-message override. Placeholder: {player}." }, + "deathWithReasonMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional death-message override when a reason is available. Placeholders: {player}, {reason}." }, + "serverStartingMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional server-online message override. Placeholder: {serverName}." }, + "serverOfflineMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional server-offline message override. Placeholder: {serverName}." }, + "emptyPlayerListText": { "type": "string", "maxLength": 100, "default": "", "description": "Optional text shown when no players are online." }, + "unknownPlayerText": { "type": "string", "maxLength": 100, "default": "", "description": "Optional fallback when an event has no player name." }, "updateStatusMessage": { "type": "boolean", "default": true, "description": "Update one persistent bot message with current server status instead of posting a new status every tick." }, - "statusTemplate": { "type": "string", "maxLength": 500, "default": "{onlinePlayers} os. ⭔ Dzień {day} ⭔ {time}{bloodMoonIcon}", "description": "Server status message. Placeholders: {onlinePlayers}, {day}, {time}, {bloodMoonIcon}, {serverName}." }, - "bloodMoonIcon": { "type": "string", "maxLength": 50, "default": " ⭔ 🩸" }, + "statusTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional server-status override. Placeholders: {onlinePlayers}, {playerList}, {day}, {time}, {bloodMoonIcon}, {serverName}." }, + "bloodMoonIcon": { "type": "string", "maxLength": 50, "default": " ⭒ 🩸" }, "timeConsoleCommand": { "type": "string", "enum": ["gettime"], "default": "gettime" }, "hordeIntervalDays": { "type": "integer", "minimum": 1, "default": 7 }, "firstHordeDay": { "type": "integer", "minimum": 1, "default": 7 }, "bloodMoonStartHour": { "type": "integer", "minimum": 0, "maximum": 23, "default": 22 }, "bloodMoonEndHour": { "type": "integer", "minimum": 0, "maximum": 23, "default": 4 }, - "bloodMoonTodayMessage": { "type": "string", "maxLength": 500, "default": "Dzisiaj zapowiadają Krwawy Księżyc..." }, - "bloodMoonStartingMessage": { "type": "string", "maxLength": 500, "default": "Krwawy Księżyc wschodzi..." }, - "bloodMoonEndingMessage": { "type": "string", "maxLength": 500, "default": "Krwawy Księżyc zachodzi..." } + "bloodMoonTodayMessage": { "type": "string", "maxLength": 500, "default": "", "description": "Optional blood-moon-day message override." }, + "bloodMoonStartingMessage": { "type": "string", "maxLength": 500, "default": "", "description": "Optional blood-moon-starting message override." }, + "bloodMoonEndingMessage": { "type": "string", "maxLength": 500, "default": "", "description": "Optional blood-moon-ending message override." } }, "required": [], "additionalProperties": false @@ -44,7 +53,8 @@ "gameChatRelay": { "eventType": "chat-message", "description": "Relays in-game chat messages to Discord chat channel.", "function": "src/hooks/game-chat-relay/index.js" }, "monitorJoin": { "eventType": "player-connected", "description": "Sends player join messages to the monitoring channel.", "function": "src/hooks/monitor-join/index.js" }, "monitorLeave": { "eventType": "player-disconnected", "description": "Sends player leave messages to the monitoring channel.", "function": "src/hooks/monitor-leave/index.js" }, - "monitorDeath": { "eventType": "player-death", "description": "Sends player death messages to the monitoring channel.", "function": "src/hooks/monitor-death/index.js" } + "monitorDeath": { "eventType": "player-death", "description": "Sends player death messages to the monitoring channel.", "function": "src/hooks/monitor-death/index.js" }, + "monitorServerStatus": { "eventType": "server-status-changed", "description": "Sends server online and offline messages to the monitoring channel.", "function": "src/hooks/monitor-server-status/index.js" } }, "cronJobs": { "updateStatus": { "temporalValue": "*/5 * * * *", "description": "Updates the persistent Discord server status message.", "function": "src/cronjobs/update-status/index.js" }, diff --git a/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js b/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js index 9c6017c..1d3e164 100644 --- a/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js +++ b/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js @@ -1,18 +1,18 @@ import { data } from '@takaro/helpers'; -import { BLOOD_STATE_KEY, getDiscordChannelFromHook, getStatus, isBloodMoonDay, readVariable, sendDiscord, writeVariable } from './discord-7d2d-status-helpers.js'; +import { BLOOD_STATE_KEY, getDiscordChannelFromHook, getStatus, isBloodMoonDay, readVariable, resolveMessage, sendDiscord, writeVariable } from './discord-7d2d-status-helpers.js'; function phaseFor(status, config) { const parsed = status.parsed; - if (!parsed) return { key: 'unknown', message: null }; + if (!parsed) return { key: 'unknown', messageKey: null }; const first = config.firstHordeDay ?? 7; const interval = config.hordeIntervalDays ?? 7; const start = config.bloodMoonStartHour ?? 22; const end = config.bloodMoonEndHour ?? 4; - if (isBloodMoonDay(parsed.day, first, interval) && parsed.hour !== null && parsed.hour >= start) return { key: `start:${parsed.day}`, message: config.bloodMoonStartingMessage }; - if (parsed.hour !== null && parsed.hour < end && parsed.day > 1 && isBloodMoonDay(parsed.day - 1, first, interval)) return { key: `start:${parsed.day - 1}`, message: null }; - if (parsed.hour !== null && parsed.hour >= end && parsed.day > 1 && isBloodMoonDay(parsed.day - 1, first, interval)) return { key: `end:${parsed.day - 1}`, message: config.bloodMoonEndingMessage }; - if (isBloodMoonDay(parsed.day, first, interval)) return { key: `today:${parsed.day}`, message: config.bloodMoonTodayMessage }; - return { key: `normal:${parsed.day}`, message: null }; + if (isBloodMoonDay(parsed.day, first, interval) && parsed.hour !== null && parsed.hour >= start) return { key: `start:${parsed.day}`, messageKey: 'bloodMoonStartingMessage' }; + if (parsed.hour !== null && parsed.hour < end && parsed.day > 1 && isBloodMoonDay(parsed.day - 1, first, interval)) return { key: `start:${parsed.day - 1}`, messageKey: null }; + if (parsed.hour !== null && parsed.hour >= end && parsed.day > 1 && isBloodMoonDay(parsed.day - 1, first, interval)) return { key: `end:${parsed.day - 1}`, messageKey: 'bloodMoonEndingMessage' }; + if (isBloodMoonDay(parsed.day, first, interval)) return { key: `today:${parsed.day}`, messageKey: 'bloodMoonTodayMessage' }; + return { key: `normal:${parsed.day}`, messageKey: null }; } async function main() { @@ -22,7 +22,7 @@ async function main() { const status = await getStatus(gameServerId, config); const phase = phaseFor(status, config); const prev = await readVariable(gameServerId, mod.moduleId, BLOOD_STATE_KEY, null); - if (phase.message && prev !== phase.key) await sendDiscord(channelId, phase.message); + if (phase.messageKey && prev !== phase.key) await sendDiscord(channelId, resolveMessage(config, phase.messageKey)); await writeVariable(gameServerId, mod.moduleId, BLOOD_STATE_KEY, phase.key); } diff --git a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js index a07fecd..ea9894d 100644 --- a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +++ b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js @@ -3,6 +3,60 @@ import { takaro } from '@takaro/helpers'; export const STATUS_MESSAGE_KEY_PREFIX = 'discord7d2d:statusMessage:'; export const BLOOD_STATE_KEY = 'discord7d2d:bloodState'; +export const MESSAGE_PRESETS = { + en: { + joinMessageTemplate: '{player} joined the game.', + leaveMessageTemplate: '{player} left the game.', + deathMessageTemplate: '{player} died.', + deathWithReasonMessageTemplate: '{player} died: {reason}', + bloodMoonTodayMessage: 'Today is the Blood Moon day...', + bloodMoonStartingMessage: 'Blood Moon is starting...', + bloodMoonEndingMessage: 'Blood Moon is ending...', + serverStartingMessageTemplate: 'Server starting...', + serverOfflineMessageTemplate: 'Server is offline.', + statusTemplate: '{onlinePlayers} online ⭒ Day {day} ⭒ {time}{bloodMoonIcon}\nPlayers:\n{playerList}', + emptyPlayerListText: 'Nobody online', + unknownPlayerText: 'Player', + }, + pl: { + joinMessageTemplate: '{player} dołącza do gry.', + leaveMessageTemplate: '{player} opuszcza grę.', + deathMessageTemplate: '{player} nie żyje.', + deathWithReasonMessageTemplate: '{player} nie żyje: {reason}', + bloodMoonTodayMessage: 'Dzisiaj zapowiadają Krwawy Księżyc...', + bloodMoonStartingMessage: 'Krwawy Księżyc wschodzi...', + bloodMoonEndingMessage: 'Krwawy Księżyc zachodzi...', + serverStartingMessageTemplate: 'Serwer startuje...', + serverOfflineMessageTemplate: 'Serwer wyłączony.', + statusTemplate: '{onlinePlayers} os. ⭒ Dzień {day} ⭒ {time}{bloodMoonIcon}\nLista graczy:\n{playerList}', + emptyPlayerListText: 'Brak graczy online', + unknownPlayerText: 'Gracz', + }, +}; + +export function getLanguage(config = {}) { + return Object.hasOwn(MESSAGE_PRESETS, config.language) ? config.language : 'en'; +} + +export function resolveMessage(config = {}, key) { + const override = config[key]; + if (typeof override === 'string' && override.trim()) return override; + const language = getLanguage(config); + return MESSAGE_PRESETS[language]?.[key] ?? MESSAGE_PRESETS.en[key] ?? ''; +} + +export function renderMessage(config, key, values = {}) { + let rendered = resolveMessage(config, key); + for (const [placeholder, value] of Object.entries(values)) { + rendered = rendered.split(`{${placeholder}}`).join(String(value ?? '')); + } + const unknown = [...new Set(rendered.match(/\{[A-Za-z][A-Za-z0-9]*\}/g) ?? [])]; + if (unknown.length > 0) { + console.warn(`discord-7d2d-status: unknown template placeholders: ${unknown.join(', ')}`); + } + return rendered; +} + export function parse7d2dTime(raw) { const text = String(raw ?? ''); const dayMatch = text.match(/\bDay\s*:?\s*(\d+)\b/i) || text.match(/\bD\s*:?\s*(\d+)\b/i) || text.match(/(?:Game|World)\s*time.*?\b(\d+)\b/i); @@ -32,12 +86,32 @@ export function isBloodMoonActive(parsed, firstHordeDay = 7, interval = 7, start return false; } +export async function getOnlinePlayers(gameServerId) { + const limit = 100; + const records = []; + let page = 0; + let total = 0; + do { + const res = await takaro.playerOnGameserver.playerOnGameServerControllerSearch({ + filters: { gameServerId: [gameServerId], online: [true] }, + extend: ['player'], + limit, + page, + }); + records.push(...res.data.data); + total = res.data.meta?.total ?? records.length; + page += 1; + } while (records.length < total); + + const playerNames = records + .map((record) => record.player?.name || record.gameId) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })); + return { onlinePlayers: total, playerNames }; +} + export async function getOnlineCount(gameServerId) { - const res = await takaro.playerOnGameserver.playerOnGameServerControllerSearch({ - filters: { gameServerId: [gameServerId], online: [true] }, - limit: 1, - }); - return res.data.meta?.total ?? res.data.data.length; + return (await getOnlinePlayers(gameServerId)).onlinePlayers; } export async function getServerName(gameServerId) { @@ -61,26 +135,35 @@ export async function executeTimeCommand(gameServerId, command = 'gettime') { } export async function getStatus(gameServerId, config) { - const [onlinePlayers, serverName, rawTime] = await Promise.all([ - getOnlineCount(gameServerId), + const [players, serverName, rawTime] = await Promise.all([ + getOnlinePlayers(gameServerId), getServerName(gameServerId), executeTimeCommand(gameServerId, config.timeConsoleCommand ?? 'gettime'), ]); const parsed = parse7d2dTime(rawTime); + if (!parsed) console.warn(`discord-7d2d-status: could not parse 7D2D time: ${rawTime}`); const day = parsed?.day ?? '?'; const time = parsed?.time ?? '?'; const active = isBloodMoonActive(parsed, config.firstHordeDay ?? 7, config.hordeIntervalDays ?? 7, config.bloodMoonStartHour ?? 22, config.bloodMoonEndHour ?? 4); - return { onlinePlayers, serverName, rawTime, parsed, day, time, bloodMoonActive: active }; + return { ...players, serverName, rawTime, parsed, day, time, bloodMoonActive: active }; } export function renderTemplate(template, status, config) { - const bloodMoonIcon = status.bloodMoonActive ? (config.bloodMoonIcon ?? ' ⭔ 🩸') : ''; - return String(template ?? '{onlinePlayers} os. ⭔ Dzień {day} ⭔ {time}{bloodMoonIcon}') - .replace(/\{onlinePlayers\}/g, String(status.onlinePlayers)) - .replace(/\{serverName\}/g, String(status.serverName)) - .replace(/\{day\}/g, String(status.day)) - .replace(/\{time\}/g, String(status.time)) - .replace(/\{bloodMoonIcon\}/g, bloodMoonIcon); + const effectiveConfig = typeof template === 'string' && template.trim() + ? { ...config, statusTemplate: template } + : config; + const bloodMoonIcon = status.bloodMoonActive ? (config.bloodMoonIcon ?? ' ⭒ 🩸') : ''; + const playerList = status.playerNames?.length + ? status.playerNames.join('\n') + : resolveMessage(config, 'emptyPlayerListText'); + return renderMessage(effectiveConfig, 'statusTemplate', { + onlinePlayers: status.onlinePlayers, + playerList, + serverName: status.serverName, + day: status.day, + time: status.time, + bloodMoonIcon, + }); } async function findVariable(gameServerId, moduleId, key) { @@ -107,11 +190,12 @@ export function getDiscordChannelFromHook(data, configuredChannelId, hookName) { } export async function sendDiscord(channelId, message) { + const safeMessage = sanitizeDiscordMessage(message); if (!channelId) { - console.log(`discord-7d2d-status: no Discord channel configured, skipped message: ${message}`); + console.log(`discord-7d2d-status: no Discord channel configured, skipped message: ${safeMessage}`); return null; } - return takaro.discord.discordControllerSendMessage(channelId, { message: sanitizeDiscordMessage(message) }); + return takaro.discord.discordControllerSendMessage(channelId, { message: safeMessage }); } export function sanitizeDiscordMessage(message) { diff --git a/modules/discord-7d2d-status-bridge/src/hooks/discord-chat-relay/index.js b/modules/discord-7d2d-status-bridge/src/hooks/discord-chat-relay/index.js index 7e06337..038b6e6 100644 --- a/modules/discord-7d2d-status-bridge/src/hooks/discord-chat-relay/index.js +++ b/modules/discord-7d2d-status-bridge/src/hooks/discord-chat-relay/index.js @@ -12,7 +12,9 @@ async function main() { const text = pickText(eventData?.msg, eventData?.message, eventData?.content, eventData?.text); if (!text) return; const author = pickAuthorName(eventData); - await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { message: `[Discord] ${author}: ${text}`, opts: {} }); + const message = `[Discord] ${author}: ${text}`; + await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { message, opts: {} }); + console.log(`discord-7d2d-status: relayed Discord message to game: ${message}`); } await main(); diff --git a/modules/discord-7d2d-status-bridge/src/hooks/monitor-death/index.js b/modules/discord-7d2d-status-bridge/src/hooks/monitor-death/index.js index 2c65a43..9f7a04a 100644 --- a/modules/discord-7d2d-status-bridge/src/hooks/monitor-death/index.js +++ b/modules/discord-7d2d-status-bridge/src/hooks/monitor-death/index.js @@ -1,5 +1,5 @@ import { data } from '@takaro/helpers'; -import { getDiscordChannelFromHook, pickText, sendDiscord } from './discord-7d2d-status-helpers.js'; +import { getDiscordChannelFromHook, pickText, renderMessage, resolveMessage, sendDiscord } from './discord-7d2d-status-helpers.js'; async function main() { const { eventData, player, module: mod } = data; @@ -7,8 +7,9 @@ async function main() { if (config.monitorDeaths === false) return; const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'monitorDeath'); const reason = pickText(eventData?.reason, eventData?.cause, eventData?.message); - const victim = player?.name || eventData?.player?.name || 'Player'; - await sendDiscord(channelId, reason ? `☠️ ${victim} zginął: ${reason}` : `☠️ ${victim} zginął.`); + const victim = player?.name || eventData?.player?.name || resolveMessage(config, 'unknownPlayerText'); + const key = reason ? 'deathWithReasonMessageTemplate' : 'deathMessageTemplate'; + await sendDiscord(channelId, renderMessage(config, key, { player: victim, reason })); } await main(); diff --git a/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js b/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js index 7314ae1..73f9ead 100644 --- a/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js +++ b/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js @@ -1,12 +1,13 @@ import { data } from '@takaro/helpers'; -import { getDiscordChannelFromHook, sendDiscord } from './discord-7d2d-status-helpers.js'; +import { getDiscordChannelFromHook, renderMessage, resolveMessage, sendDiscord } from './discord-7d2d-status-helpers.js'; async function main() { const { player, module: mod } = data; const config = mod.userConfig; if (config.monitorJoins === false) return; const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'monitorJoin'); - await sendDiscord(channelId, `✅ ${player?.name || 'Player'} dołączył do serwera.`); + const playerName = player?.name || resolveMessage(config, 'unknownPlayerText'); + await sendDiscord(channelId, renderMessage(config, 'joinMessageTemplate', { player: playerName })); } await main(); diff --git a/modules/discord-7d2d-status-bridge/src/hooks/monitor-leave/index.js b/modules/discord-7d2d-status-bridge/src/hooks/monitor-leave/index.js index ac6063a..22770f5 100644 --- a/modules/discord-7d2d-status-bridge/src/hooks/monitor-leave/index.js +++ b/modules/discord-7d2d-status-bridge/src/hooks/monitor-leave/index.js @@ -1,12 +1,13 @@ import { data } from '@takaro/helpers'; -import { getDiscordChannelFromHook, sendDiscord } from './discord-7d2d-status-helpers.js'; +import { getDiscordChannelFromHook, renderMessage, resolveMessage, sendDiscord } from './discord-7d2d-status-helpers.js'; async function main() { const { player, module: mod } = data; const config = mod.userConfig; if (config.monitorLeaves === false) return; const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'monitorLeave'); - await sendDiscord(channelId, `👋 ${player?.name || 'Player'} opuścił serwer.`); + const playerName = player?.name || resolveMessage(config, 'unknownPlayerText'); + await sendDiscord(channelId, renderMessage(config, 'leaveMessageTemplate', { player: playerName })); } await main(); diff --git a/modules/discord-7d2d-status-bridge/src/hooks/monitor-server-status/index.js b/modules/discord-7d2d-status-bridge/src/hooks/monitor-server-status/index.js new file mode 100644 index 0000000..6dc9b6b --- /dev/null +++ b/modules/discord-7d2d-status-bridge/src/hooks/monitor-server-status/index.js @@ -0,0 +1,24 @@ +import { data } from '@takaro/helpers'; +import { getDiscordChannelFromHook, getServerName, renderMessage, sendDiscord } from './discord-7d2d-status-helpers.js'; + +async function main() { + const { eventData, gameServerId, module: mod } = data; + const config = mod.userConfig; + const status = String(eventData?.status ?? '').toLowerCase(); + const messageKey = status === 'online' + ? 'serverStartingMessageTemplate' + : status === 'offline' + ? 'serverOfflineMessageTemplate' + : null; + + if (!messageKey) { + console.log(`discord-7d2d-status: ignoring unsupported server status: ${status || 'missing'}`); + return; + } + + const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'monitorServerStatus'); + const serverName = await getServerName(gameServerId); + await sendDiscord(channelId, renderMessage(config, messageKey, { serverName })); +} + +await main(); diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts new file mode 100644 index 0000000..91889fd --- /dev/null +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -0,0 +1,426 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, SpawnSyncReturns } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + Client, + EventOutputDTO, + EventSearchInputAllowedFiltersEventNameEnum, + HookTriggerDTOEventTypeEnum, + ModuleOutputDTO, +} from '@takaro/apiclient'; +import { createClient } from '../../../test/helpers/client.js'; +import { MockServerContext, startMockServer, stopMockServer } from '../../../test/helpers/mock-server.js'; +import { + cleanupTestGameServers, + cleanupTestModules, + deleteModule, + installModule, + uninstallModule, +} from '../../../test/helpers/modules.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MODULE_DIR = path.resolve(__dirname, '..'); +const MODULE_TO_JSON_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'dist', 'scripts', 'module-to-json.js'); +const TEST_MODULE_NAME = `test-discord-7d2d-status-bridge-${process.pid}`; + +interface WaitForBridgeEventOptions { + eventName: EventSearchInputAllowedFiltersEventNameEnum; + gameserverId: string; + moduleId: string; + after: Date; + predicate?: (event: EventOutputDTO) => boolean; + timeout?: number; + pollInterval?: number; +} + +async function waitForBridgeEvent(client: Client, options: WaitForBridgeEventOptions): Promise { + const deadline = Date.now() + (options.timeout ?? 30000); + const pollInterval = options.pollInterval ?? 1000; + + while (Date.now() < deadline) { + const result = await client.event.eventControllerSearch({ + filters: { + eventName: [options.eventName], + gameserverId: [options.gameserverId], + moduleId: [options.moduleId], + }, + greaterThan: { createdAt: options.after.toISOString() }, + }); + const event = options.predicate + ? result.data.data.find(options.predicate) + : result.data.data[0]; + if (event) return event; + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + throw new Error( + `Timed out waiting for event '${options.eventName}' for module '${options.moduleId}' ` + + `on gameserver '${options.gameserverId}'`, + ); +} + +async function pushDisposableBridgeModule(client: Client): Promise { + const tempFile = path.join(os.tmpdir(), `takaro-bridge-push-${process.pid}-${Date.now()}.json`); + try { + try { + execFileSync(process.execPath, [MODULE_TO_JSON_SCRIPT, MODULE_DIR, tempFile], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (err) { + const spawnErr = err as SpawnSyncReturns; + const stderr = spawnErr.stderr?.toString().trim() ?? ''; + throw new Error(`module-to-json failed${stderr ? `:\n${stderr}` : ''}`); + } + + const moduleJson = JSON.parse(fs.readFileSync(tempFile, 'utf-8')) as { + name: string; + supportedGames?: string[]; + }; + moduleJson.name = TEST_MODULE_NAME; + moduleJson.supportedGames = []; + + const existing = await client.module.moduleControllerSearch({ + filters: { name: [TEST_MODULE_NAME] }, + }); + const existingModule = existing.data.data.find((candidate) => candidate.name === TEST_MODULE_NAME); + if (existingModule) await client.module.moduleControllerRemove(existingModule.id); + + await client.module.moduleControllerImport(moduleJson); + + const imported = await client.module.moduleControllerSearch({ + filters: { name: [TEST_MODULE_NAME] }, + }); + const found = imported.data.data.find((candidate) => candidate.name === TEST_MODULE_NAME); + if (!found) throw new Error(`Module '${TEST_MODULE_NAME}' not found after import`); + return found; + } finally { + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + } +} + +describe('discord-7d2d-status-bridge integration', () => { + let client: Client; + let ctx: MockServerContext; + let mod: ModuleOutputDTO; + let installed = false; + let playerName: string; + let playerNames: string[]; + + async function installWithConfig(userConfig: Record = {}) { + if (installed) { + await uninstallModule(client, mod.id, ctx.gameServer.id); + installed = false; + } + await installModule(client, mod.latestVersion.id, ctx.gameServer.id, { + userConfig: { chatChannelId: '', monitoringChannelId: '', ...userConfig }, + systemConfig: { hooks: { discordChatRelay: { discordChannelId: '1' } } }, + }); + installed = true; + } + + async function triggerHook( + eventType: HookTriggerDTOEventTypeEnum, + options: { playerId?: string; eventMeta?: Record; expectedLog?: string } = {}, + ): Promise { + const beforeTrigger = new Date(); + await client.hook.hookControllerTrigger({ + gameServerId: ctx.gameServer.id, + moduleId: mod.id, + playerId: options.playerId, + eventType, + eventMeta: options.eventMeta ?? {}, + }); + const event = await waitForBridgeEvent(client, { + eventName: EventSearchInputAllowedFiltersEventNameEnum.HookExecuted, + gameserverId: ctx.gameServer.id, + moduleId: mod.id, + after: beforeTrigger, + predicate: options.expectedLog + ? (candidate) => { + const result = (candidate.meta as { result?: { logs?: Array<{ msg: string }> } }).result; + return (result?.logs ?? []).some((entry) => entry.msg.includes(options.expectedLog!)); + } + : undefined, + }); + const result = (event.meta as { result?: { success?: boolean; logs?: Array<{ msg: string }> } }).result; + const logs = (result?.logs ?? []).map((entry) => entry.msg); + assert.equal(result?.success, true, `Expected hook '${eventType}' to succeed, logs: ${JSON.stringify(logs)}`); + return logs; + } + + async function triggerCronjob(name: string): Promise { + const cronjob = mod.latestVersion.cronJobs.find((candidate) => candidate.name === name); + assert.ok(cronjob, `Expected cronjob '${name}' to exist`); + const beforeTrigger = new Date(); + await client.cronjob.cronJobControllerTrigger({ + gameServerId: ctx.gameServer.id, + moduleId: mod.id, + cronjobId: cronjob.id, + }); + const event = await waitForBridgeEvent(client, { + eventName: EventSearchInputAllowedFiltersEventNameEnum.CronjobExecuted, + gameserverId: ctx.gameServer.id, + moduleId: mod.id, + after: beforeTrigger, + }); + const result = (event.meta as { result?: { success?: boolean; logs?: Array<{ msg: string }> } }).result; + const logs = (result?.logs ?? []).map((entry) => entry.msg); + assert.equal(result?.success, true, `Expected cronjob '${name}' to succeed, logs: ${JSON.stringify(logs)}`); + return logs; + } + + async function waitForOnlineCount(expected: number) { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + const result = await client.playerOnGameserver.playerOnGameServerControllerSearch({ + filters: { gameServerId: [ctx.gameServer.id], online: [true] }, + }); + if (result.data.data.length === expected) return; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Timed out waiting for ${expected} online players`); + } + + function assertLogContains(logs: string[], expected: string) { + assert.ok( + logs.some((message) => message.includes(expected)), + `Expected logs to contain ${JSON.stringify(expected)}, got: ${JSON.stringify(logs)}`, + ); + } + + before(async () => { + client = await createClient(); + await cleanupTestModules(client); + await cleanupTestGameServers(client); + ctx = await startMockServer(client); + mod = await pushDisposableBridgeModule(client); + playerNames = await Promise.all(ctx.players.map(async (playerOnGameserver) => { + const player = await client.player.playerControllerGetOne(playerOnGameserver.playerId); + return player.data.data.name; + })); + playerName = playerNames[0]; + try { + await installWithConfig(); + } catch (err) { + const response = (err as { response?: { status?: number; data?: unknown } }).response; + throw new Error(`Bridge test installation failed (${response?.status ?? 'unknown'}): ${JSON.stringify(response?.data ?? {})}`); + } + }); + + after(async () => { + if (mod && ctx) { + if (installed) { + await uninstallModule(client, mod.id, ctx.gameServer.id).catch((err) => { + const status = (err as { response?: { status?: number } }).response?.status; + console.error(`Cleanup: failed to uninstall bridge test module (HTTP ${status ?? 'unknown'})`); + }); + } + await deleteModule(client, mod.id).catch((err) => { + const status = (err as { response?: { status?: number } }).response?.status; + console.error(`Cleanup: failed to delete bridge test module (HTTP ${status ?? 'unknown'})`); + }); + } + if (ctx) await stopMockServer(ctx.server, client, ctx.gameServer.id); + }); + + it('imports under a disposable name and scopes hook events to that module', async () => { + assert.equal(mod.name, TEST_MODULE_NAME); + const logs = await triggerHook('player-connected', { playerId: ctx.players[0].playerId }); + assert.ok(logs.length > 0); + }); + + it('uses English monitoring messages by default', async () => { + await installWithConfig(); + + const joinLogs = await triggerHook('player-connected', { playerId: ctx.players[0].playerId }); + const leaveLogs = await triggerHook('player-disconnected', { playerId: ctx.players[0].playerId }); + const deathLogs = await triggerHook('player-death', { playerId: ctx.players[0].playerId }); + const reasonLogs = await triggerHook('player-death', { + playerId: ctx.players[0].playerId, + eventMeta: { reason: 'zombie' }, + }); + + assertLogContains(joinLogs, `${playerName} joined the game.`); + assertLogContains(leaveLogs, `${playerName} left the game.`); + assertLogContains(deathLogs, `${playerName} died.`); + assertLogContains(reasonLogs, `${playerName} died: zombie`); + }); + + it('uses the exact Polish issue wording when selected', async () => { + await installWithConfig({ language: 'pl' }); + + const joinLogs = await triggerHook('player-connected', { playerId: ctx.players[0].playerId }); + const leaveLogs = await triggerHook('player-disconnected', { playerId: ctx.players[0].playerId }); + const deathLogs = await triggerHook('player-death', { playerId: ctx.players[0].playerId }); + const reasonLogs = await triggerHook('player-death', { + playerId: ctx.players[0].playerId, + eventMeta: { reason: 'zombie' }, + }); + + assertLogContains(joinLogs, `${playerName} dołącza do gry.`); + assertLogContains(leaveLogs, `${playerName} opuszcza grę.`); + assertLogContains(deathLogs, `${playerName} nie żyje.`); + assertLogContains(reasonLogs, `${playerName} nie żyje: zombie`); + }); + + it('prefers non-empty custom templates and warns about unknown placeholders', async () => { + await installWithConfig({ + language: 'pl', + joinMessageTemplate: 'Welcome {player} {mystery}', + }); + + const logs = await triggerHook('player-connected', { playerId: ctx.players[0].playerId }); + + assertLogContains(logs, `Welcome ${playerName} {mystery}`); + assertLogContains(logs, 'unknown template placeholders: {mystery}'); + }); + + it('falls back from an empty override and localizes a missing player name', async () => { + await installWithConfig({ + language: 'pl', + joinMessageTemplate: '', + unknownPlayerText: 'Nieznany gracz', + }); + + const logs = await triggerHook('player-connected'); + + assertLogContains(logs, 'Nieznany gracz dołącza do gry.'); + }); + + it('announces server online and offline states in English by default', async () => { + await installWithConfig(); + const timestamp = new Date().toISOString(); + + const onlineLogs = await triggerHook('server-status-changed', { + eventMeta: { status: 'online', timestamp }, + }); + const offlineLogs = await triggerHook('server-status-changed', { + eventMeta: { status: 'offline', timestamp, details: 'test outage' }, + }); + + assertLogContains(onlineLogs, 'Server starting...'); + assertLogContains(offlineLogs, 'Server is offline.'); + }); + + it('localizes server states and supports a server-name override', async () => { + await installWithConfig({ + language: 'pl', + serverStartingMessageTemplate: 'Custom {serverName} online', + }); + const timestamp = new Date().toISOString(); + + const onlineLogs = await triggerHook('server-status-changed', { + eventMeta: { status: 'online', timestamp }, + }); + const offlineLogs = await triggerHook('server-status-changed', { + eventMeta: { status: 'offline', timestamp }, + }); + + assertLogContains(onlineLogs, `Custom ${ctx.gameServer.name} online`); + assertLogContains(offlineLogs, 'Serwer wyłączony.'); + }); + + it('ignores unknown server states with a diagnostic log', async () => { + await installWithConfig(); + + const logs = await triggerHook('server-status-changed', { + eventMeta: { status: 'maintenance', timestamp: new Date().toISOString() }, + }); + + assertLogContains(logs, 'ignoring unsupported server status: maintenance'); + assert.ok(!logs.some((message) => message.includes('skipped message:'))); + }); + + it('renders count, server name, unknown time, and an ordered player list', async () => { + await installWithConfig({ + updateStatusMessage: false, + statusTemplate: '{onlinePlayers} online | {serverName} | Day {day} | {time}\nPlayers:\n{playerList}', + }); + + const logs = await triggerCronjob('updateStatus'); + const expectedPlayers = [...playerNames].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })); + + assertLogContains(logs, `${playerNames.length} online`); + assertLogContains(logs, `Day ?`); + assertLogContains(logs, ctx.gameServer.name); + assertLogContains(logs, `Players:\n${expectedPlayers.join('\n')}`); + }); + + it('uses localized or overridden empty-player-list text', async () => { + await ctx.server.executeConsoleCommand('disconnectAll'); + await waitForOnlineCount(0); + try { + await installWithConfig({ + language: 'pl', + updateStatusMessage: false, + statusTemplate: 'Lista:\n{playerList}', + emptyPlayerListText: 'Nikt teraz nie gra', + }); + + const logs = await triggerCronjob('updateStatus'); + + assertLogContains(logs, 'Lista:\nNikt teraz nie gra'); + } finally { + await ctx.server.executeConsoleCommand('connectAll'); + await waitForOnlineCount(playerNames.length); + } + }); + + it('relays global game chat with sanitized Discord mentions', async () => { + await installWithConfig(); + + const logs = await triggerHook('chat-message', { + playerId: ctx.players[0].playerId, + eventMeta: { channel: 'global', msg: '@everyone hello' }, + }); + + assertLogContains(logs, `**${playerName}:** @\u200beveryone hello`); + assert.ok(!logs.some((message) => message.includes('skipped message:') && message.includes('@everyone'))); + }); + + it('ignores private chat and Discord relay echoes', async () => { + await installWithConfig(); + + const privateLogs = await triggerHook('chat-message', { + playerId: ctx.players[0].playerId, + eventMeta: { channel: 'team', msg: 'private message' }, + }); + const echoLogs = await triggerHook('chat-message', { + playerId: ctx.players[0].playerId, + eventMeta: { channel: 'global', msg: '[Discord] Alice: echo' }, + }); + + assert.ok(!privateLogs.some((message) => message.includes('skipped message:'))); + assert.ok(!echoLogs.some((message) => message.includes('skipped message:'))); + }); + + it('ignores Discord bots and relays Discord users into the game', async () => { + await installWithConfig(); + + const botLogs = await triggerHook('discord-message', { + eventMeta: { + msg: 'bot message', + author: { id: 'bot', username: 'BridgeBot', displayName: 'Bridge Bot', isBot: true, isTakaroBot: false }, + channel: { id: '1', name: 'test' }, + timestamp: new Date().toISOString(), + }, + }); + const userLogs = await triggerHook('discord-message', { + expectedLog: 'relayed Discord message to game: [Discord] Alice: hello game', + eventMeta: { + msg: 'hello game', + author: { id: 'user', username: 'Alice', displayName: 'Alice', isBot: false, isTakaroBot: false }, + channel: { id: '1', name: 'test' }, + timestamp: new Date().toISOString(), + }, + }); + + assert.ok(!botLogs.some((message) => message.includes('/message'))); + assertLogContains(userLogs, 'relayed Discord message to game: [Discord] Alice: hello game'); + }); +}); diff --git a/modules/discord-7d2d-status-bridge/test/helpers.test.ts b/modules/discord-7d2d-status-bridge/test/helpers.test.ts deleted file mode 100644 index 4422c24..0000000 --- a/modules/discord-7d2d-status-bridge/test/helpers.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { readFileSync } from 'node:fs'; -import * as vm from 'node:vm'; - -interface HelperExports { - parse7d2dTime: (raw: string) => { day: number; hour: number | null; minute: number | null; time: string } | null; - nextHordeDay: (currentDay: number, firstHordeDay?: number, interval?: number) => number; - isBloodMoonDay: (day: number, firstHordeDay?: number, interval?: number) => boolean; - isBloodMoonActive: (parsed: { day: number; hour: number | null }, firstHordeDay?: number, interval?: number, startHour?: number, endHour?: number) => boolean; - renderTemplate: (template: string, status: Record, config: Record) => string; - sanitizeDiscordMessage: (message: string) => string; - isGlobalChatMessage: (eventData: Record) => boolean; - isDiscordRelayEcho: (eventData: Record) => boolean; -} - -const helperPath = new URL('../src/functions/discord-7d2d-status-helpers.js', import.meta.url); -let source = readFileSync(helperPath, 'utf8'); -source = source.replace("import { takaro } from '@takaro/helpers';", 'const takaro = {};'); -source = source.replace(/export /g, ''); -source += '\nmodule.exports = { parse7d2dTime, nextHordeDay, isBloodMoonDay, isBloodMoonActive, renderTemplate, sanitizeDiscordMessage, isGlobalChatMessage, isDiscordRelayEcho };'; -const sandbox: { module: { exports: HelperExports | Record }; console: Console } = { module: { exports: {} }, console }; -vm.runInNewContext(source, sandbox, { filename: helperPath.pathname }); -const { parse7d2dTime, nextHordeDay, isBloodMoonDay, isBloodMoonActive, renderTemplate, sanitizeDiscordMessage, isGlobalChatMessage, isDiscordRelayEcho } = sandbox.module.exports as HelperExports; - -describe('discord-7d2d-status-helpers', () => { - it('parses active 7D2D gettime output', () => { - assert.equal(JSON.stringify(parse7d2dTime('Day 4, 16:52')), JSON.stringify({ day: 4, hour: 16, minute: 52, time: '16:52' })); - assert.equal(JSON.stringify(parse7d2dTime('Day: 7, 22:01')), JSON.stringify({ day: 7, hour: 22, minute: 1, time: '22:01' })); - }); - - it('calculates horde day and active blood moon window', () => { - assert.equal(nextHordeDay(4, 7, 7), 7); - assert.equal(nextHordeDay(8, 7, 7), 14); - assert.equal(isBloodMoonDay(14, 7, 7), true); - const start = parse7d2dTime('Day 7, 22:00'); - const lateNight = parse7d2dTime('Day 8, 03:30'); - const over = parse7d2dTime('Day 8, 04:00'); - if (!start || !lateNight || !over) throw new Error('test setup failed to parse 7D2D time'); - assert.equal(isBloodMoonActive(start, 7, 7, 22, 4), true); - assert.equal(isBloodMoonActive(lateNight, 7, 7, 22, 4), true); - assert.equal(isBloodMoonActive(over, 7, 7, 22, 4), false); - }); - - it('renders Polish status template', () => { - const status = { onlinePlayers: 5, day: 60, time: '14:43', bloodMoonActive: true, serverName: '7D2D' }; - assert.equal(renderTemplate('{onlinePlayers} os. ⭔ Dzień {day} ⭔ {time}{bloodMoonIcon}', status, { bloodMoonIcon: ' ⭔ 🩸' }), '5 os. ⭔ Dzień 60 ⭔ 14:43 ⭔ 🩸'); - }); - - it('sanitizes Discord mention spam', () => { - assert.equal(sanitizeDiscordMessage('@everyone @here <@123> <@!234> <@&456>'), '@\u200beveryone @\u200bhere <@\u200b123> <@\u200b234> <@&\u200b456>'); - }); - - it('only relays explicit global/public game chat', () => { - assert.equal(isGlobalChatMessage({ channel: 'global' }), true); - assert.equal(isGlobalChatMessage({ chatType: 'public' }), true); - assert.equal(isGlobalChatMessage({ scope: 'all' }), true); - assert.equal(isGlobalChatMessage({ channel: 'team' }), false); - assert.equal(isGlobalChatMessage({ type: 'whisper', recipient: 'player2' }), false); - assert.equal(isGlobalChatMessage({ msg: 'missing scope defaults private' }), false); - }); - - it('detects Discord-to-game relay echoes', () => { - assert.equal(isDiscordRelayEcho({ msg: '[Discord] Alice: hello', channel: 'global' }), true); - assert.equal(isDiscordRelayEcho({ message: ' [discord] Bob: hello', chatType: 'public' }), true); - assert.equal(isDiscordRelayEcho({ text: 'normal game chat', channel: 'global' }), false); - }); -});