diff --git a/CODEOWNERS b/CODEOWNERS index 3aad92168b..96bd1e44d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -16,5 +16,6 @@ server/chat-plugins/rock-paper-scissors.ts @mia-pi-git server/chat-plugins/scavenger*.ts @xfix @sparkychildcharlie server/chat-plugins/the-studio.ts @KrisXV server/chat-plugins/trivia.ts @AnnikaCodes +server/chat-plugins/username-prefixes.ts @AnnikaCodes server/modlog.ts @monsanto test/random-battles/* @AnnikaCodes diff --git a/server/chat-plugins/username-prefixes.ts b/server/chat-plugins/username-prefixes.ts new file mode 100644 index 0000000000..2e3ee44f21 --- /dev/null +++ b/server/chat-plugins/username-prefixes.ts @@ -0,0 +1,127 @@ +/** + * Code to manage username prefixes that force battles to be public or disable modchat. + * @author Annika + */ + +import {FS} from '../../lib'; + +const PREFIXES_FILE = 'config/chat-plugins/username-prefixes.json'; + +export class PrefixManager { + constructor() { + // after a restart/newly using the plugin, load prefixes from config.js + if (!Chat.oldPlugins['username-prefixes']) this.refreshConfig(true); + } + + save() { + FS(PREFIXES_FILE).writeUpdate(() => JSON.stringify(Config.forcedprefixes || {})); + } + + refreshConfig(configJustLoaded = false) { + if (!Config.forcedprefixes) Config.forcedprefixes = {}; + if (configJustLoaded) { + // if we just loaded the config file, ensure that all prefixes are IDs + if (Config.forcedprefixes.privacy) Config.forcedprefixes.privacy = Config.forcedprefixes.privacy.map(toID); + if (Config.forcedprefixes.modchat) Config.forcedprefixes.modchat = Config.forcedprefixes.modchat.map(toID); + } + + let data: AnyObject; + try { + data = JSON.parse(FS(PREFIXES_FILE).readSync()); + } catch (e) { + if (e.code !== 'ENOENT') throw e; + return; + } + for (const [type, prefixes] of Object.values(data)) { + if (!Config.forcedprefixes[type]) Config.forcedprefixes[type] = []; + for (const prefix of prefixes) { + if (Config.forcedprefixes[type].includes(prefix)) continue; + Config.forcedprefixes[type].push(prefix); + } + } + } + + addPrefix(prefix: ID, type: 'privacy' | 'modchat') { + if (!Config.forcedprefixes[type]) Config.forcedprefixes[type] = []; + if (Config.forcedprefixes[type].includes(prefix)) { + throw new Chat.ErrorMessage(`Username prefix '${prefix}' is already configured to force ${type}.`); + } + + Config.forcedprefixes[type].push(prefix); + this.save(); + } + + removePrefix(prefix: ID, type: 'privacy' | 'modchat') { + if (!Config.forcedprefixes[type]?.includes(prefix)) { + throw new Chat.ErrorMessage(`Username prefix '${prefix}' is not configured to force ${type}!`); + } + + Config.forcedprefixes[type] = Config.forcedprefixes[type].filter((curPrefix: ID) => curPrefix !== prefix); + this.save(); + } + + validateType(type: string) { + if (type !== 'privacy' && type !== 'modchat') { + throw new Chat.ErrorMessage(`'${type}' is not a valid type of forced prefix. Valid types are 'privacy' and 'modchat'.`); + } + return type; + } +} + +export const prefixManager = new PrefixManager(); + +export const commands: Chat.ChatCommands = { + forcedprefix: 'usernameprefix', + forcedprefixes: 'usernameprefix', + usernameprefixes: 'usernameprefix', + usernameprefix: { + help: '', + ''() { + this.parse(`/help forcedprefix`); + }, + + delete: 'add', + remove: 'add', + add(target, room, user, connection, cmd) { + this.checkCan('rangeban'); + + const isAdding = cmd.includes('add'); + + const [prefix, type] = target.split(',').map(toID); + if (!prefix || !type) return this.parse(`/help usernameprefix`); + if (prefix.length > 18) { + throw new Chat.ErrorMessage(`Specified prefix '${prefix}' is longer than the maximum user ID length.`); + } + + if (isAdding) { + prefixManager.addPrefix(prefix, prefixManager.validateType(type)); + } else { + prefixManager.removePrefix(prefix, prefixManager.validateType(type)); + } + + this.globalModlog(`FORCEDPREFIX ${isAdding ? 'ADD' : 'REMOVE'}`, null, `'${prefix}' ${isAdding ? 'to' : 'from'} ${type}`); + this.addGlobalModAction(`${user.name} set the username prefix ${prefix} to${isAdding ? '' : ' no longer'} disable ${type}.`); + }, + + view(target) { + this.checkCan('rangeban'); + + const types = target ? [prefixManager.validateType(toID(target))] : ['privacy', 'modchat']; + + return this.sendReplyBox(types.map(type => { + const info = Config.forcedprefixes[type].length ? + `${Config.forcedprefixes[type].join(', ')}` : `none`; + return `Username prefixes that disable ${type}: ${info}.`; + }).join(`
`)); + }, + }, + usernameprefixhelp() { + return this.sendReplyBox( + `/usernameprefix add [prefix], [type]: Sets the username prefix [prefix] to disable privacy or modchat on battles where at least one player has the prefix.
` + + `/usernameprefix remove [prefix], [type]: Removes a prefix configuration.
` + + `/usernameprefix view [optional type]: Displays the currently configured username prefixes.
` + + `Valid types are privacy (which forces battles to take place in public rooms) and modchat (which prevents players from setting moderated chat).
` + + `Requires: &` + ); + }, +}; diff --git a/server/index.ts b/server/index.ts index eaa0a2e30a..e6a73d7fd3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -90,6 +90,9 @@ if (Config.watchconfig) { FS(require.resolve('../config/config')).onModify(() => { try { global.Config = ConfigLoader.load(true); + // ensure that battle prefixes configured via the chat plugin are not overwritten + // by battle prefixes manually specified in config.js + Chat.plugins['username-prefixes']?.prefixManager.refreshConfig(true); Monitor.notice('Reloaded ../config/config.js'); } catch (e) { Monitor.adminlog("Error reloading ../config/config.js: " + e.stack); diff --git a/test/server/chat-plugins/username-prefixes.js b/test/server/chat-plugins/username-prefixes.js new file mode 100644 index 0000000000..58f2e1ced5 --- /dev/null +++ b/test/server/chat-plugins/username-prefixes.js @@ -0,0 +1,44 @@ +/** + * Tests for the username-prefixes chat plugin. + * @author Annika + */ +'use strict'; + +const assert = require('assert').strict; +const {PrefixManager} = require('../../../.server-dist/chat-plugins/username-prefixes'); + +describe('PrefixManager', () => { + beforeEach(() => { + this.prefixManager = new PrefixManager(); + Config.forcedprefixes = {privacy: [], modchat: []}; + }); + + it('Config.forcedprefixes should reflect prefix additions and removals', () => { + this.prefixManager.addPrefix('forcedpublic', 'privacy'); + this.prefixManager.addPrefix('nomodchat', 'modchat'); + + assert(Config.forcedprefixes.privacy.includes('forcedpublic')); + assert(Config.forcedprefixes.modchat.includes('nomodchat')); + + this.prefixManager.removePrefix('forcedpublic', 'privacy'); + this.prefixManager.removePrefix('nomodchat', 'modchat'); + + assert(!Config.forcedprefixes.privacy.includes('forcedpublic')); + assert(!Config.forcedprefixes.modchat.includes('nomodchat')); + }); + + it('should not overwrite manually specified prefixes', () => { + Config.forcedprefixes.modchat = ['manual']; + this.prefixManager.addPrefix('nomodchat', 'modchat'); + + assert.deepEqual(Config.forcedprefixes.modchat, ['manual', 'nomodchat']); + }); + + it('should correctly validate prefix types', () => { + assert.doesNotThrow(() => this.prefixManager.validateType('privacy')); + assert.doesNotThrow(() => this.prefixManager.validateType('modchat')); + + assert.throws(() => this.prefixManager.validateType('gibberish')); + assert.throws(() => this.prefixManager.validateType('')); + }); +});