diff --git a/server/src/services/audioLevelsService/gainMath.js b/server/src/services/audioLevelsService/gainMath.js new file mode 100644 index 00000000..aeecfa25 --- /dev/null +++ b/server/src/services/audioLevelsService/gainMath.js @@ -0,0 +1,79 @@ +// audio Levels Gain Math +// Purpose: Holds the pure clamping and ceiling rules shared by every gain layer. +// Scope: No IO, no state; keeps the volume policy independently reviewable and testable. + +/* + The three gain keys are the same on every layer of this feature: the global + admin gains, the admin-editable VIP boost caps, and each user's personal + preference. Iterating one list keeps those layers from drifting apart. +*/ +const GAIN_KEYS = ['hornGain', 'ttsGain', 'forwardGain']; + +// Absolute gain limits accepted anywhere a multiplier is stored. +const MIN_GAIN = 0; +const MAX_GAIN = 4; + +function clampGain(value, fallback = 1) { + const num = Number(value); + if (!Number.isFinite(num)) return fallback; + return Math.max(MIN_GAIN, Math.min(MAX_GAIN, num)); +} + +function clampFraction(value, fallback = 1) { + const num = Number(value); + if (!Number.isFinite(num)) return fallback; + return Math.max(0, Math.min(1, num)); +} + +function normalizeUserGains(raw = {}) { + const out = {}; + GAIN_KEYS.forEach((key) => { + out[key] = clampFraction(raw?.[key], 1); + }); + return out; +} + +function normalizeGainSet(raw = {}, fallback = {}) { + const out = {}; + GAIN_KEYS.forEach((key) => { + out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1)); + }); + return out; +} + +/* + A user without the boost flag can never exceed the global admin gain. The flag + raises the ceiling to the admin-managed hard cap, and Math.max keeps the flag + from ever being a downgrade: if an admin runs the global gain higher than the + boost cap, a boosted user keeps the global ceiling instead of losing volume + for holding a permission. +*/ +function resolveCeilings({ adminLimits = {}, boostCaps = {}, hasBoost = false } = {}) { + const out = {}; + GAIN_KEYS.forEach((key) => { + const adminCeiling = clampGain(adminLimits?.[key], 0); + out[key] = hasBoost ? Math.max(adminCeiling, clampGain(boostCaps?.[key], 0)) : adminCeiling; + }); + return out; +} + +// Personal preferences are fractions of whichever ceiling applies to the user. +function applyCeilings(fractions = {}, ceilings = {}) { + const out = {}; + GAIN_KEYS.forEach((key) => { + out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0); + }); + return out; +} + +module.exports = { + GAIN_KEYS, + MIN_GAIN, + MAX_GAIN, + clampGain, + clampFraction, + normalizeUserGains, + normalizeGainSet, + resolveCeilings, + applyCeilings, +}; diff --git a/server/src/services/audioLevelsService/gainMath.test.js b/server/src/services/audioLevelsService/gainMath.test.js new file mode 100644 index 00000000..3ad9af6d --- /dev/null +++ b/server/src/services/audioLevelsService/gainMath.test.js @@ -0,0 +1,85 @@ +// audio Levels Gain Math Tests +// Purpose: Pins the ceiling rules that keep user volume inside admin limits. +// Scope: Pure math only; no store, socket, or rover involvement. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + clampFraction, + clampGain, + normalizeUserGains, + normalizeGainSet, + resolveCeilings, + applyCeilings, +} = require('./gainMath'); + +const ADMIN_LIMITS = { hornGain: 0.3, ttsGain: 0.2, forwardGain: 0.1 }; +const BOOST_CAPS = { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }; + +test('an unboosted user is capped by the global admin gains', () => { + const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: false }); + assert.deepEqual(ceilings, ADMIN_LIMITS); +}); + +test('the boost flag raises the ceiling to the hard caps', () => { + const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: true }); + assert.deepEqual(ceilings, BOOST_CAPS); +}); + +test('the boost flag never lowers a ceiling when admin gains exceed the caps', () => { + const loud = { hornGain: 2, ttsGain: 1.5, forwardGain: 3 }; + const ceilings = resolveCeilings({ adminLimits: loud, boostCaps: BOOST_CAPS, hasBoost: true }); + assert.deepEqual(ceilings, loud); +}); + +test('a full personal slider resolves to exactly the ceiling', () => { + const effective = applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ADMIN_LIMITS); + assert.deepEqual(effective, ADMIN_LIMITS); +}); + +test('a personal slider scales the ceiling rather than replacing it', () => { + const effective = applyCeilings({ hornGain: 0.5, ttsGain: 0.5, forwardGain: 0.5 }, BOOST_CAPS); + assert.deepEqual(effective, { hornGain: 0.25, ttsGain: 0.4, forwardGain: 0.2 }); +}); + +test('an out-of-range personal value cannot escape the ceiling', () => { + const effective = applyCeilings({ hornGain: 12, ttsGain: -4, forwardGain: 'loud' }, ADMIN_LIMITS); + assert.equal(effective.hornGain, ADMIN_LIMITS.hornGain); + assert.equal(effective.ttsGain, 0); + // A non-numeric value falls back to the full slider, still bounded by the ceiling. + assert.equal(effective.forwardGain, ADMIN_LIMITS.forwardGain); +}); + +test('a zero admin gain silences even a boosted user at full slider', () => { + const ceilings = resolveCeilings({ + adminLimits: { hornGain: 0, ttsGain: 0, forwardGain: 0 }, + boostCaps: { hornGain: 0, ttsGain: 0, forwardGain: 0 }, + hasBoost: true, + }); + assert.deepEqual(applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ceilings), { + hornGain: 0, + ttsGain: 0, + forwardGain: 0, + }); +}); + +test('personal values normalize into the 0..1 range with a full-volume default', () => { + assert.deepEqual(normalizeUserGains({ hornGain: 0.25, ttsGain: 9 }), { + hornGain: 0.25, + ttsGain: 1, + forwardGain: 1, + }); +}); + +test('gain sets normalize into the 0..4 range and fall back per key', () => { + assert.deepEqual(normalizeGainSet({ hornGain: 9, ttsGain: 'x' }, BOOST_CAPS), { + hornGain: 4, + ttsGain: BOOST_CAPS.ttsGain, + forwardGain: BOOST_CAPS.forwardGain, + }); +}); + +test('clamps reject non-finite input by returning the supplied fallback', () => { + assert.equal(clampGain(Number.NaN, 0.7), 0.7); + assert.equal(clampGain(Infinity, 0.7), 0.7); + assert.equal(clampFraction(undefined, 0.4), 0.4); +}); diff --git a/server/src/services/audioLevelsService/index.js b/server/src/services/audioLevelsService/index.js index 649f2493..e8af8f5a 100644 --- a/server/src/services/audioLevelsService/index.js +++ b/server/src/services/audioLevelsService/index.js @@ -11,6 +11,15 @@ const { isAdmin } = require('../roleService'); const roverManager = require('../roverManager'); const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService'); const { issueCommand } = require('../commandService'); +const { + GAIN_KEYS, + clampGain, + clampFraction, + normalizeUserGains, + normalizeGainSet, + resolveCeilings, + applyCeilings, +} = require('./gainMath'); const audioLevelsEvents = new EventEmitter(); const DATA_DIR = resolveDataDir(); @@ -19,13 +28,6 @@ const config = loadConfig(); const configuredDefaults = config.audioLevels || {}; const configuredUserCaps = configuredDefaults.userGainCaps || {}; -/* - The three gain keys are the same on every layer of this feature: the global - admin gains, the admin-editable VIP boost caps, and each user's personal - preference. Iterating one list keeps those layers from drifting apart. -*/ -const GAIN_KEYS = ['hornGain', 'ttsGain', 'forwardGain']; - /* Per-user preferences live in identity feature state so they follow the user across browsers and cannot be raised by editing a client-side cookie. They are @@ -45,35 +47,15 @@ const USER_GAIN_CAP_DEFAULTS = { forwardGain: 0.4, }; -function clampGain(value, fallback = 1) { - const num = Number(value); - if (!Number.isFinite(num)) return fallback; - return Math.max(0, Math.min(4, num)); -} - -function clampFraction(value, fallback = 1) { - const num = Number(value); - if (!Number.isFinite(num)) return fallback; - return Math.max(0, Math.min(1, num)); -} - const DEFAULTS = { hornGain: clampGain(configuredDefaults.hornGain, 1), ttsGain: clampGain(configuredDefaults.ttsGain, 1), forwardGain: clampGain(configuredDefaults.forwardGain, 1), - userGainCaps: { - hornGain: clampGain(configuredUserCaps.hornGain, USER_GAIN_CAP_DEFAULTS.hornGain), - ttsGain: clampGain(configuredUserCaps.ttsGain, USER_GAIN_CAP_DEFAULTS.ttsGain), - forwardGain: clampGain(configuredUserCaps.forwardGain, USER_GAIN_CAP_DEFAULTS.forwardGain), - }, + userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS), }; function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) { - return { - hornGain: clampGain(raw?.hornGain, fallback.hornGain), - ttsGain: clampGain(raw?.ttsGain, fallback.ttsGain), - forwardGain: clampGain(raw?.forwardGain, fallback.forwardGain), - }; + return normalizeGainSet(raw, fallback); } function normalizeStore(raw = {}) { @@ -141,36 +123,28 @@ function emitChange(reason = 'update', extra = {}) { }); } -/* - Ceiling resolution. A user without the boost flag can never exceed the global - admin gain. The flag raises the ceiling to the admin-managed hard cap, and - Math.max keeps the flag from ever being a downgrade: if an admin runs the - global gain higher than the boost cap, a boosted user simply keeps the global - ceiling instead of losing volume for holding a permission. -*/ +function getAdminLimits() { + const current = loadState(); + return { + hornGain: current.hornGain, + ttsGain: current.ttsGain, + forwardGain: current.forwardGain, + }; +} + function getGainCeilings(hasBoost) { const current = loadState(); - const caps = current.userGainCaps; - const ceilings = {}; - GAIN_KEYS.forEach((key) => { - const adminCeiling = clampGain(current[key], 0); - ceilings[key] = hasBoost ? Math.max(adminCeiling, clampGain(caps[key], 0)) : adminCeiling; + return resolveCeilings({ + adminLimits: getAdminLimits(), + boostCaps: current.userGainCaps, + hasBoost, }); - return ceilings; } function getGainCeilingsForSocket(socket) { return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost)); } -function normalizeUserGains(raw = {}) { - const out = {}; - GAIN_KEYS.forEach((key) => { - out[key] = clampFraction(raw?.[key], 1); - }); - return out; -} - function getUserGains(userId) { if (!userId) return normalizeUserGains({}); return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {})); @@ -180,14 +154,6 @@ function getUserGainsForSocket(socket) { return getUserGains(getUserIdForSocket(socket)); } -function applyCeilings(fractions, ceilings) { - const out = {}; - GAIN_KEYS.forEach((key) => { - out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0); - }); - return out; -} - function getEffectiveLevelsForSocket(socket) { return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket)); } @@ -222,15 +188,7 @@ function resolveAudioOwnerSocket(roverId) { function resolveLevelsForRover(roverId) { const owner = resolveAudioOwnerSocket(roverId); - if (!owner) { - const current = loadState(); - return { - hornGain: current.hornGain, - ttsGain: current.ttsGain, - forwardGain: current.forwardGain, - }; - } - return getEffectiveLevelsForSocket(owner); + return owner ? getEffectiveLevelsForSocket(owner) : getAdminLimits(); } function pushLevelsToRover(roverId) { @@ -315,7 +273,6 @@ function setUserGains(socket, input = {}) { so the UI can show what the rover will actually play. */ function getAudioGainStateForSocket(socket) { - const current = loadState(); const hasBoost = Boolean(socket?.data?.hasAudioGainBoost); const values = getUserGainsForSocket(socket); const ceilings = getGainCeilings(hasBoost); @@ -324,12 +281,8 @@ function getAudioGainStateForSocket(socket) { ceilings, effective: applyCeilings(values, ceilings), boostGranted: hasBoost, - adminLimits: { - hornGain: current.hornGain, - ttsGain: current.ttsGain, - forwardGain: current.forwardGain, - }, - boostCaps: { ...current.userGainCaps }, + adminLimits: getAdminLimits(), + boostCaps: getUserGainCaps(), }; } diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index 1776282d..8c15d39b 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -22,6 +22,9 @@ const { undeterUser, muteUser, unmuteUser, + listAudioGainBoostUsers, + grantAudioGainBoost, + revokeAudioGainBoost, } = require('../verificationService'); const { publishEvent } = require('../eventBus'); const assignmentService = require('../assignmentService'); @@ -184,6 +187,9 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) { undeterUser, muteUser, unmuteUser, + listAudioGainBoostUsers, + grantAudioGainBoost, + revokeAudioGainBoost, sanitizeMentions, sendToChannel: null, isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index fa2ae6ac..7a671993 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -46,6 +46,9 @@ const { undeterUser, muteUser, unmuteUser, + listAudioGainBoostUsers, + grantAudioGainBoost, + revokeAudioGainBoost, } = require('../verificationService'); const { attachDmMessage: attachPrivateAccessDmMessage, @@ -250,6 +253,9 @@ const commandDependencies = { undeterUser, muteUser, unmuteUser, + listAudioGainBoostUsers, + grantAudioGainBoost, + revokeAudioGainBoost, sanitizeMentions, sendToChannel: channelIO.sendToChannel, isAdminUser, diff --git a/server/src/services/operatorCommandService/commands/gain.js b/server/src/services/operatorCommandService/commands/gain.js new file mode 100644 index 00000000..7b2a96ac --- /dev/null +++ b/server/src/services/operatorCommandService/commands/gain.js @@ -0,0 +1,87 @@ +// Operator Gain Command +// Purpose: Handles the audio gain boost permission for VIPs. +// Scope: Supports list, grant, and revoke subcommands; resolution stays VIP-only. +const { mask, resolveIdentitySelector } = require('./resolvers'); +const { getCommandConfig } = require('../../operatorCommandService/config'); + +function createGainCommand({ + listVerifiedUsers, + listAudioGainBoostUsers, + grantAudioGainBoost, + revokeAudioGainBoost, + sanitizeMentions, + config, +}) { + // Usage text comes from the same core prefix that both transports parse. + const { prefix: commandPrefix } = getCommandConfig(config); + const plain = { parse: [], repliedUser: false }; + + function usage(subcommand) { + return `Usage: \`${commandPrefix} gain ${subcommand} \``; + } + + /* + The boost is a VIP-only permission, so candidate matching runs against the + verified list rather than every known identity. A nickname that only belongs + to an unverified visitor therefore reports "not found" instead of resolving + to someone who cannot hold the flag anyway. + */ + async function applyBoost(message, tokens, enabled) { + const selector = tokens.join(' ').trim(); + if (!selector) { + return message.reply({ content: usage(enabled ? 'grant' : 'revoke'), allowedMentions: plain }); + } + const candidates = enabled ? listVerifiedUsers() : listAudioGainBoostUsers(); + const resolved = resolveIdentitySelector(selector, candidates, { includeId: false }); + if (resolved.error) { + return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: plain }); + } + const target = resolved.record.userId || resolved.record.id || resolved.record.cookieUserId; + try { + const actor = message.actor?.id || null; + const user = enabled ? grantAudioGainBoost(target, actor) : revokeAudioGainBoost(target, actor); + const verb = enabled ? 'Granted' : 'Revoked'; + return message.reply({ + content: sanitizeMentions(`${verb} audio gain boost for ${user.nickname || 'unknown'} (${mask(user.cookieUserId)}).`), + allowedMentions: plain, + }); + } catch (err) { + return message.reply({ + content: sanitizeMentions(`Failed to update audio gain boost: ${err.message}`), + allowedMentions: plain, + }); + } + } + + return async function handleGainCommand(message, tokens) { + if (!message.actor?.isAdmin) { + await message.reply({ content: 'Only admins can manage audio gain boosts.', allowedMentions: plain }); + return; + } + const action = (tokens.shift() || 'list').toLowerCase(); + + if (action === 'list') { + const users = listAudioGainBoostUsers(); + if (!users.length) { + return message.reply({ content: 'No users hold an audio gain boost.', allowedMentions: plain }); + } + const lines = users.map((entry, idx) => ( + `${idx + 1}. ${entry.nickname || 'unknown'} | ${entry.userId || entry.id} | ${mask(entry.cookieUserId)}` + )); + return message.reply({ + content: sanitizeMentions(['Audio gain boost holders:', ...lines].join('\n').slice(0, 1900)), + allowedMentions: plain, + }); + } + + if (action === 'grant') return applyBoost(message, tokens, true); + if (action === 'revoke') return applyBoost(message, tokens, false); + + return message.reply({ + content: `Unknown gain command. Use \`${commandPrefix} gain list\`, \`${commandPrefix} gain grant \`, or \`${commandPrefix} gain revoke \`.`, + allowedMentions: plain, + }); + }; +} + +module.exports = { createGainCommand }; diff --git a/server/src/services/operatorCommandService/commands/gain.test.js b/server/src/services/operatorCommandService/commands/gain.test.js new file mode 100644 index 00000000..d4ff6e4e --- /dev/null +++ b/server/src/services/operatorCommandService/commands/gain.test.js @@ -0,0 +1,125 @@ +// Operator Gain Command Tests +// Purpose: Verifies the audio gain boost command stays admin-only and VIP-only. +// Scope: Exercises command target resolution with in-memory identity doubles. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createGainCommand } = require('./gain'); + +const VIPS = [ + { userId: 'usr-vip', nickname: 'Croissant', cookieUserId: 'cookie-croissant' }, + { userId: 'usr-other', nickname: 'Baguette', cookieUserId: 'cookie-baguette' }, +]; + +function createHarness({ verified = VIPS, boosted = [], isAdmin = true } = {}) { + const calls = []; + const replies = []; + const handler = createGainCommand({ + listVerifiedUsers: () => verified, + listAudioGainBoostUsers: () => boosted, + grantAudioGainBoost: (selector, actor) => { + calls.push({ action: 'grant', selector, actor }); + return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' }; + }, + revokeAudioGainBoost: (selector, actor) => { + calls.push({ action: 'revoke', selector, actor }); + return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' }; + }, + sanitizeMentions: (value) => value, + config: { commands: { prefix: 'rs' } }, + }); + const message = { + actor: { id: 'admin', isAdmin }, + reply: async (payload) => { + replies.push(payload); + return payload; + }, + }; + return { handler, message, calls, replies }; +} + +test('non-admins cannot manage the boost', async () => { + const { handler, message, calls, replies } = createHarness({ isAdmin: false }); + + await handler(message, ['grant', 'Croissant']); + + assert.deepEqual(calls, []); + assert.match(replies[0].content, /Only admins/); +}); + +test('grant resolves a VIP nickname to its stable user id', async () => { + const { handler, message, calls } = createHarness(); + + await handler(message, ['grant', 'croissant']); + + assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-vip', actor: 'admin' }]); +}); + +test('grant refuses a nickname that belongs to no VIP', async () => { + const { handler, message, calls, replies } = createHarness({ verified: [] }); + + await handler(message, ['grant', 'Stranger']); + + assert.deepEqual(calls, []); + assert.match(replies[0].content, /not found/i); +}); + +test('revoke only matches users who currently hold the boost', async () => { + const { handler, message, calls, replies } = createHarness({ boosted: [] }); + + await handler(message, ['revoke', 'Croissant']); + + assert.deepEqual(calls, []); + assert.match(replies[0].content, /not found/i); +}); + +test('revoke resolves against the boosted list', async () => { + const { handler, message, calls } = createHarness({ boosted: [VIPS[0]] }); + + await handler(message, ['revoke', 'Croissant']); + + assert.deepEqual(calls, [{ action: 'revoke', selector: 'usr-vip', actor: 'admin' }]); +}); + +test('grant without a target prints usage instead of acting', async () => { + const { handler, message, calls, replies } = createHarness(); + + await handler(message, ['grant']); + + assert.deepEqual(calls, []); + assert.match(replies[0].content, /rs gain grant/); +}); + +test('list defaults when no subcommand is given', async () => { + const { handler, message, replies } = createHarness({ boosted: [VIPS[0]] }); + + await handler(message, []); + + assert.match(replies[0].content, /Croissant/); + assert.match(replies[0].content, /usr-vip/); +}); + +test('list reports an empty holder set', async () => { + const { handler, message, replies } = createHarness({ boosted: [] }); + + await handler(message, ['list']); + + assert.match(replies[0].content, /No users hold/); +}); + +test('a service rejection is surfaced instead of thrown', async () => { + const { handler, message, replies } = createHarness(); + const failing = createGainCommand({ + listVerifiedUsers: () => VIPS, + listAudioGainBoostUsers: () => [], + grantAudioGainBoost: () => { + throw new Error('Only verified VIPs can be granted an audio gain boost.'); + }, + revokeAudioGainBoost: () => null, + sanitizeMentions: (value) => value, + config: { commands: { prefix: 'rs' } }, + }); + + await failing(message, ['grant', 'Croissant']); + + assert.match(replies[0].content, /Only verified VIPs/); +}); diff --git a/server/src/services/operatorCommandService/index.js b/server/src/services/operatorCommandService/index.js index 8a0b63d5..df6aa2c6 100644 --- a/server/src/services/operatorCommandService/index.js +++ b/server/src/services/operatorCommandService/index.js @@ -8,6 +8,7 @@ const { createReasonCommand } = require('./commands/reason'); const { createGoalCommand } = require('./commands/goal'); const { createVerifyCommand } = require('./commands/verify'); const { createDeterCommand } = require('./commands/deter'); +const { createGainCommand } = require('./commands/gain'); const { createLightsCommand } = require('./commands/lights'); const { createKickCommand } = require('./commands/kick'); const { createLiftCommand } = require('./commands/lift'); @@ -47,6 +48,7 @@ function createCommandHandlers(deps) { const handleGoalCommand = createGoalCommand(deps); const handleVerifyCommand = createVerifyCommand(deps); const handleDeterCommand = createDeterCommand(deps); + const handleGainCommand = createGainCommand(deps); const handleBridgeCommand = transportHandlers.bridge; const handleTimeStatusCommand = transportHandlers.timeStatus; const handleLightsCommand = createLightsCommand(deps); @@ -97,7 +99,7 @@ function createCommandHandlers(deps) { // is included because its lock/unlock subcommands change room policy. Its // ordinary on/off/color actions are also intentionally restricted to a // lockdown admin while the entire server is in lockdown. - const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']); + const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'gain', 'lights', 'kick', 'lift', 'neato']); const isAccessModeCommand = commandDefinition?.permission === 'access-mode'; // Feature commands are public activities while access is open or managed @@ -153,6 +155,8 @@ function createCommandHandlers(deps) { return handleVerifyCommand(request, tokens); case 'deter': return handleDeterCommand(request, tokens); + case 'gain': + return handleGainCommand(request, tokens); default: return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled })); } diff --git a/server/src/services/operatorCommandService/registry.js b/server/src/services/operatorCommandService/registry.js index 883dcd28..c33e1256 100644 --- a/server/src/services/operatorCommandService/registry.js +++ b/server/src/services/operatorCommandService/registry.js @@ -3,7 +3,7 @@ // Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations. const CATEGORIES = { system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] }, - admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter'] }, + admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'gain'] }, features: { title: 'Features', names: ['lights', 'lift', 'neato'] }, discord: { title: 'Discord', names: ['bridge'] }, }; @@ -46,6 +46,17 @@ function buildCommandRegistry(prefix, timeCommand) { access: 'Lockdown admin', permission: 'lockdown-admin', }, + gain: { + category: 'admin', + summary: 'Manage the VIP audio gain boost that raises a user\'s volume ceiling past the global gains.', + usage: [ + `${prefix} gain list`, + `${prefix} gain grant `, + `${prefix} gain revoke `, + ], + access: 'Admin', + permission: 'admin', + }, lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift `], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' }, neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato `], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' }, bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here `, `${prefix} bridge mode `, `${prefix} bridge off`], access: 'Discord server manager' },