From 0f119ac273dbe1d2d3b04e304cd299583c13eadb Mon Sep 17 00:00:00 2001 From: Harambe Date: Sun, 2 Aug 2026 02:55:17 +0000 Subject: [PATCH] feat: add Discord role permission sync module --- .../discord-role-permission-sync/module.json | 105 ++++++++++++++++++ .../src/functions/permission-sync-helpers.js | 87 +++++++++++++++ .../hooks/sync-permission-on-join/index.js | 59 ++++++++++ .../test/helpers.test.js | 81 ++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 modules/discord-role-permission-sync/module.json create mode 100644 modules/discord-role-permission-sync/src/functions/permission-sync-helpers.js create mode 100644 modules/discord-role-permission-sync/src/hooks/sync-permission-on-join/index.js create mode 100644 modules/discord-role-permission-sync/test/helpers.test.js diff --git a/modules/discord-role-permission-sync/module.json b/modules/discord-role-permission-sync/module.json new file mode 100644 index 0000000..12848d6 --- /dev/null +++ b/modules/discord-role-permission-sync/module.json @@ -0,0 +1,105 @@ +{ + "name": "discord-role-permission-sync", + "description": "Sync Takaro role membership, including roles synced from Discord, to game-server permission levels by running configurable console commands when players join.", + "version": "latest", + "supportedGames": [ + "7d2d", + "all" + ], + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable permission sync on player join." + }, + "rolePermissionMappings": { + "type": "array", + "default": [ + { + "roleName": "Diamond", + "permissionLevel": 200 + } + ], + "description": "Takaro role names to game permission levels. If Discord role sync creates/assigns Takaro roles, use those Takaro role names here. Lower permission numbers are more powerful in 7 Days to Die.", + "items": { + "type": "object", + "properties": { + "roleName": { + "type": "string", + "minLength": 1, + "description": "Takaro role name to match exactly, case-insensitive." + }, + "permissionLevel": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "description": "Game permission level to apply when the player has this role. For 7D2D, lower numbers grant more access; 1000 is default/no access." + } + }, + "required": [ + "roleName", + "permissionLevel" + ], + "additionalProperties": false + } + }, + "unmatchedPermissionLevel": { + "type": [ + "integer", + "null" + ], + "default": null, + "minimum": 0, + "maximum": 1000, + "description": "Optional permission level to apply to players who do not match any role. Leave null to leave unmatched players unchanged." + }, + "commandTemplate": { + "type": "string", + "default": "admin add {playerGameId} {permissionLevel}", + "description": "Console command template. Available placeholders: {playerGameId}, {playerName}, {permissionLevel}, {matchedRole}. Default is 7D2D's admin add command." + }, + "notifyPlayer": { + "type": "boolean", + "default": false, + "description": "Privately notify the player after their permission level is synced." + }, + "notifyUnmatchedDiscordLink": { + "type": "boolean", + "default": false, + "description": "Privately remind unmatched players to link Discord / ask staff if they expected a reserved-slot role." + }, + "matchedMessage": { + "type": "string", + "default": "Your reserved-slot permission has been synced from role {matchedRole}.", + "description": "Private message sent when notifyPlayer is enabled and a mapping matched. Placeholders: {playerName}, {permissionLevel}, {matchedRole}." + }, + "unmatchedMessage": { + "type": "string", + "default": "No reserved-slot role was found for you. If you are a supporter, make sure your Discord account is linked.", + "description": "Private message sent when notifyUnmatchedDiscordLink is enabled and no mapping matched." + }, + "dryRun": { + "type": "boolean", + "default": false, + "description": "Log the command that would be executed without running it. Useful while setting up mappings." + } + }, + "required": [], + "additionalProperties": false + }, + "hooks": { + "sync-permission-on-join": { + "eventType": "player-connected", + "description": "Apply configured game permission level when a player joins based on their Takaro/Discord-synced role.", + "function": "src/hooks/sync-permission-on-join/index.js" + } + }, + "functions": { + "permission-sync-helpers": { + "function": "src/functions/permission-sync-helpers.js" + } + } +} diff --git a/modules/discord-role-permission-sync/src/functions/permission-sync-helpers.js b/modules/discord-role-permission-sync/src/functions/permission-sync-helpers.js new file mode 100644 index 0000000..3c1a835 --- /dev/null +++ b/modules/discord-role-permission-sync/src/functions/permission-sync-helpers.js @@ -0,0 +1,87 @@ +export function normalizeRoleName(value) { + return String(value ?? '').trim().toLowerCase(); +} + +export function getRoleNames(pog) { + const roles = Array.isArray(pog?.roles) ? pog.roles : []; + return roles + .map((role) => role?.name ?? role?.friendlyName ?? role?.role?.name ?? role?.roleName) + .filter((name) => typeof name === 'string' && name.trim().length > 0); +} + +export function normalizeMappings(config) { + const mappings = Array.isArray(config?.rolePermissionMappings) ? config.rolePermissionMappings : []; + return mappings + .map((mapping) => ({ + roleName: String(mapping?.roleName ?? '').trim(), + permissionLevel: Number(mapping?.permissionLevel), + })) + .filter((mapping) => mapping.roleName && Number.isInteger(mapping.permissionLevel)); +} + +export function selectPermissionForRoles(roleNames, config) { + const mappings = normalizeMappings(config); + const normalizedPlayerRoles = new Set(roleNames.map(normalizeRoleName)); + + for (const mapping of mappings) { + if (normalizedPlayerRoles.has(normalizeRoleName(mapping.roleName))) { + return { + matched: true, + matchedRole: mapping.roleName, + permissionLevel: mapping.permissionLevel, + }; + } + } + + if (config?.unmatchedPermissionLevel === null || config?.unmatchedPermissionLevel === undefined || config?.unmatchedPermissionLevel === '') { + return { + matched: false, + matchedRole: '', + permissionLevel: null, + }; + } + + const fallbackLevel = Number(config.unmatchedPermissionLevel); + if (!Number.isInteger(fallbackLevel)) { + return { + matched: false, + matchedRole: '', + permissionLevel: null, + }; + } + + return { + matched: false, + matchedRole: '', + permissionLevel: fallbackLevel, + }; +} + +export function getPlayerGameId(player, pog) { + return String(pog?.gameId ?? player?.gameId ?? player?.steamId ?? player?.name ?? player?.id ?? '').trim(); +} + +export function quoteConsoleValue(value) { + const text = String(value ?? ''); + if (/[\r\n]/.test(text)) { + throw new Error('Console command placeholder contains a newline; refusing to execute.'); + } + if (/^[A-Za-z0-9_:\-.]+$/.test(text)) return text; + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +export function renderTemplate(template, context) { + return String(template ?? '') + .replaceAll('{playerGameId}', quoteConsoleValue(context.playerGameId)) + .replaceAll('{playerName}', quoteConsoleValue(context.playerName)) + .replaceAll('{permissionLevel}', String(context.permissionLevel)) + .replaceAll('{matchedRole}', quoteConsoleValue(context.matchedRole || 'unmatched')); +} + +export function renderMessage(template, context) { + return String(template ?? '') + .replaceAll('{playerGameId}', String(context.playerGameId ?? '')) + .replaceAll('{playerName}', String(context.playerName ?? '')) + .replaceAll('{permissionLevel}', String(context.permissionLevel ?? '')) + .replaceAll('{matchedRole}', String(context.matchedRole || 'unmatched')); +} diff --git a/modules/discord-role-permission-sync/src/hooks/sync-permission-on-join/index.js b/modules/discord-role-permission-sync/src/hooks/sync-permission-on-join/index.js new file mode 100644 index 0000000..ba5f59d --- /dev/null +++ b/modules/discord-role-permission-sync/src/hooks/sync-permission-on-join/index.js @@ -0,0 +1,59 @@ +import { data, takaro } from '@takaro/helpers'; +import { + getPlayerGameId, + getRoleNames, + renderMessage, + renderTemplate, + selectPermissionForRoles, +} from './permission-sync-helpers.js'; + +async function main() { + const { gameServerId, player, module: mod } = data; + const config = mod.userConfig || {}; + + if (config.enabled === false) { + console.log('discordRolePermissionSync: disabled by config'); + return; + } + + const pog = (await takaro.playerOnGameserver.playerOnGameServerControllerGetOne(gameServerId, player.id)).data.data; + const roleNames = getRoleNames(pog); + const selection = selectPermissionForRoles(roleNames, config); + + if (selection.permissionLevel === null) { + console.log(`discordRolePermissionSync: player=${player.name} roles=[${roleNames.join(', ')}] did not match a role and no unmatchedPermissionLevel is configured`); + if (config.notifyUnmatchedDiscordLink) { + await pog.pm(config.unmatchedMessage || 'No reserved-slot role was found for you. If you are a supporter, make sure your Discord account is linked.'); + } + return; + } + + const playerGameId = getPlayerGameId(player, pog); + if (!playerGameId) { + console.error(`discordRolePermissionSync: player=${player.name} has no gameId/name usable in console command`); + return; + } + + const context = { + playerGameId, + playerName: player.name, + permissionLevel: selection.permissionLevel, + matchedRole: selection.matchedRole, + }; + const command = renderTemplate(config.commandTemplate || 'admin add {playerGameId} {permissionLevel}', context); + + if (config.dryRun) { + console.log(`discordRolePermissionSync: dryRun player=${player.name} matched=${selection.matched} role=${selection.matchedRole || 'unmatched'} command=${command}`); + } else { + await takaro.gameserver.gameServerControllerExecuteCommand(gameServerId, { command }); + console.log(`discordRolePermissionSync: applied permissionLevel=${selection.permissionLevel} to player=${player.name} matchedRole=${selection.matchedRole || 'unmatched'}`); + } + + if (config.notifyPlayer && selection.matched) { + await pog.pm(renderMessage(config.matchedMessage || 'Your reserved-slot permission has been synced.', context)); + } else if (config.notifyUnmatchedDiscordLink && !selection.matched) { + await pog.pm(renderMessage(config.unmatchedMessage || 'No reserved-slot role was found for you. If you are a supporter, make sure your Discord account is linked.', context)); + } +} + +await main(); diff --git a/modules/discord-role-permission-sync/test/helpers.test.js b/modules/discord-role-permission-sync/test/helpers.test.js new file mode 100644 index 0000000..eb25bc2 --- /dev/null +++ b/modules/discord-role-permission-sync/test/helpers.test.js @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + getPlayerGameId, + getRoleNames, + renderTemplate, + selectPermissionForRoles, +} from '../src/functions/permission-sync-helpers.js'; + +describe('discord-role-permission-sync helpers', () => { + it('matches Takaro role names case-insensitively and chooses the first configured mapping', () => { + const selection = selectPermissionForRoles(['Member', 'Diamond'], { + rolePermissionMappings: [ + { roleName: 'diamond', permissionLevel: 200 }, + { roleName: 'Member', permissionLevel: 900 }, + ], + }); + + assert.deepEqual(selection, { + matched: true, + matchedRole: 'diamond', + permissionLevel: 200, + }); + }); + + it('can apply an optional fallback permission level to unmatched players', () => { + const selection = selectPermissionForRoles(['Member'], { + rolePermissionMappings: [{ roleName: 'VIP', permissionLevel: 250 }], + unmatchedPermissionLevel: 1000, + }); + + assert.deepEqual(selection, { + matched: false, + matchedRole: '', + permissionLevel: 1000, + }); + }); + + it('leaves unmatched players unchanged when no fallback is configured', () => { + const selection = selectPermissionForRoles(['Member'], { + rolePermissionMappings: [{ roleName: 'VIP', permissionLevel: 250 }], + unmatchedPermissionLevel: null, + }); + + assert.equal(selection.permissionLevel, null); + assert.equal(selection.matched, false); + }); + + it('extracts role names from common POG role shapes', () => { + assert.deepEqual( + getRoleNames({ + roles: [ + { name: 'Diamond' }, + { friendlyName: 'Supporter' }, + { role: { name: 'Nested' } }, + { roleName: 'Legacy' }, + ], + }), + ['Diamond', 'Supporter', 'Nested', 'Legacy'], + ); + }); + + it('renders a safe 7D2D admin command with quoted names when necessary', () => { + const command = renderTemplate('admin add {playerGameId} {permissionLevel}', { + playerGameId: 'Player With Spaces', + playerName: 'Player With Spaces', + permissionLevel: 200, + matchedRole: 'Diamond', + }); + + assert.equal(command, 'admin add "Player With Spaces" 200'); + }); + + it('prefers the POG gameId as the console-command player identifier', () => { + assert.equal( + getPlayerGameId({ id: 'player-id', gameId: 'event-game-id', name: 'Name' }, { gameId: 'pog-game-id' }), + 'pog-game-id', + ); + }); +});