Skip to content
Merged
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
66 changes: 66 additions & 0 deletions modules/discord-7d2d-status-bridge/module.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "discord-7d2d-status-bridge",
"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." },
"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 },
"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": "", "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": "", "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
},
"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" },
"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" },
"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" }
}
}
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { data } from '@takaro/helpers';
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', 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}`, 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() {
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.messageKey && prev !== phase.key) await sendDiscord(channelId, resolveMessage(config, phase.messageKey));
await writeVariable(gameServerId, mod.moduleId, BLOOD_STATE_KEY, phase.key);
}

await main();
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
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);
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 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) {
return (await getOnlinePlayers(gameServerId)).onlinePlayers;
}

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 [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 { ...players, serverName, rawTime, parsed, day, time, bloodMoonActive: active };
}

export function renderTemplate(template, status, config) {
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) {
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) {
const safeMessage = sanitizeDiscordMessage(message);
if (!channelId) {
console.log(`discord-7d2d-status: no Discord channel configured, skipped message: ${safeMessage}`);
return null;
}
return takaro.discord.discordControllerSendMessage(channelId, { message: safeMessage });
}

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 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}`;
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');
}
Loading
Loading