mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
feat(commands): add 'rs gain' to grant the VIP audio gain boost
Admins can now run 'rs gain list|grant <vip>|revoke <vip>' from web chat or Discord. Grant matches only against the verified list and revoke only against current holders, so a nickname shared with an unverified visitor reports not-found rather than resolving to someone ineligible. The action joins the moderation set so lockdown narrows it to lockdown admins. Also extracts the ceiling math into audioLevelsService/gainMath.js and covers both it and the command with node:test suites. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -11,6 +11,15 @@ const { isAdmin } = require('../roleService');
|
|||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
|
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
|
||||||
const { issueCommand } = require('../commandService');
|
const { issueCommand } = require('../commandService');
|
||||||
|
const {
|
||||||
|
GAIN_KEYS,
|
||||||
|
clampGain,
|
||||||
|
clampFraction,
|
||||||
|
normalizeUserGains,
|
||||||
|
normalizeGainSet,
|
||||||
|
resolveCeilings,
|
||||||
|
applyCeilings,
|
||||||
|
} = require('./gainMath');
|
||||||
|
|
||||||
const audioLevelsEvents = new EventEmitter();
|
const audioLevelsEvents = new EventEmitter();
|
||||||
const DATA_DIR = resolveDataDir();
|
const DATA_DIR = resolveDataDir();
|
||||||
@@ -19,13 +28,6 @@ const config = loadConfig();
|
|||||||
const configuredDefaults = config.audioLevels || {};
|
const configuredDefaults = config.audioLevels || {};
|
||||||
const configuredUserCaps = configuredDefaults.userGainCaps || {};
|
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
|
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
|
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,
|
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 = {
|
const DEFAULTS = {
|
||||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||||
userGainCaps: {
|
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
|
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
|
||||||
return {
|
return normalizeGainSet(raw, fallback);
|
||||||
hornGain: clampGain(raw?.hornGain, fallback.hornGain),
|
|
||||||
ttsGain: clampGain(raw?.ttsGain, fallback.ttsGain),
|
|
||||||
forwardGain: clampGain(raw?.forwardGain, fallback.forwardGain),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStore(raw = {}) {
|
function normalizeStore(raw = {}) {
|
||||||
@@ -141,36 +123,28 @@ function emitChange(reason = 'update', extra = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
function getAdminLimits() {
|
||||||
Ceiling resolution. A user without the boost flag can never exceed the global
|
const current = loadState();
|
||||||
admin gain. The flag raises the ceiling to the admin-managed hard cap, and
|
return {
|
||||||
Math.max keeps the flag from ever being a downgrade: if an admin runs the
|
hornGain: current.hornGain,
|
||||||
global gain higher than the boost cap, a boosted user simply keeps the global
|
ttsGain: current.ttsGain,
|
||||||
ceiling instead of losing volume for holding a permission.
|
forwardGain: current.forwardGain,
|
||||||
*/
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getGainCeilings(hasBoost) {
|
function getGainCeilings(hasBoost) {
|
||||||
const current = loadState();
|
const current = loadState();
|
||||||
const caps = current.userGainCaps;
|
return resolveCeilings({
|
||||||
const ceilings = {};
|
adminLimits: getAdminLimits(),
|
||||||
GAIN_KEYS.forEach((key) => {
|
boostCaps: current.userGainCaps,
|
||||||
const adminCeiling = clampGain(current[key], 0);
|
hasBoost,
|
||||||
ceilings[key] = hasBoost ? Math.max(adminCeiling, clampGain(caps[key], 0)) : adminCeiling;
|
|
||||||
});
|
});
|
||||||
return ceilings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGainCeilingsForSocket(socket) {
|
function getGainCeilingsForSocket(socket) {
|
||||||
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
|
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) {
|
function getUserGains(userId) {
|
||||||
if (!userId) return normalizeUserGains({});
|
if (!userId) return normalizeUserGains({});
|
||||||
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
|
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
|
||||||
@@ -180,14 +154,6 @@ function getUserGainsForSocket(socket) {
|
|||||||
return getUserGains(getUserIdForSocket(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) {
|
function getEffectiveLevelsForSocket(socket) {
|
||||||
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
|
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
|
||||||
}
|
}
|
||||||
@@ -222,15 +188,7 @@ function resolveAudioOwnerSocket(roverId) {
|
|||||||
|
|
||||||
function resolveLevelsForRover(roverId) {
|
function resolveLevelsForRover(roverId) {
|
||||||
const owner = resolveAudioOwnerSocket(roverId);
|
const owner = resolveAudioOwnerSocket(roverId);
|
||||||
if (!owner) {
|
return owner ? getEffectiveLevelsForSocket(owner) : getAdminLimits();
|
||||||
const current = loadState();
|
|
||||||
return {
|
|
||||||
hornGain: current.hornGain,
|
|
||||||
ttsGain: current.ttsGain,
|
|
||||||
forwardGain: current.forwardGain,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return getEffectiveLevelsForSocket(owner);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushLevelsToRover(roverId) {
|
function pushLevelsToRover(roverId) {
|
||||||
@@ -315,7 +273,6 @@ function setUserGains(socket, input = {}) {
|
|||||||
so the UI can show what the rover will actually play.
|
so the UI can show what the rover will actually play.
|
||||||
*/
|
*/
|
||||||
function getAudioGainStateForSocket(socket) {
|
function getAudioGainStateForSocket(socket) {
|
||||||
const current = loadState();
|
|
||||||
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
|
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
|
||||||
const values = getUserGainsForSocket(socket);
|
const values = getUserGainsForSocket(socket);
|
||||||
const ceilings = getGainCeilings(hasBoost);
|
const ceilings = getGainCeilings(hasBoost);
|
||||||
@@ -324,12 +281,8 @@ function getAudioGainStateForSocket(socket) {
|
|||||||
ceilings,
|
ceilings,
|
||||||
effective: applyCeilings(values, ceilings),
|
effective: applyCeilings(values, ceilings),
|
||||||
boostGranted: hasBoost,
|
boostGranted: hasBoost,
|
||||||
adminLimits: {
|
adminLimits: getAdminLimits(),
|
||||||
hornGain: current.hornGain,
|
boostCaps: getUserGainCaps(),
|
||||||
ttsGain: current.ttsGain,
|
|
||||||
forwardGain: current.forwardGain,
|
|
||||||
},
|
|
||||||
boostCaps: { ...current.userGainCaps },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
@@ -184,6 +187,9 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: null,
|
sendToChannel: null,
|
||||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ const {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
const {
|
const {
|
||||||
attachDmMessage: attachPrivateAccessDmMessage,
|
attachDmMessage: attachPrivateAccessDmMessage,
|
||||||
@@ -250,6 +253,9 @@ const commandDependencies = {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: channelIO.sendToChannel,
|
sendToChannel: channelIO.sendToChannel,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
|
|||||||
@@ -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} <nickname|userId|cookieUserId>\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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 <vip>\`, or \`${commandPrefix} gain revoke <vip>\`.`,
|
||||||
|
allowedMentions: plain,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createGainCommand };
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ const { createReasonCommand } = require('./commands/reason');
|
|||||||
const { createGoalCommand } = require('./commands/goal');
|
const { createGoalCommand } = require('./commands/goal');
|
||||||
const { createVerifyCommand } = require('./commands/verify');
|
const { createVerifyCommand } = require('./commands/verify');
|
||||||
const { createDeterCommand } = require('./commands/deter');
|
const { createDeterCommand } = require('./commands/deter');
|
||||||
|
const { createGainCommand } = require('./commands/gain');
|
||||||
const { createLightsCommand } = require('./commands/lights');
|
const { createLightsCommand } = require('./commands/lights');
|
||||||
const { createKickCommand } = require('./commands/kick');
|
const { createKickCommand } = require('./commands/kick');
|
||||||
const { createLiftCommand } = require('./commands/lift');
|
const { createLiftCommand } = require('./commands/lift');
|
||||||
@@ -47,6 +48,7 @@ function createCommandHandlers(deps) {
|
|||||||
const handleGoalCommand = createGoalCommand(deps);
|
const handleGoalCommand = createGoalCommand(deps);
|
||||||
const handleVerifyCommand = createVerifyCommand(deps);
|
const handleVerifyCommand = createVerifyCommand(deps);
|
||||||
const handleDeterCommand = createDeterCommand(deps);
|
const handleDeterCommand = createDeterCommand(deps);
|
||||||
|
const handleGainCommand = createGainCommand(deps);
|
||||||
const handleBridgeCommand = transportHandlers.bridge;
|
const handleBridgeCommand = transportHandlers.bridge;
|
||||||
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
||||||
const handleLightsCommand = createLightsCommand(deps);
|
const handleLightsCommand = createLightsCommand(deps);
|
||||||
@@ -97,7 +99,7 @@ function createCommandHandlers(deps) {
|
|||||||
// is included because its lock/unlock subcommands change room policy. Its
|
// is included because its lock/unlock subcommands change room policy. Its
|
||||||
// ordinary on/off/color actions are also intentionally restricted to a
|
// ordinary on/off/color actions are also intentionally restricted to a
|
||||||
// lockdown admin while the entire server is in lockdown.
|
// 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';
|
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
|
||||||
|
|
||||||
// Feature commands are public activities while access is open or managed
|
// Feature commands are public activities while access is open or managed
|
||||||
@@ -153,6 +155,8 @@ function createCommandHandlers(deps) {
|
|||||||
return handleVerifyCommand(request, tokens);
|
return handleVerifyCommand(request, tokens);
|
||||||
case 'deter':
|
case 'deter':
|
||||||
return handleDeterCommand(request, tokens);
|
return handleDeterCommand(request, tokens);
|
||||||
|
case 'gain':
|
||||||
|
return handleGainCommand(request, tokens);
|
||||||
default:
|
default:
|
||||||
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
|
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
||||||
const CATEGORIES = {
|
const CATEGORIES = {
|
||||||
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
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'] },
|
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
|
||||||
discord: { title: 'Discord', names: ['bridge'] },
|
discord: { title: 'Discord', names: ['bridge'] },
|
||||||
};
|
};
|
||||||
@@ -46,6 +46,17 @@ function buildCommandRegistry(prefix, timeCommand) {
|
|||||||
access: 'Lockdown admin',
|
access: 'Lockdown admin',
|
||||||
permission: '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 <vip>`,
|
||||||
|
`${prefix} gain revoke <vip>`,
|
||||||
|
],
|
||||||
|
access: 'Admin',
|
||||||
|
permission: 'admin',
|
||||||
|
},
|
||||||
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
|
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], 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 <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
|
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], 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 <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
|
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
|
||||||
|
|||||||
Reference in New Issue
Block a user