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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions modules/discord-role-permission-sync/module.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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'));
}
Original file line number Diff line number Diff line change
@@ -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();
81 changes: 81 additions & 0 deletions modules/discord-role-permission-sync/test/helpers.test.js
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
Loading