gruh i hate fun!!

This commit is contained in:
legop3
2026-08-07 15:54:28 -04:00
parent a55257dd51
commit 9702cf0f82
27 changed files with 67 additions and 2628 deletions
+1
View File
@@ -21,6 +21,7 @@ server/data/admin-reason.json
server/data/buttonbox-state.json server/data/buttonbox-state.json
server/data/barcode-tts-cache/ server/data/barcode-tts-cache/
server/data/rover-odometers.json server/data/rover-odometers.json
server/data/mediamtx.yml
webui/package-lock.json webui/package-lock.json
!server/data/ !server/data/
!server/data/barcode-registry.json !server/data/barcode-registry.json
-2
View File
@@ -300,8 +300,6 @@ Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
RuntimeDirectory=multirover
RuntimeDirectoryMode=0750
ExecStart=$NODE_BIN $SERVER_DIR/index.js ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
@@ -1,55 +0,0 @@
// audio Forward Service bonk sound
// Purpose: Plays the built-in bonk sound effect on the rover a bonked user is driving.
// Scope: Keeps the fun commands and the audio pipeline decoupled by listening to the server event bus only.
const path = require('path');
const fs = require('fs');
const { subscribe } = require('../eventBus');
/*
Lives in server/assets rather than server/public because the webui build writes
to server/public with emptyOutDir enabled, which deletes anything else in there.
server/assets is a plain checked-in asset directory that no build step touches.
*/
const BONK_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'assets', 'bonk.wav');
function registerBonkSound(deps) {
const {
logger,
playServerAudioFile,
soundPath = BONK_SOUND_PATH,
} = deps;
subscribe('fun.bonked', (event = {}) => {
const roverId = String(event?.payload?.roverId || '').trim();
if (!roverId) return;
/*
The sound is optional. An operator who has not dropped a bonk.wav into
server/assets still gets a fully working `rs bonk` command, so a missing
file is reported once at debug volume rather than thrown at the caller.
*/
if (!fs.existsSync(soundPath)) {
logger.info('Bonk sound file is not installed; skipping playback', { soundPath });
return;
}
try {
playServerAudioFile(roverId, soundPath, { source: 'bonk' });
logger.info('Played bonk sound', { roverId, soundPath });
} catch (err) {
// Playback interrupts mic forwarding and spawns ffmpeg, so an offline rover
// or a missing encoder must not turn into a failed chat command. The bonk
// itself already happened; the sound is layered on top of it.
logger.warn('Failed to play bonk sound', {
roverId,
soundPath,
error: err?.message || String(err),
});
}
});
}
module.exports = {
registerBonkSound,
BONK_SOUND_PATH,
};
@@ -1,84 +0,0 @@
// audio Forward Service bonk sound tests
// Purpose: Verifies the bonk cue plays for a real event and stays contained when the file or rover is missing.
// Scope: Subscribes through the real event bus with a playback double; no ffmpeg runs.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { publishEvent } = require('../eventBus');
const { registerBonkSound, BONK_SOUND_PATH } = require('./bonkSound');
const soundDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bonk-sound-test-'));
const presentSound = path.join(soundDir, 'bonk.wav');
fs.writeFileSync(presentSound, 'not really audio, only the path is read here');
const missingSound = path.join(soundDir, 'absent.wav');
function harness({ soundPath = presentSound, playImpl = null } = {}) {
const played = [];
const warnings = [];
registerBonkSound({
logger: {
info: () => {},
warn: (message, meta) => warnings.push({ message, meta }),
},
playServerAudioFile: (roverId, filePath, options) => {
played.push({ roverId, filePath, options });
if (playImpl) playImpl();
},
soundPath,
});
return { played, warnings };
}
// Each registerBonkSound call adds another subscriber to the shared bus, so every
// test publishes a distinct rover id and asserts only on its own rover.
function bonk(roverId) {
publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId, targetLabel: 'bob' } });
}
test('a bonk event plays the sound on the named rover', () => {
const { played } = harness();
bonk('rover-play');
const mine = played.filter((entry) => entry.roverId === 'rover-play');
assert.equal(mine.length, 1);
assert.equal(mine[0].filePath, presentSound);
assert.equal(mine[0].options.source, 'bonk');
});
test('an event with no rover id is ignored', () => {
const { played } = harness();
publishEvent({ source: 'test', type: 'fun.bonked', payload: {} });
publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId: ' ' } });
assert.equal(played.length, 0);
});
test('a missing sound file skips playback instead of throwing', () => {
const { played, warnings } = harness({ soundPath: missingSound });
assert.doesNotThrow(() => bonk('rover-missing'));
assert.equal(played.filter((entry) => entry.roverId === 'rover-missing').length, 0);
assert.equal(warnings.length, 0, 'a not-installed sound is informational, not a warning');
});
test('a playback failure is contained and logged rather than thrown at the caller', () => {
const { warnings } = harness({
playImpl: () => {
throw new Error('Rover offline');
},
});
assert.doesNotThrow(() => bonk('rover-offline'));
assert.ok(warnings.some((entry) => entry.meta?.error === 'Rover offline'));
});
test('the default sound path lives in server/assets, which the webui build does not wipe', () => {
// webui/vite.config.js builds to ../server/public with emptyOutDir enabled, so a
// sound stored there would be deleted by the next build.
assert.match(BONK_SOUND_PATH, /server\/assets\/bonk\.wav$/);
assert.doesNotMatch(BONK_SOUND_PATH, /server\/public/);
});
test.after(() => {
fs.rmSync(soundDir, { recursive: true, force: true });
});
@@ -14,7 +14,6 @@ const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine'); const { createAudioForwardWorkerEngine } = require('./workerEngine');
const { registerAudioForwardHooks } = require('./hooks'); const { registerAudioForwardHooks } = require('./hooks');
const { registerChargeCompleteSound } = require('./chargeCompleteSound'); const { registerChargeCompleteSound } = require('./chargeCompleteSound');
const { registerBonkSound } = require('./bonkSound');
const audioForwardEvents = new EventEmitter(); const audioForwardEvents = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
@@ -152,11 +151,6 @@ registerChargeCompleteSound({
playServerAudioFile, playServerAudioFile,
}); });
registerBonkSound({
logger,
playServerAudioFile,
});
module.exports = { module.exports = {
getAudioForwardState, getAudioForwardState,
audioForwardEvents, audioForwardEvents,
@@ -28,10 +28,8 @@ const {
} = require('../verificationService'); } = require('../verificationService');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
const funStatsService = require('../funStatsService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { createCommandHandlers } = require('../operatorCommandService'); const { createCommandHandlers } = require('../operatorCommandService');
const { createCooldownGate } = require('../operatorCommandService/cooldowns');
const { parseCommandText } = require('../operatorCommandService/config'); const { parseCommandText } = require('../operatorCommandService/config');
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport'); const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
const { commandReplyToText } = require('./commandResultFormatter'); const { commandReplyToText } = require('./commandResultFormatter');
@@ -44,14 +42,6 @@ const {
const config = loadConfig(); const config = loadConfig();
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
/*
Site chat builds a fresh command router for every message so each router can
close over the sending socket. Fun command cooldowns therefore have to live out
here: a gate created inside the router would be thrown away after one message
and would never actually rate limit anything.
*/
const commandCooldowns = createCooldownGate();
function isTextCommand(text) { function isTextCommand(text) {
return parseCommandText(text, config).matched; return parseCommandText(text, config).matched;
} }
@@ -133,12 +123,6 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) {
actor: { actor: {
bot: false, bot: false,
id: socket.id, id: socket.id,
/*
Fun command tallies are keyed by identity rather than connection, so the
canonical user id is passed alongside the socket id. Without it a user's
bonk count would reset on every reconnect and split across browser tabs.
*/
userId: String(socket?.data?.userId || '').trim() || null,
label: nickname, label: nickname,
isAdmin: isAdmin(socket), isAdmin: isAdmin(socket),
isLockdownAdmin: isLockdownAdmin(socket), isLockdownAdmin: isLockdownAdmin(socket),
@@ -207,19 +191,6 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
grantAudioGainBoost, grantAudioGainBoost,
revokeAudioGainBoost, revokeAudioGainBoost,
sanitizeMentions, sanitizeMentions,
funStatsService,
commandCooldowns,
// Lets `rs bonk` announce itself so audioForwardService can play the bonk
// sound on the rover the target is driving.
publishEvent,
/*
Fun commands that move hardware need the sending socket so they can prove
the caller holds control. issueCommand is required lazily for the same
reason replayEngineV2 is: commandService registers socket handlers on load,
and chatService should not pull that forward in the boot order.
*/
getActorSocket: () => socket,
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
sendToChannel: null, sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
+1 -20
View File
@@ -56,13 +56,10 @@ const {
approveRequest: approvePrivateAccessRequest, approveRequest: approvePrivateAccessRequest,
denyRequest: denyPrivateAccessRequest, denyRequest: denyPrivateAccessRequest,
} = require('../privateRoverAccessRequestService'); } = require('../privateRoverAccessRequestService');
const { subscribe, publishEvent } = require('../eventBus'); const { subscribe } = require('../eventBus');
const funStatsService = require('../funStatsService');
const { issueCommand } = require('../commandService');
const { createPresenceManager } = require('./presence'); const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO'); const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('../operatorCommandService'); const { createCommandHandlers } = require('../operatorCommandService');
const { createCooldownGate } = require('../operatorCommandService/cooldowns');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter'); const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations'); const { createIntegrations } = require('./integrations');
const { createFleetDailyReports } = require('./fleetDailyReports'); const { createFleetDailyReports } = require('./fleetDailyReports');
@@ -212,10 +209,6 @@ if (discordConfig?.channels?.replay) {
}); });
} }
// The Discord router is built once for the process, so one gate here covers every
// guild and channel this bot answers in.
const commandCooldowns = createCooldownGate();
const commandDependencies = { const commandDependencies = {
logger, logger,
client, client,
@@ -264,18 +257,6 @@ const commandDependencies = {
grantAudioGainBoost, grantAudioGainBoost,
revokeAudioGainBoost, revokeAudioGainBoost,
sanitizeMentions, sanitizeMentions,
funStatsService,
commandCooldowns,
// A Discord bonk still plays the sound on the rover the target is driving; only
// the commands that need the caller's own socket are unavailable from here.
publishEvent,
/*
Discord has no socket behind a message, so the hardware-backed fun commands
cannot prove drive control and decline with an explanation instead. The text,
counter, and read-only fun commands work normally from here.
*/
getActorSocket: () => null,
issueCommand,
sendToChannel: channelIO.sendToChannel, sendToChannel: channelIO.sendToChannel,
isAdminUser, isAdminUser,
isLockdownAdminUser, isLockdownAdminUser,
@@ -1,178 +0,0 @@
// Fun Stats Service
// Purpose: Persists the running counters behind the social `rs` fun commands.
// Scope: Owns storage and clamping only; command handlers decide what a counter means.
const fs = require('fs');
const path = require('path');
const logger = require('../../globals/logger').child('funStatsService');
const { resolveDataPath } = require('../../helpers/dataPaths');
const STORE_PATH = resolveDataPath('fun-stats.json');
// Counters are additive and never authoritative for anything but bragging
// rights, so the ceiling only exists to keep a runaway loop from writing an
// unbounded integer into the store.
const MAX_COUNT = 1_000_000;
const MAX_LABEL_LENGTH = 64;
const ACTOR_COUNTERS = [
'bonksGiven',
'bonksTaken',
'hugsGiven',
'hugsTaken',
'slapsGiven',
'slapsTaken',
];
/*
This service deliberately keeps its own tiny JSON store rather than reusing
identityService.createJsonStore. Fun counters are keyed by an actor key that
spans transports (`user:<id>` for site chat, `discord:<id>` for Discord), and
a Discord id has no row in `users`, so it cannot live in `user_feature_state`
without violating that table's foreign key. Keeping storage local also means
the counters can be unit tested without opening the identity database.
*/
let cache = null;
function clampCount(value) {
const count = Number(value);
if (!Number.isFinite(count) || count <= 0) return 0;
return Math.min(Math.floor(count), MAX_COUNT);
}
function normalizeLabel(value) {
const label = String(value || '').trim().replace(/\s+/g, ' ');
if (!label) return null;
return label.slice(0, MAX_LABEL_LENGTH);
}
function normalizeActor(raw = {}) {
const actor = { label: normalizeLabel(raw.label) };
ACTOR_COUNTERS.forEach((key) => {
actor[key] = clampCount(raw[key]);
});
actor.updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : null;
return actor;
}
function normalizeStore(raw = {}) {
const actors = {};
const rawActors = raw && typeof raw.actors === 'object' && raw.actors ? raw.actors : {};
Object.keys(rawActors).forEach((key) => {
const actorKey = String(key || '').trim();
if (!actorKey) return;
actors[actorKey] = normalizeActor(rawActors[actorKey]);
});
const rovers = {};
const rawRovers = raw && typeof raw.rovers === 'object' && raw.rovers ? raw.rovers : {};
Object.keys(rawRovers).forEach((key) => {
const roverId = String(key || '').trim();
if (!roverId) return;
const entry = rawRovers[roverId] || {};
rovers[roverId] = {
pets: clampCount(entry.pets),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : null,
};
});
return { actors, rovers };
}
function loadState() {
if (cache) return cache;
try {
cache = normalizeStore(JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')));
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalizeStore({});
}
return cache;
}
function persistState(next) {
const normalized = normalizeStore(next);
try {
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
} catch (err) {
// A failed write must not break the command that triggered it. The joke
// still lands; only the tally is lost.
logger.warn('Failed to persist fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalized;
return cache;
}
function getActorStats(actorKey) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
return { ...(loadState().actors[key] || normalizeActor({})) };
}
/*
`patch` is a map of counter name to increment. Unknown counter names are
ignored rather than stored so a typo in a handler cannot quietly create a
parallel counter that never shows up on the leaderboard.
*/
function bumpActorStats(actorKey, { label = null, ...patch } = {}) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
const state = loadState();
const current = state.actors[key] || normalizeActor({});
const next = { ...current };
const resolvedLabel = normalizeLabel(label);
if (resolvedLabel) next.label = resolvedLabel;
ACTOR_COUNTERS.forEach((counter) => {
const delta = Number(patch[counter]);
if (!Number.isFinite(delta) || delta === 0) return;
next[counter] = clampCount(current[counter] + delta);
});
next.updatedAt = Date.now();
persistState({ ...state, actors: { ...state.actors, [key]: next } });
return { ...next };
}
function listActorStats() {
const { actors } = loadState();
return Object.keys(actors).map((actorKey) => ({ actorKey, ...actors[actorKey] }));
}
function bumpRoverPets(roverId, by = 1) {
const id = String(roverId || '').trim();
if (!id) return 0;
const state = loadState();
const current = state.rovers[id] || { pets: 0, updatedAt: null };
const delta = Number(by);
const next = {
pets: clampCount(current.pets + (Number.isFinite(delta) ? delta : 0)),
updatedAt: Date.now(),
};
persistState({ ...state, rovers: { ...state.rovers, [id]: next } });
return next.pets;
}
function getRoverPets(roverId) {
const id = String(roverId || '').trim();
if (!id) return 0;
return loadState().rovers[id]?.pets || 0;
}
// Tests drive the store through a temporary SERVER_DATA_DIR, so they need a way
// to drop the module-level cache between cases.
function resetCacheForTests() {
cache = null;
}
module.exports = {
ACTOR_COUNTERS,
STORE_PATH,
getActorStats,
bumpActorStats,
listActorStats,
bumpRoverPets,
getRoverPets,
resetCacheForTests,
};
@@ -1,131 +0,0 @@
// Fun Stats Service Tests
// Purpose: Verifies counter persistence, clamping, and that a corrupt store degrades instead of throwing.
// Scope: Runs against a temporary SERVER_DATA_DIR so the real data directory is never touched.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fun-stats-test-'));
process.env.SERVER_DATA_DIR = dataDir;
const funStatsService = require('./index');
function reset() {
try {
fs.rmSync(funStatsService.STORE_PATH, { force: true });
} catch {
// A missing store is the normal starting state.
}
funStatsService.resetCacheForTests();
}
test('counters start at zero for an unknown actor', () => {
reset();
const stats = funStatsService.getActorStats('user:nobody');
assert.equal(stats.bonksGiven, 0);
assert.equal(stats.bonksTaken, 0);
assert.equal(stats.label, null);
});
test('bumping a counter accumulates and records the label', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
const stats = funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
assert.equal(stats.bonksGiven, 2);
assert.equal(stats.label, 'alice');
});
test('counters are independent of one another', () => {
reset();
funStatsService.bumpActorStats('user:alice', { bonksGiven: 3, hugsGiven: 1 });
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.bonksGiven, 3);
assert.equal(stats.hugsGiven, 1);
assert.equal(stats.slapsGiven, 0);
});
test('an unrecognized counter name is ignored rather than silently stored', () => {
reset();
funStatsService.bumpActorStats('user:alice', { notACounter: 5 });
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.notACounter, undefined);
});
test('state survives a cold read from disk', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 7 });
funStatsService.resetCacheForTests();
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 7);
});
test('an empty actor key is refused so anonymous bumps cannot share a bucket', () => {
reset();
funStatsService.bumpActorStats('', { bonksGiven: 1 });
assert.deepEqual(funStatsService.listActorStats(), []);
});
test('rover pets accumulate per rover', () => {
reset();
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 1);
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 2);
assert.equal(funStatsService.bumpRoverPets('rover-2', 1), 1);
assert.equal(funStatsService.getRoverPets('rover-1'), 2);
assert.equal(funStatsService.getRoverPets('unknown'), 0);
});
test('listActorStats returns every actor with their key', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
funStatsService.bumpActorStats('discord:4242', { label: 'dave', bonksGiven: 2 });
const keys = funStatsService.listActorStats().map((row) => row.actorKey).sort();
assert.deepEqual(keys, ['discord:4242', 'user:alice']);
});
test('negative and non-numeric deltas cannot drive a counter below zero', () => {
reset();
funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 });
funStatsService.bumpActorStats('user:alice', { bonksGiven: -50 });
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
funStatsService.bumpActorStats('user:alice', { bonksGiven: Number.NaN });
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
});
test('labels are trimmed and length capped', () => {
reset();
const stats = funStatsService.bumpActorStats('user:alice', { label: ` ${'x'.repeat(200)} `, bonksGiven: 1 });
assert.equal(stats.label.length, 64);
});
test('a corrupt store file degrades to empty instead of throwing', () => {
reset();
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
fs.writeFileSync(funStatsService.STORE_PATH, '{not json at all', 'utf8');
funStatsService.resetCacheForTests();
assert.deepEqual(funStatsService.listActorStats(), []);
// And it must still be writable afterwards.
assert.equal(funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 }).bonksGiven, 1);
});
test('a store with the wrong shape is normalized rather than trusted', () => {
reset();
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
fs.writeFileSync(
funStatsService.STORE_PATH,
JSON.stringify({ actors: { 'user:alice': { bonksGiven: 'lots', label: 42 } }, rovers: 'nope' }),
'utf8',
);
funStatsService.resetCacheForTests();
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.bonksGiven, 0);
assert.equal(stats.label, '42');
assert.equal(funStatsService.getRoverPets('rover-1'), 0);
});
test.after(() => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const { resolveDataPath } = require('../../helpers/dataPaths');
const { buildMediaMtxConfig } = require('./config'); const { buildMediaMtxConfig } = require('./config');
function createMediaMtxSupervisor(deps) { function createMediaMtxSupervisor(deps) {
@@ -13,7 +14,7 @@ function createMediaMtxSupervisor(deps) {
serverPort, serverPort,
logger, logger,
mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx', mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx',
runtimeDir = process.env.MULTIROVER_RUNTIME_DIR || '/run/multirover', configPath = resolveDataPath('mediamtx.yml'),
snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh', snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
spawnProcess = spawn, spawnProcess = spawn,
} = deps; } = deps;
@@ -39,10 +40,14 @@ function createMediaMtxSupervisor(deps) {
function start() { function start() {
if (child) return child; if (child) return child;
const runtimeConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }); const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
const configPath = path.join(runtimeDir, 'mediamtx.yml'); /*
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o750 }); Generated MediaMTX state belongs beside the server's other owned data. Using the shared
fs.writeFileSync(configPath, yaml.dump(runtimeConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 }); data-path helper honors SERVER_DATA_DIR as well as the normal server/data directory and
avoids introducing a systemd-created /run directory with separate permission rules.
*/
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, yaml.dump(generatedConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
logger.info(`Starting MediaMTX with generated config ${configPath}`); logger.info(`Starting MediaMTX with generated config ${configPath}`);
child = spawnProcess(mediaMtxBin, [configPath], { child = spawnProcess(mediaMtxBin, [configPath], {
@@ -1,174 +0,0 @@
// Operator Fun Command Helpers
// Purpose: Shared actor identity, target lookup, and deterministic randomness for the fun commands.
// Scope: No side effects; every function here is safe to call before permission checks pass.
const { normalizeSearchText, normalizeText, resolveRoverSelector } = require('./resolvers');
// Echoed user text is capped so a fun command cannot be used to shout a wall of
// text into every bridged Discord channel.
const MAX_ECHO_LENGTH = 180;
const PLAIN_MENTIONS = { parse: [], repliedUser: false };
/*
Fun counters have to survive across transports, so they are keyed by a stable
identity rather than a connection. Site chat resolves to the identity user id
that moderation already uses; Discord has no row in that database, so it gets
its own key space. An unidentified site socket falls back to its socket id,
which means its tally resets on reconnect — acceptable for a joke counter, and
much better than crediting every anonymous visitor to one shared bucket.
*/
function buildActorKey(request) {
const transport = normalizeText(request?.transport) || 'unknown';
if (transport === 'discord') {
const discordId = normalizeText(request?.actor?.id);
return discordId ? `discord:${discordId}` : null;
}
const userId = normalizeText(request?.actor?.userId);
if (userId) return `user:${userId}`;
const socketId = normalizeText(request?.actor?.id);
return socketId ? `socket:${socketId}` : null;
}
function actorLabel(request) {
return normalizeText(request?.actor?.label) || 'someone';
}
/*
Collapses every connected socket for one person onto their canonical user id so
extra browser tabs cannot make a target look ambiguous. Mirrors the same
approach the deter command uses for moderation targets.
*/
function findOnlineUsers(io, getNickname, selector) {
const normalizedSelector = normalizeSearchText(selector);
if (!normalizedSelector) return [];
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return [];
const byUserId = new Map();
sockets.forEach((socket) => {
const nickname = getNickname?.(socket);
if (normalizeSearchText(nickname) !== normalizedSelector) return;
const userId = normalizeText(socket?.data?.userId);
const key = userId || `socket:${normalizeText(socket?.id)}`;
if (!key) return;
if (!byUserId.has(key)) {
byUserId.set(key, { userId: userId || null, nickname: normalizeText(nickname), socket });
}
});
return Array.from(byUserId.values());
}
/*
A fun command should still work when the target is not a real user — bonking
"the dishwasher" is half the point. So an unmatched selector is not an error:
it becomes a plain label and simply credits nobody's tally. Ambiguity is
treated the same way, because guessing which of two identical nicknames took
the hit would be worse than crediting neither.
*/
function resolveFunTarget({ io, getNickname, selector }) {
const label = clampEcho(selector);
if (!label) return null;
const matches = findOnlineUsers(io, getNickname, selector);
if (matches.length === 1) {
const [match] = matches;
return {
label: match.nickname || label,
actorKey: match.userId ? `user:${match.userId}` : null,
socket: match.socket || null,
online: true,
};
}
return { label, actorKey: null, socket: null, online: false };
}
/*
Rover-scoped fun commands accept an explicit rover name and otherwise fall back
to whichever rover the caller is already attached to. Discord has no socket
behind it, so the fallback simply is not available there and the caller is asked
to name a rover rather than having one chosen for them.
*/
function createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix = 'rs' }) {
return function resolveTargetRover(selector, action = 'pet') {
const query = normalizeText(selector);
if (query) {
const resolved = resolveRoverSelector(query, rovers);
if (resolved.error) return { error: resolved.error };
return { id: resolved.id, name: resolved.label || resolved.id, record: resolved.record };
}
const socket = getActorSocket?.() || null;
if (!socket) return { error: `Name a rover: \`${commandPrefix} ${action} <rover>\`` };
// getPrimaryRoverForSocket takes a socket id and returns a rover id string.
const roverId = normalizeText(roverManager?.getPrimaryRoverForSocket?.(socket.id));
if (!roverId) return { error: 'You are not on a rover right now. Name one instead.' };
const record = rovers.get(roverId) || null;
return { id: roverId, name: record?.meta?.name || roverId, record, socket };
};
}
function clampEcho(value) {
const text = normalizeText(value).replace(/\s+/g, ' ');
if (!text) return '';
if (text.length <= MAX_ECHO_LENGTH) return text;
return `${text.slice(0, MAX_ECHO_LENGTH - 1)}`;
}
/*
FNV-1a. Fun commands that judge something — `ship`, `rate`, `8ball` — use a
hash of the input instead of Math.random so the same question always gets the
same answer. Re-rolling until you like the verdict is not funny; a server that
stubbornly insists your ship rating is 4% is.
*/
function hashSeed(value) {
const text = normalizeSearchText(value);
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
function pickBySeed(list, seed) {
const items = Array.isArray(list) ? list : [];
if (!items.length) return null;
return items[seed % items.length];
}
// Order-independent so `rs ship a b` and `rs ship b a` agree with each other.
function pairSeed(left, right) {
const pair = [normalizeSearchText(left), normalizeSearchText(right)].sort();
return hashSeed(pair.join(' '));
}
function percentFromSeed(seed) {
return seed % 101;
}
function ordinal(count) {
const value = Number(count) || 0;
const mod100 = value % 100;
if (mod100 >= 11 && mod100 <= 13) return `${value}th`;
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[value % 10] || 'th';
return `${value}${suffix}`;
}
module.exports = {
MAX_ECHO_LENGTH,
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
clampEcho,
createRoverResolver,
findOnlineUsers,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
};
@@ -1,162 +0,0 @@
// Operator Fun Helper Tests
// Purpose: Locks down actor identity, target resolution, and the deterministic seeding the fun commands depend on.
// Scope: Pure helpers plus in-memory socket doubles; nothing here touches the fun stats store.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildActorKey,
clampEcho,
createRoverResolver,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
MAX_ECHO_LENGTH,
} = require('./funHelpers');
function socket(id, userId, nickname) {
return { id, data: { userId, nickname } };
}
function harness(sockets = []) {
return {
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
getNickname: (entry) => entry?.data?.nickname || '',
};
}
test('site chat keys on the identity user id, not the socket', () => {
assert.equal(
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1', userId: 'u-alice' } }),
'user:u-alice',
);
});
test('an unidentified site socket falls back to its socket id', () => {
assert.equal(
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1' } }),
'socket:socket-1',
);
});
test('discord actors get their own key space so ids cannot collide with identity ids', () => {
assert.equal(buildActorKey({ transport: 'discord', actor: { id: '4242' } }), 'discord:4242');
});
test('an actor with no usable id at all is rejected rather than sharing a bucket', () => {
assert.equal(buildActorKey({ transport: 'web-chat', actor: {} }), null);
assert.equal(buildActorKey({ transport: 'discord', actor: {} }), null);
});
test('a single online nickname resolves to that user and credits their tally', () => {
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'BOB' });
assert.equal(resolved.label, 'bob');
assert.equal(resolved.actorKey, 'user:u-bob');
assert.equal(resolved.online, true);
});
test('multiple tabs for one person do not make the target ambiguous', () => {
const { io, getNickname } = harness([
socket('s1', 'u-bob', 'bob'),
socket('s2', 'u-bob', 'bob'),
]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
assert.equal(resolved.actorKey, 'user:u-bob');
});
test('an unmatched selector still works but credits nobody', () => {
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'the dishwasher' });
assert.equal(resolved.label, 'the dishwasher');
assert.equal(resolved.actorKey, null);
assert.equal(resolved.online, false);
});
test('two different people sharing a nickname credit neither', () => {
const { io, getNickname } = harness([
socket('s1', 'u-bob', 'bob'),
socket('s2', 'u-other', 'bob'),
]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
assert.equal(resolved.actorKey, null);
});
test('echoed text is length capped so a fun command cannot shout a wall of text', () => {
const long = 'a'.repeat(500);
const clamped = clampEcho(long);
assert.equal(clamped.length, MAX_ECHO_LENGTH);
assert.ok(clamped.endsWith('…'));
});
test('ship is order independent so both spellings agree', () => {
assert.equal(pairSeed('alice', 'bob'), pairSeed('bob', 'alice'));
});
test('seeded verdicts are stable, so a rating cannot be rerolled by asking again', () => {
const first = percentFromSeed(pairSeed('alice', 'bob'));
const second = percentFromSeed(pairSeed('alice', 'bob'));
assert.equal(first, second);
assert.ok(first >= 0 && first <= 100);
});
test('hashSeed ignores case and surrounding whitespace', () => {
assert.equal(hashSeed(' Will It Dock '), hashSeed('will it dock'));
});
test('pickBySeed stays in range and tolerates an empty list', () => {
const list = ['a', 'b', 'c'];
for (let seed = 0; seed < 20; seed += 1) {
assert.ok(list.includes(pickBySeed(list, seed)));
}
assert.equal(pickBySeed([], 5), null);
});
test('ordinal handles the teens correctly', () => {
assert.equal(ordinal(1), '1st');
assert.equal(ordinal(2), '2nd');
assert.equal(ordinal(3), '3rd');
assert.equal(ordinal(4), '4th');
assert.equal(ordinal(11), '11th');
assert.equal(ordinal(12), '12th');
assert.equal(ordinal(13), '13th');
assert.equal(ordinal(21), '21st');
assert.equal(ordinal(111), '111th');
});
test('an explicit rover name wins over whatever the caller is attached to', () => {
const rovers = new Map([
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
]);
const resolve = createRoverResolver({
rovers,
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
getActorSocket: () => ({ id: 's1' }),
});
assert.equal(resolve('Roomba Two').id, 'rover-2');
});
test('with no rover named the caller\'s current rover is used', () => {
const rovers = new Map([['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }]]);
const resolve = createRoverResolver({
rovers,
roverManager: { getPrimaryRoverForSocket: (socketId) => (socketId === 's1' ? 'rover-1' : null) },
getActorSocket: () => ({ id: 's1' }),
});
const resolved = resolve('');
assert.equal(resolved.id, 'rover-1');
assert.equal(resolved.name, 'Roomba One');
});
test('without a socket the caller is asked to name a rover instead of one being chosen', () => {
const resolve = createRoverResolver({
rovers: new Map(),
roverManager: {},
getActorSocket: () => null,
commandPrefix: 'rs',
});
assert.match(resolve('', 'pet').error, /Name a rover/);
});
@@ -1,285 +0,0 @@
// Operator Fun Rover Commands
// Purpose: Implements the fun commands that actually make the fleet or the room do something (honk, boo, disco, spin, vibecheck).
// Scope: Every handler here re-checks control and feature gating itself, because issueCommand bypasses the socket command guards.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const {
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
createRoverResolver,
hashSeed,
pickBySeed,
resolveFunTarget,
} = require('./funHelpers');
// Durations are deliberately short and are also bounded rover-side: roverd
// enforces its own horn MaxDuration, so a lost stop command cannot leave a horn
// sounding forever.
const HONK_MS = 600;
const HONK_FREQ_HZ = 440;
const SPIN_MS = 1200;
const SPIN_SPEED = 120;
const DISCO_MS = 12 * 1000;
const DISCO_TICK_MS = 750;
const HONK_ACTOR_COOLDOWN_MS = 20 * 1000;
const HONK_ROVER_COOLDOWN_MS = 8 * 1000;
const BOO_COOLDOWN_MS = 30 * 1000;
const SPIN_COOLDOWN_MS = 25 * 1000;
const DISCO_COOLDOWN_MS = 2 * 60 * 1000;
const VIBECHECK_COOLDOWN_MS = 5 * 1000;
/*
Taunts are a fixed list rather than caller-supplied text on purpose. `boo` puts
audio out of a speaker in a room full of people, so letting it read arbitrary
input would turn a joke command into an unmoderated TTS channel aimed at
whoever is nearest the rover.
*/
const BOO_TAUNTS = [
'Boo.', 'Your driving is being reviewed.', 'That was a choice.',
'The wall was right there.', 'Someone in chat is laughing at you.',
'I have seen better parking from the Neato.', 'Boo. Respectfully.',
'This is a citizen\'s arrest.', 'Turn left. No, the other left.',
];
const VIBE_VERDICTS = [
'immaculate', 'acceptable', 'questionable', 'concerning', 'dire', 'unwell',
];
function describeBattery(batteryState) {
const display = Number(batteryState?.percentDisplay);
if (Number.isFinite(display)) return `${Math.round(display)}%`;
const percent = Number(batteryState?.percent);
if (Number.isFinite(percent)) return `${Math.round(percent * 100)}%`;
return 'unknown';
}
function createFunRoverCommands({
io,
rovers,
roverManager,
getNickname,
getActiveDrivers,
getActorSocket,
issueCommand,
homeAssistantService,
isFeatureEnabled,
sanitizeMentions,
cooldowns,
logger,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
/*
issueCommand is the raw rover transport: it performs none of the ownership,
deterrence, or private-safety checks that the socket `command` handler applies.
Any fun command that moves hardware therefore has to prove control here, which
also means these commands are inherently site-chat only — a Discord message has
no socket and so can never satisfy canDrive.
*/
function requireDriveControl(action, selector) {
const socket = getActorSocket?.() || null;
if (!socket) {
return { error: `\`${commandPrefix} ${action}\` only works from site chat, where you can actually be driving.` };
}
const rover = resolveTargetRover(selector, action);
if (rover.error) return { error: rover.error };
if (!roverManager?.canDrive?.(rover.id, socket)) {
return { error: `You need control of ${rover.name} to do that.` };
}
return { rover, socket };
}
function safeIssue(roverId, payload, context) {
try {
issueCommand(roverId, payload);
return true;
} catch (err) {
// Deferred stop commands routinely land after a rover drops off. That is
// expected, not an incident, so it is logged at debug volume and swallowed.
logger?.warn?.('Fun command could not reach rover', { roverId, context, error: err.message });
return false;
}
}
async function handleHonk(message, tokens = []) {
const control = requireDriveControl('honk', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover } = control;
if (rover.record?.meta?.horn?.enabled === false) {
return reply(message, `${rover.name} has no horn fitted.`);
}
const gated = gate(message, 'honk', HONK_ACTOR_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
// A second, rover-scoped window stops a group of drivers taking turns to
// honk the same rover continuously while each stays inside their own limit.
const roverWait = cooldowns.consume(`honk:rover:${rover.id}`, HONK_ROVER_COOLDOWN_MS);
if (roverWait > 0) {
return reply(message, `${rover.name} was just honked. Give it ${describeWait(roverWait)}.`);
}
if (!safeIssue(rover.id, { type: 'horn', horn: { action: 'start', waveform: 'sine', freqs: [HONK_FREQ_HZ] } }, 'honk:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'horn', horn: { action: 'stop' } }, 'honk:stop'), HONK_MS);
return reply(message, `📢 HONK. (${rover.name})`);
}
async function handleSpin(message, tokens = []) {
const control = requireDriveControl('spin', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover, socket } = control;
const gated = gate(message, 'spin', SPIN_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const maxWheelSpeed = Number(rover.record?.meta?.maxWheelSpeed);
const speed = Math.max(1, Math.min(SPIN_SPEED, Number.isFinite(maxWheelSpeed) && maxWheelSpeed > 0 ? maxWheelSpeed : SPIN_SPEED));
let driveDirect = { left: speed, right: -speed };
/*
Private rovers can carry a reduced speed ceiling that the socket path would
normally apply. Applying it explicitly keeps a fun command from being the one
way to exceed a limit an admin set for a specific rover.
*/
const safeDrive = roverManager?.applyPrivateDriveSafety?.(rover.id, socket, driveDirect);
if (safeDrive) driveDirect = safeDrive;
if (!safeIssue(rover.id, { type: 'drive', driveDirect }, 'spin:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'drive', driveDirect: { left: 0, right: 0 } }, 'spin:stop'), SPIN_MS);
return reply(message, `🌀 ${rover.name} is doing a spin.`);
}
async function handleBoo(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
const gated = gate(message, 'boo', BOO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const resolved = resolveFunTarget({ io, getNickname, selector });
if (!resolved) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
if (!resolved.online || !resolved.socket) {
return reply(message, `${resolved.label} is not here to be booed.`);
}
// Boo lands on the rover the target is actually driving, so it needs the
// active-driver map rather than merely which rovers they are watching.
const drivers = getActiveDrivers?.() || {};
const roverId = Object.keys(drivers).find((id) => drivers[id] === resolved.socket.id) || null;
if (!roverId) return reply(message, `${resolved.label} is not driving anything right now.`);
const record = rovers.get(String(roverId));
const roverName = record?.meta?.name || roverId;
if (record?.meta?.audio?.ttsEnabled === false) {
return reply(message, `${roverName} cannot speak.`);
}
const taunt = pickBySeed(BOO_TAUNTS, hashSeed(`${gated.actorKey}:${resolved.label}`));
if (!safeIssue(roverId, { type: 'tts', tts: { text: taunt, speak: true, engine: 'chromegtts' } }, 'boo')) {
return reply(message, `${roverName} is offline.`);
}
return reply(message, `👻 Booed ${resolved.label} through ${roverName}.`);
}
async function handleDisco(message) {
if (!homeAssistantService || !isFeatureEnabled?.('homeAssistant')) {
return reply(message, 'Room light controls are unavailable.');
}
// An admin lock on the room lights is a policy boundary. Disco is a scene
// change like `rs lights on`, so it must not be the one command that ignores it.
const lightPolicy = homeAssistantService.getLightPolicyState?.() || {};
if (lightPolicy.locked) {
return reply(message, 'Room lights are locked. No disco.');
}
const gated = gate(message, 'disco', DISCO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const setAll = homeAssistantService.setAllControllableEntitiesState;
if (typeof setAll !== 'function') {
return reply(message, 'Room light controls are unavailable.');
}
const endsAt = Date.now() + DISCO_MS;
let on = false;
/*
Held in a local interval rather than the rewards effect store because a
disco is short and disposable. Nothing needs to survive a restart, and the
final tick always restores the lights to on.
*/
const timer = setInterval(() => {
if (Date.now() >= endsAt) {
clearInterval(timer);
Promise.resolve(setAll('on')).catch((err) => {
logger?.warn?.('Disco could not restore lights', { error: err.message });
});
return;
}
on = !on;
Promise.resolve(setAll(on ? 'on' : 'off')).catch((err) => {
logger?.warn?.('Disco tick failed', { error: err.message });
});
}, DISCO_TICK_MS);
return reply(message, `🪩 Disco for ${Math.round(DISCO_MS / 1000)} seconds. Started by ${gated.label}.`);
}
async function handleVibecheck(message, tokens = []) {
const gated = gate(message, 'vibecheck', VIBECHECK_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'vibecheck');
if (rover.error) return reply(message, rover.error);
const record = rover.record || rovers.get(rover.id) || null;
const battery = describeBattery(record?.batteryState);
const offline = !record?.ws;
const locked = Boolean(record?.locked);
const urgent = Boolean(record?.batteryState?.urgentActive);
const warn = Boolean(record?.batteryState?.warnActive);
let verdict;
if (offline) verdict = 'nonexistent — it is offline';
else if (urgent) verdict = 'dying';
else if (warn) verdict = 'running low';
else if (locked) verdict = 'locked out and sulking';
else verdict = pickBySeed(VIBE_VERDICTS, hashSeed(`${rover.id}:${battery}`));
return reply(message, `🔍 ${rover.name}: vibes are **${verdict}**. Battery ${battery}.`);
}
return {
honk: handleHonk,
boo: handleBoo,
disco: handleDisco,
spin: handleSpin,
vibecheck: handleVibecheck,
};
}
module.exports = { createFunRoverCommands, describeBattery };
@@ -1,297 +0,0 @@
// Operator Fun Rover Command Tests
// Purpose: Verifies the control, feature, and lock checks the hardware-backed fun commands must make themselves.
// Scope: issueCommand, roverManager, and Home Assistant are all doubles; no real rover or timer is involved.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunRoverCommands, describeBattery } = require('./funRover');
const { createCooldownGate } = require('../cooldowns');
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
function createHarness({
canDrive = true,
socket = ALICE,
hornEnabled = true,
ttsEnabled = true,
online = true,
maxWheelSpeed = 300,
privateSafetyDrive = null,
activeDrivers = {},
homeAssistantService = null,
featureEnabled = false,
sockets = [ALICE, BOB],
} = {}) {
const issued = [];
const record = {
id: 'rover-1',
ws: online ? {} : null,
locked: false,
meta: {
name: 'Roomba One',
maxWheelSpeed,
horn: { enabled: hornEnabled },
audio: { ttsEnabled },
},
batteryState: { percentDisplay: 74, warnActive: false, urgentActive: false },
};
const rovers = new Map([['rover-1', record]]);
const handlers = createFunRoverCommands({
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
rovers,
roverManager: {
canDrive: () => canDrive,
getPrimaryRoverForSocket: () => 'rover-1',
applyPrivateDriveSafety: () => privateSafetyDrive,
},
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
getActorSocket: () => socket,
issueCommand: (roverId, payload) => {
if (!record.ws) throw new Error('Rover offline');
issued.push({ roverId, ...payload });
return 'cmd-1';
},
homeAssistantService,
isFeatureEnabled: () => featureEnabled,
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
cooldowns: createCooldownGate(),
logger: { warn: () => {} },
config: { commands: { prefix: 'rs' } },
});
return { handlers, issued, record, rovers };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
test('honk starts the horn and schedules a stop', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { handlers, issued } = createHarness();
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /HONK/);
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start']);
// The stop is deferred, so nothing has released the horn yet.
t.mock.timers.tick(1000);
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start', 'stop']);
});
test('honk is refused without drive control', async () => {
const { handlers, issued } = createHarness({ canDrive: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /need control of Roomba One/);
assert.equal(issued.length, 0);
});
test('honk is refused from a transport with no socket, so Discord cannot drive hardware', async () => {
const { handlers, issued } = createHarness({ socket: null });
const msg = message({ id: '4242', label: 'DiscordUser' });
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /only works from site chat/);
assert.equal(issued.length, 0);
});
test('honk is refused on a rover with no horn fitted', async () => {
const { handlers, issued } = createHarness({ hornEnabled: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /no horn fitted/);
assert.equal(issued.length, 0);
});
test('a second driver cannot bypass the rover cooldown with their own fresh actor window', async () => {
const { handlers, issued } = createHarness();
await handlers.honk(message(), []);
const other = message({ id: 's2', userId: 'u-bob', label: 'bob' });
await handlers.honk(other, []);
assert.match(other.replies[0].content, /was just honked/);
// Only the first honk reached the rover.
assert.equal(issued.filter((entry) => entry.horn?.action === 'start').length, 1);
});
test('an offline rover reports offline instead of claiming a honk happened', async () => {
const { handlers } = createHarness({ online: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /is offline/);
});
test('spin clamps to the rover wheel speed ceiling and always stops itself', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { handlers, issued } = createHarness({ maxWheelSpeed: 50 });
const msg = message();
await handlers.spin(msg, []);
assert.equal(issued[0].driveDirect.left, 50);
assert.equal(issued[0].driveDirect.right, -50);
t.mock.timers.tick(2000);
assert.deepEqual(issued[1].driveDirect, { left: 0, right: 0 });
});
test('spin honours a private rover safety override rather than bypassing it', async () => {
const { handlers, issued } = createHarness({ privateSafetyDrive: { left: 20, right: -20 } });
await handlers.spin(message(), []);
assert.deepEqual(issued[0].driveDirect, { left: 20, right: -20 });
});
test('spin is refused without drive control', async () => {
const { handlers, issued } = createHarness({ canDrive: false });
const msg = message();
await handlers.spin(msg, []);
assert.match(msg.replies[0].content, /need control/);
assert.equal(issued.length, 0);
});
test('boo speaks a canned taunt rather than any caller supplied text', async () => {
const { handlers, issued } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.equal(issued.length, 1);
assert.equal(issued[0].type, 'tts');
// The spoken text must not contain anything the caller typed.
assert.doesNotMatch(issued[0].tts.text, /bob/i);
assert.ok(issued[0].tts.text.length > 0);
});
test('boo is refused when the target is not driving anything', async () => {
const { handlers, issued } = createHarness({ activeDrivers: {} });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.match(msg.replies[0].content, /not driving anything/);
assert.equal(issued.length, 0);
});
test('boo is refused when the target is not online at all', async () => {
const { handlers, issued } = createHarness({ sockets: [ALICE] });
const msg = message();
await handlers.boo(msg, ['nobody-here']);
assert.match(msg.replies[0].content, /not here to be booed/);
assert.equal(issued.length, 0);
});
test('boo is refused on a rover that cannot speak', async () => {
const { handlers, issued } = createHarness({ ttsEnabled: false, activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.match(msg.replies[0].content, /cannot speak/);
assert.equal(issued.length, 0);
});
test('disco is unavailable when the Home Assistant feature is off', async () => {
const calls = [];
const { handlers } = createHarness({
featureEnabled: false,
homeAssistantService: {
getLightPolicyState: () => ({}),
setAllControllableEntitiesState: (state) => calls.push(state),
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /unavailable/);
assert.equal(calls.length, 0);
});
test('disco obeys the room light lock', async () => {
const calls = [];
const { handlers } = createHarness({
featureEnabled: true,
homeAssistantService: {
getLightPolicyState: () => ({ locked: true, lockState: 'on' }),
setAllControllableEntitiesState: (state) => calls.push(state),
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /locked/);
assert.equal(calls.length, 0);
});
test('disco strobes while unlocked and restores the lights on when it ends', async (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'] });
const calls = [];
const { handlers } = createHarness({
featureEnabled: true,
homeAssistantService: {
getLightPolicyState: () => ({ locked: false }),
setAllControllableEntitiesState: (state) => {
calls.push(state);
return Promise.resolve();
},
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /Disco/);
t.mock.timers.tick(3000);
assert.ok(calls.length >= 2, `expected several ticks, saw ${calls.length}`);
assert.ok(calls.includes('on') && calls.includes('off'));
// Past the end of the window the lights must be put back on and left alone.
t.mock.timers.tick(20 * 1000);
assert.equal(calls[calls.length - 1], 'on');
const settled = calls.length;
t.mock.timers.tick(20 * 1000);
assert.equal(calls.length, settled);
});
test('vibecheck reports the battery and never issues a command', async () => {
const { handlers, issued } = createHarness();
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /Roomba One/);
assert.match(msg.replies[0].content, /Battery 74%/);
assert.equal(issued.length, 0);
});
test('vibecheck leads with the real problem when the battery is urgent', async () => {
const { handlers, record } = createHarness();
record.batteryState = { percentDisplay: 4, warnActive: true, urgentActive: true };
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /dying/);
});
test('vibecheck reports an offline rover as offline', async () => {
const { handlers, record } = createHarness();
record.ws = null;
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /offline/);
});
test('describeBattery falls back through the available fields', () => {
assert.equal(describeBattery({ percentDisplay: 55.4 }), '55%');
assert.equal(describeBattery({ percent: 0.42 }), '42%');
assert.equal(describeBattery({}), 'unknown');
assert.equal(describeBattery(null), 'unknown');
});
@@ -1,118 +0,0 @@
// Operator Fun Stats Commands
// Purpose: Implements the fun commands that read or extend persistent counters (bonkboard, pet, snitch).
// Scope: Reads the roster and the fun stats store; issues no rover commands.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const { PLAIN_MENTIONS, actorLabel, buildActorKey, createRoverResolver } = require('./funHelpers');
const PET_COOLDOWN_MS = 10 * 1000;
const READ_COOLDOWN_MS = 5 * 1000;
const LEADERBOARD_SIZE = 10;
function formatLeaderboard(title, rows, counter) {
const ranked = rows
.filter((row) => Number(row[counter]) > 0)
.sort((left, right) => Number(right[counter]) - Number(left[counter]))
.slice(0, LEADERBOARD_SIZE);
if (!ranked.length) return null;
const lines = ranked.map((row, index) => `${index + 1}. ${row.label || 'unknown'}${row[counter]}`);
return [`**${title}**`, ...lines].join('\n');
}
function createFunStatsCommands({
io,
rovers,
getNickname,
getActiveDrivers,
getActorSocket,
roverManager,
sanitizeMentions,
funStatsService,
cooldowns,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
async function handleBonkboard(message) {
const gated = gate(message, 'bonkboard', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rows = funStatsService.listActorStats();
const sections = [
formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'),
formatLeaderboard('Most bonks taken', rows, 'bonksTaken'),
formatLeaderboard('Most hugs given', rows, 'hugsGiven'),
].filter(Boolean);
if (!sections.length) {
return reply(message, `Nobody has been bonked yet. Fix that with \`${commandPrefix} bonk <user>\`.`);
}
return reply(message, sections.join('\n\n').slice(0, 1900));
}
async function handlePet(message, tokens = []) {
const gated = gate(message, 'pet', PET_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'pet');
if (rover.error) return reply(message, rover.error);
const pets = funStatsService.bumpRoverPets(rover.id, 1);
return reply(message, `🤖 ${gated.label} pets ${rover.name}. It has now been petted ${pets} time${pets === 1 ? '' : 's'}.`);
}
/*
Reads the same active-driver map the turn system uses, so it reports real
control rather than who merely has the page open. Rovers with nobody driving
are listed too — an empty fleet is exactly what a snitch should report.
*/
async function handleSnitch(message) {
const gated = gate(message, 'snitch', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const drivers = getActiveDrivers?.() || {};
const sockets = io?.sockets?.sockets;
const lines = [];
rovers.forEach((record, roverId) => {
const id = String(roverId);
const name = record?.meta?.name || id;
const socketId = drivers[id];
const socket = socketId && sockets?.get ? sockets.get(socketId) : null;
const nickname = socket ? getNickname?.(socket) : null;
if (nickname) {
lines.push(`${name}${nickname}`);
} else if (socketId) {
lines.push(`${name} — someone who will not say their name`);
} else {
lines.push(`${name} — nobody`);
}
});
if (!lines.length) return reply(message, 'No rovers are online to snitch about.');
return reply(message, ['🕵️ Currently driving:', ...lines].join('\n').slice(0, 1900));
}
return {
bonkboard: handleBonkboard,
pet: handlePet,
snitch: handleSnitch,
};
}
module.exports = { createFunStatsCommands, formatLeaderboard };
@@ -1,166 +0,0 @@
// Operator Fun Stats Command Tests
// Purpose: Verifies the leaderboard ordering, rover pet counting, and what snitch reports.
// Scope: In-memory stats and roster doubles only.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunStatsCommands, formatLeaderboard } = require('./funStats');
const { createCooldownGate } = require('../cooldowns');
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
function createHarness({
actorRows = [],
activeDrivers = {},
socket = ALICE,
rovers = new Map([
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
]),
} = {}) {
const pets = new Map();
const handlers = createFunStatsCommands({
io: { sockets: { sockets: new Map([[ALICE.id, ALICE], [BOB.id, BOB]]) } },
rovers,
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
getActorSocket: () => socket,
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
funStatsService: {
listActorStats: () => actorRows,
bumpRoverPets: (roverId, by) => {
const next = (pets.get(roverId) || 0) + by;
pets.set(roverId, next);
return next;
},
},
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
return { handlers, pets };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
test('the leaderboard sorts descending and drops zero scores', () => {
const rows = [
{ label: 'alice', bonksGiven: 2 },
{ label: 'bob', bonksGiven: 9 },
{ label: 'carol', bonksGiven: 0 },
];
const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven');
const lines = rendered.split('\n');
assert.equal(lines[1], '1. bob — 9');
assert.equal(lines[2], '2. alice — 2');
assert.equal(lines.length, 3, 'carol should not appear with a zero score');
});
test('the leaderboard is capped at ten entries', () => {
const rows = Array.from({ length: 25 }, (_, index) => ({ label: `user${index}`, bonksGiven: index + 1 }));
const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven');
assert.equal(rendered.split('\n').length - 1, 10);
});
test('an all-zero counter renders no section at all', () => {
assert.equal(formatLeaderboard('Most bonks dealt', [{ label: 'alice', bonksGiven: 0 }], 'bonksGiven'), null);
});
test('bonkboard says so when nothing has happened yet', async () => {
const { handlers } = createHarness({ actorRows: [] });
const msg = message();
await handlers.bonkboard(msg, []);
assert.match(msg.replies[0].content, /Nobody has been bonked yet/);
});
test('bonkboard renders each populated section', async () => {
const { handlers } = createHarness({
actorRows: [
{ label: 'alice', bonksGiven: 3, bonksTaken: 0, hugsGiven: 1 },
{ label: 'bob', bonksGiven: 0, bonksTaken: 3, hugsGiven: 0 },
],
});
const msg = message();
await handlers.bonkboard(msg, []);
assert.match(msg.replies[0].content, /Most bonks dealt/);
assert.match(msg.replies[0].content, /Most bonks taken/);
assert.match(msg.replies[0].content, /Most hugs given/);
});
test('bonkboard sanitizes stored labels, so a hostile nickname cannot ping a guild', async () => {
const { handlers } = createHarness({ actorRows: [{ label: '@everyone', bonksGiven: 1 }] });
const msg = message();
await handlers.bonkboard(msg, []);
assert.doesNotMatch(msg.replies[0].content, /@everyone/);
});
test('pet counts against the rover the caller is on when none is named', async () => {
const { handlers, pets } = createHarness();
const msg = message();
await handlers.pet(msg, []);
assert.match(msg.replies[0].content, /pets Roomba One/);
assert.match(msg.replies[0].content, /petted 1 time\./);
assert.equal(pets.get('rover-1'), 1);
});
test('pet accepts an explicit rover and keeps a separate count per rover', async () => {
const { handlers, pets } = createHarness();
await handlers.pet(message(), ['Roomba Two']);
await handlers.pet(message({ id: 's2', userId: 'u-bob', label: 'bob' }), ['Roomba Two']);
assert.equal(pets.get('rover-2'), 2);
assert.equal(pets.get('rover-1'), undefined);
});
test('pet pluralizes the running total', async () => {
const { handlers } = createHarness();
await handlers.pet(message(), []);
const second = message({ id: 's2', userId: 'u-bob', label: 'bob' });
await handlers.pet(second, []);
assert.match(second.replies[0].content, /petted 2 times\./);
});
test('pet from a transport with no socket asks for a rover name', async () => {
const { handlers, pets } = createHarness({ socket: null });
const msg = message({ id: '4242', label: 'DiscordUser' });
await handlers.pet(msg, []);
assert.match(msg.replies[0].content, /Name a rover/);
assert.equal(pets.size, 0);
});
test('snitch names the active driver and reports idle rovers as nobody', async () => {
const { handlers } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /Roomba One — bob/);
assert.match(msg.replies[0].content, /Roomba Two — nobody/);
});
test('snitch handles a driver socket that has already gone away', async () => {
const { handlers } = createHarness({ activeDrivers: { 'rover-1': 'ghost-socket' } });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /Roomba One — someone who will not say their name/);
});
test('snitch reports an empty fleet rather than an empty message', async () => {
const { handlers } = createHarness({ rovers: new Map() });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /No rovers are online/);
});
@@ -1,361 +0,0 @@
// Operator Fun Text Commands
// Purpose: Implements the social, text-only `rs` commands (bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted).
// Scope: Text and counters only; nothing here touches rover hardware.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const {
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
clampEcho,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
} = require('./funHelpers');
const TEXT_COOLDOWN_MS = 4 * 1000;
/*
The bonk sound gets its own, much longer rover-scoped window. Playing it
interrupts whatever that rover is forwarding — including a live microphone — so
the audio must not be spammable even though the text bonk stays snappy, and a
group of people bonking one driver cannot chain it either.
*/
const BONK_SOUND_ROVER_COOLDOWN_MS = 20 * 1000;
const SLAP_ITEMS = [
'a large trout', 'a rolled-up service manual', 'a dead AA battery', 'a docking station',
'a suspiciously warm power brick', 'half a roll of duct tape', 'a decommissioned brush guard',
'a bag of loose screws', 'an unlabelled USB cable', 'a soggy floor sensor',
'the heaviest available wrench', 'a stack of unread pull requests',
];
const EIGHT_BALL_ANSWERS = [
'Yes.', 'No.', 'Absolutely.', 'Absolutely not.', 'Ask again once the battery is charged.',
'Signs point to the docking station.', 'The overseer says no.', 'Almost certainly.',
'Not while anyone is watching.', 'Outlook cloudy, sensors dirty.', 'Try it and find out.',
'That is a maintenance window problem.', 'Only on a Tuesday.', 'The rover has already decided.',
];
const HUG_FLAVOURS = [
'gently', 'aggressively', 'with both brush guards', 'at full wheel speed',
'for slightly too long', 'while beeping softly', 'without asking first',
];
const WANTED_CRIMES = [
'reckless docking', 'driving with the brush on indoors', 'excessive honking',
'unauthorised carpet donuts', 'battery hoarding', 'ignoring the global objective',
'parking in the doorway', 'nine consecutive turn skips', 'talking to the Neato',
'strobing the room lights for fun', 'stealing another rover\'s charger',
];
const RATE_SUFFIXES = [
'No further questions.', 'I stand by this.', 'Do not appeal.', 'Take it or leave it.',
'The sensors agree.', 'This rating is final.',
];
/*
A dice roll is one of the few places a fun command should be genuinely random:
the whole point is that nobody can predict it. Everything that passes judgement
on a thing (`ship`, `rate`, `8ball`, `wanted`) is seeded from the input instead,
so re-running it cannot reroll a verdict somebody disliked.
*/
function rollDice(count, sides) {
let total = 0;
const rolls = [];
for (let index = 0; index < count; index += 1) {
const value = 1 + Math.floor(Math.random() * sides);
rolls.push(value);
total += value;
}
return { rolls, total };
}
function parseDiceSpec(spec) {
const text = String(spec || '').trim().toLowerCase() || '1d6';
const match = /^(\d*)d(\d+)$/.exec(text);
if (!match) {
// A bare number is read as a single die with that many sides so `rs roll 20`
// does the obvious thing instead of erroring.
const bare = /^(\d+)$/.exec(text);
if (!bare) return { error: 'Roll format is `NdN`, for example `2d6`.' };
const sides = Number(bare[1]);
if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' };
return { count: 1, sides };
}
const count = match[1] === '' ? 1 : Number(match[1]);
const sides = Number(match[2]);
if (count < 1 || count > 20) return { error: 'Roll between 1 and 20 dice.' };
if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' };
return { count, sides };
}
function uwuify(text) {
return String(text || '')
.replace(/[rl]/g, 'w')
.replace(/[RL]/g, 'W')
.replace(/n([aeiou])/g, 'ny$1')
.replace(/N([aeiou])/g, 'Ny$1')
.replace(/ove/g, 'uv')
.replace(/!+/g, ' !!');
}
function createFunTextCommands({
io,
getNickname,
getActiveDrivers,
publishEvent,
sanitizeMentions,
funStatsService,
cooldowns,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
/*
Announces the bonk so audioForwardService can play the sound on the rover the
target is driving. Published as an event rather than calling the audio pipeline
directly, matching how the charging-complete cue is wired: the command layer
stays unaware of ffmpeg, and a server without the sound installed simply has
nothing listening that can do anything.
*/
function announceBonk(targetSocket, targetLabel, actorLabelText) {
if (!targetSocket || typeof publishEvent !== 'function') return;
const drivers = getActiveDrivers?.() || {};
const roverId = Object.keys(drivers).find((id) => drivers[id] === targetSocket.id) || null;
if (!roverId) return;
if (cooldowns.consume(`bonk:sound:${roverId}`, BONK_SOUND_ROVER_COOLDOWN_MS) > 0) return;
publishEvent({
source: 'funCommands',
type: 'fun.bonked',
payload: { roverId, targetLabel, actor: actorLabelText },
});
}
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
/*
Every fun command runs through one gate so the cooldown, the actor identity,
and the "who am I talking about" resolution cannot drift apart between
commands. `needsTarget` commands reply with their own usage line when the
selector is missing rather than silently acting on nothing.
*/
function gate(message, action, { windowMs = TEXT_COOLDOWN_MS } = {}) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
function target(selector) {
return resolveFunTarget({ io, getNickname, selector });
}
/*
Shared shape for the three "do a thing to someone" commands. Only the verb,
the counter names, and the flavour text differ, and keeping them in one place
means a fix to self-targeting or tally credit applies to all of them.
*/
function createInteraction({ action, counterGiven, counterTaken, selfReply, render, onApplied }) {
return async function handleInteraction(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) {
return reply(message, `Usage: \`${commandPrefix} ${action} <user>\``);
}
const gated = gate(message, action);
if (gated.error) return reply(message, gated.error);
const resolved = target(selector);
if (!resolved) return reply(message, `Usage: \`${commandPrefix} ${action} <user>\``);
if (resolved.actorKey && resolved.actorKey === gated.actorKey) {
return reply(message, selfReply(gated.label));
}
funStatsService.bumpActorStats(gated.actorKey, { label: gated.label, [counterGiven]: 1 });
const targetStats = resolved.actorKey
? funStatsService.bumpActorStats(resolved.actorKey, { label: resolved.label, [counterTaken]: 1 })
: null;
// Side effects run after the tallies so a failure in an optional extra (the
// bonk sound) cannot cost the user their recorded bonk.
onApplied?.({ actor: gated.label, resolved });
return reply(message, render({
actor: gated.label,
actorKey: gated.actorKey,
targetLabel: resolved.label,
targetOnline: resolved.online,
takenCount: targetStats ? targetStats[counterTaken] : null,
}));
};
}
const handleBonk = createInteraction({
action: 'bonk',
counterGiven: 'bonksGiven',
counterTaken: 'bonksTaken',
selfReply: (label) => `${label} bonked themselves. That is between you and the rover.`,
onApplied: ({ actor, resolved }) => announceBonk(resolved.socket, resolved.label, actor),
render: ({ targetLabel, takenCount }) => {
const tally = takenCount ? ` That is their ${ordinal(takenCount)} bonk.` : '';
return `🔨 Bonked ${targetLabel}.${tally}`;
},
});
const handleHug = createInteraction({
action: 'hug',
counterGiven: 'hugsGiven',
counterTaken: 'hugsTaken',
selfReply: (label) => `${label} hugged themselves. Genuinely fine. No notes.`,
render: ({ actor, targetLabel, takenCount }) => {
const flavour = pickBySeed(HUG_FLAVOURS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
const tally = takenCount ? ` (${takenCount} total)` : '';
return `🫂 ${actor} hugs ${targetLabel} ${flavour}.${tally}`;
},
});
const handleSlap = createInteraction({
action: 'slap',
counterGiven: 'slapsGiven',
counterTaken: 'slapsTaken',
selfReply: (label) => `${label} slapped themselves with a large trout. Bold.`,
render: ({ actor, targetLabel, takenCount }) => {
// Seeding on the running count means the weapon changes every time without
// being unpredictable for the same repeat number.
const item = pickBySeed(SLAP_ITEMS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
return `🐟 ${actor} slaps ${targetLabel} around a bit with ${item}.`;
},
});
async function handleEightBall(message, tokens = []) {
const question = clampEcho(tokens.join(' '));
if (!question) return reply(message, `Usage: \`${commandPrefix} 8ball <question>\``);
const gated = gate(message, '8ball');
if (gated.error) return reply(message, gated.error);
const answer = pickBySeed(EIGHT_BALL_ANSWERS, hashSeed(question));
return reply(message, `🎱 ${question}\n${answer}`);
}
async function handleRoll(message, tokens = []) {
const gated = gate(message, 'roll');
if (gated.error) return reply(message, gated.error);
const spec = parseDiceSpec(tokens.join(''));
if (spec.error) return reply(message, spec.error);
const { rolls, total } = rollDice(spec.count, spec.sides);
const detail = rolls.length > 1 ? ` (${rolls.join(' + ')})` : '';
return reply(message, `🎲 ${gated.label} rolled ${spec.count}d${spec.sides}: **${total}**${detail}`);
}
async function handleCoin(message) {
const gated = gate(message, 'coin');
if (gated.error) return reply(message, gated.error);
const side = Math.random() < 0.5 ? 'Heads' : 'Tails';
return reply(message, `🪙 ${side}.`);
}
async function handleShip(message, tokens = []) {
const parts = tokens.join(' ').split(/\s+(?:and|\+|&)\s+|\s*,\s*/i).map((part) => clampEcho(part)).filter(Boolean);
const [left, right] = parts.length >= 2 ? parts : [parts[0], null];
if (!left || !right) {
return reply(message, `Usage: \`${commandPrefix} ship <a> and <b>\``);
}
const gated = gate(message, 'ship');
if (gated.error) return reply(message, gated.error);
const score = percentFromSeed(pairSeed(left, right));
const verdict = score >= 90 ? 'Get them a shared charging dock.'
: score >= 65 ? 'Promising.'
: score >= 35 ? 'Needs work.'
: score >= 10 ? 'The sensors are not hopeful.'
: 'Absolutely not.';
return reply(message, `💞 ${left} + ${right} = **${score}%**. ${verdict}`);
}
async function handleRate(message, tokens = []) {
const thing = clampEcho(tokens.join(' '));
if (!thing) return reply(message, `Usage: \`${commandPrefix} rate <thing>\``);
const gated = gate(message, 'rate');
if (gated.error) return reply(message, gated.error);
const seed = hashSeed(thing);
const score = seed % 11;
const suffix = pickBySeed(RATE_SUFFIXES, seed);
return reply(message, `📊 I rate ${thing} **${score}/10**. ${suffix}`);
}
async function handleUwu(message, tokens = []) {
const text = clampEcho(tokens.join(' '));
if (!text) return reply(message, `Usage: \`${commandPrefix} uwu <text>\``);
const gated = gate(message, 'uwu');
if (gated.error) return reply(message, gated.error);
return reply(message, uwuify(text));
}
async function handleWanted(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} wanted <user>\``);
const gated = gate(message, 'wanted');
if (gated.error) return reply(message, gated.error);
const resolved = target(selector);
if (!resolved) return reply(message, `Usage: \`${commandPrefix} wanted <user>\``);
const seed = hashSeed(resolved.label);
const crime = pickBySeed(WANTED_CRIMES, seed);
// Bounty is seeded so a given name always carries the same price. Somebody
// being permanently worth 12 credits is funnier than a fresh number each time.
const bounty = 25 + (seed % 4776);
const stats = resolved.actorKey ? funStatsService.getActorStats(resolved.actorKey) : null;
const priors = stats && stats.bonksTaken ? `\nPrior bonks on record: ${stats.bonksTaken}.` : '';
return reply(message, [
'```',
' WANTED',
` ${resolved.label}`,
` for ${crime}`,
` reward: ${bounty} credits`,
'```',
].join('\n') + priors);
}
return {
bonk: handleBonk,
hug: handleHug,
slap: handleSlap,
'8ball': handleEightBall,
roll: handleRoll,
coin: handleCoin,
ship: handleShip,
rate: handleRate,
uwu: handleUwu,
wanted: handleWanted,
};
}
module.exports = {
createFunTextCommands,
// Exported for tests: the parsing and transform rules are the parts most
// likely to regress, and they are pure.
parseDiceSpec,
uwuify,
};
@@ -1,304 +0,0 @@
// Operator Fun Text Command Tests
// Purpose: Verifies tally credit, self-targeting, cooldown refusal, mention sanitizing, and dice parsing.
// Scope: Uses an in-memory stats double so no test touches the fun stats file.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunTextCommands, parseDiceSpec, uwuify } = require('./funText');
const { createCooldownGate } = require('../cooldowns');
function createStatsDouble() {
const store = new Map();
return {
calls: [],
bumpActorStats(actorKey, { label = null, ...patch } = {}) {
this.calls.push({ actorKey, label, patch });
const current = store.get(actorKey) || {};
const next = { ...current, label: label || current.label };
Object.keys(patch).forEach((key) => {
next[key] = (Number(current[key]) || 0) + Number(patch[key] || 0);
});
store.set(actorKey, next);
return next;
},
getActorStats(actorKey) {
return store.get(actorKey) || {};
},
listActorStats() {
return Array.from(store.entries()).map(([actorKey, value]) => ({ actorKey, ...value }));
},
};
}
function createHarness({ sockets = [], activeDrivers = {} } = {}) {
const stats = createStatsDouble();
const events = [];
const handlers = createFunTextCommands({
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
publishEvent: (event) => events.push(event),
// Matches the real sanitizer so tests exercise the actual escaping rules.
sanitizeMentions: (text) => String(text || '')
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
.replace(/@everyone/gi, '[everyone]')
.replace(/@here/gi, '[here]'),
funStatsService: stats,
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
return { handlers, stats, events };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
const bob = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
test('bonk credits both sides and reports the running tally', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message();
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Bonked bob\./);
assert.match(msg.replies[0].content, /1st bonk/);
assert.deepEqual(
stats.calls.map((call) => [call.actorKey, Object.keys(call.patch)[0]]),
[['user:u-alice', 'bonksGiven'], ['user:u-bob', 'bonksTaken']],
);
});
test('the tally ordinal advances across repeat bonks', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
// A second actor avoids the first actor's cooldown while still hitting bob.
const second = message({ id: 's3', userId: 'u-carol', label: 'carol' });
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /2nd bonk/);
});
test('bonking an offline name still replies but credits nobody', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message();
await handlers.bonk(msg, ['the', 'dishwasher']);
assert.match(msg.replies[0].content, /Bonked the dishwasher\./);
assert.doesNotMatch(msg.replies[0].content, /bonk\b.*\dst|\dnd|\drd|\dth/);
assert.deepEqual(stats.calls.map((call) => call.actorKey), ['user:u-alice']);
});
test('self-bonking is a special case and records nothing', async () => {
const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const { handlers, stats } = createHarness({ sockets: [alice] });
const msg = message();
await handlers.bonk(msg, ['alice']);
assert.match(msg.replies[0].content, /themselves/);
assert.equal(stats.calls.length, 0);
});
test('a repeat inside the cooldown window is refused and records nothing extra', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
const countAfterFirst = stats.calls.length;
const second = message();
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /Slow down/);
assert.equal(stats.calls.length, countAfterFirst);
});
test('cooldowns are per command, so a bonk does not block a hug', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
const hug = message();
await handlers.hug(hug, ['bob']);
assert.doesNotMatch(hug.replies[0].content, /Slow down/);
});
test('a missing target replies with usage and does not burn the cooldown', async () => {
const { handlers } = createHarness({ sockets: [bob] });
const first = message();
await handlers.bonk(first, []);
assert.match(first.replies[0].content, /Usage: `rs bonk <user>`/);
const second = message();
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /Bonked bob/);
});
test('every reply is sanitized so a fun command cannot ping a whole guild', async () => {
const { handlers } = createHarness();
const msg = message();
await handlers.bonk(msg, ['@everyone']);
assert.doesNotMatch(msg.replies[0].content, /@everyone/);
assert.match(msg.replies[0].content, /\[everyone\]/);
const roleMsg = message({ id: 's9', userId: 'u-dave', label: 'dave' });
await handlers.slap(roleMsg, ['<@&123456>']);
assert.match(roleMsg.replies[0].content, /\[ping removed\]/);
});
test('an actor with no identity at all is refused rather than sharing a tally', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message({ label: 'ghost' });
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Could not identify you/);
assert.equal(stats.calls.length, 0);
});
test('ship agrees with itself regardless of argument order', async () => {
const { handlers } = createHarness();
const forward = message();
await handlers.ship(forward, ['alice', 'and', 'bob']);
const { handlers: other } = createHarness();
const backward = message();
await other.ship(backward, ['bob', 'and', 'alice']);
const score = (text) => /\*\*(\d+)%\*\*/.exec(text)[1];
assert.equal(score(forward.replies[0].content), score(backward.replies[0].content));
});
test('ship needs two sides', async () => {
const { handlers } = createHarness();
const msg = message();
await handlers.ship(msg, ['alice']);
assert.match(msg.replies[0].content, /Usage: `rs ship/);
});
test('8ball gives the same answer to the same question', async () => {
const first = createHarness();
const a = message();
await first.handlers['8ball'](a, ['will', 'it', 'dock']);
const second = createHarness();
const b = message();
await second.handlers['8ball'](b, ['WILL', 'IT', 'DOCK']);
assert.equal(a.replies[0].content.split('\n')[1], b.replies[0].content.split('\n')[1]);
});
test('rate stays inside 0 to 10', async () => {
for (const thing of ['carpet', 'the dock', 'a', 'zzzzzz', 'rover 3']) {
const { handlers } = createHarness();
const msg = message();
await handlers.rate(msg, [thing]);
const score = Number(/\*\*(\d+)\/10\*\*/.exec(msg.replies[0].content)[1]);
assert.ok(score >= 0 && score <= 10, `${thing} scored ${score}`);
}
});
test('dice specs parse the accepted forms and reject the rest', () => {
assert.deepEqual(parseDiceSpec('2d6'), { count: 2, sides: 6 });
assert.deepEqual(parseDiceSpec('d20'), { count: 1, sides: 20 });
assert.deepEqual(parseDiceSpec(''), { count: 1, sides: 6 });
// A bare number is read as one die of that many sides.
assert.deepEqual(parseDiceSpec('20'), { count: 1, sides: 20 });
assert.match(parseDiceSpec('21d6').error, /between 1 and 20 dice/);
assert.match(parseDiceSpec('1d1').error, /2 and 1000 sides/);
assert.match(parseDiceSpec('1d2000').error, /2 and 1000 sides/);
assert.match(parseDiceSpec('banana').error, /NdN/);
});
test('roll totals stay within the possible range for the spec', async () => {
for (let attempt = 0; attempt < 25; attempt += 1) {
const { handlers } = createHarness();
const msg = message();
await handlers.roll(msg, ['3d6']);
const total = Number(/\*\*(\d+)\*\*/.exec(msg.replies[0].content)[1]);
assert.ok(total >= 3 && total <= 18, `rolled ${total}`);
}
});
test('uwu transforms text without dropping it', () => {
assert.equal(uwuify('hello world'), 'hewwo wowwd');
assert.equal(uwuify('love'), 'wuv');
assert.equal(uwuify('nice'), 'nyice');
});
test('bonking someone who is driving announces the sound for their rover', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['bob']);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'fun.bonked');
assert.equal(events[0].payload.roverId, 'rover-1');
assert.equal(events[0].payload.targetLabel, 'bob');
});
test('bonking someone who is not driving announces nothing', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: {} });
const msg = message();
await handlers.bonk(msg, ['bob']);
// The text bonk still lands and is still tallied; only the sound is skipped.
assert.match(msg.replies[0].content, /Bonked bob/);
assert.equal(events.length, 0);
});
test('bonking a name that is not a real user announces nothing', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['the dishwasher']);
assert.equal(events.length, 0);
});
test('the bonk sound is rate limited per rover so it cannot interrupt a mic repeatedly', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['bob']);
// A different actor has their own text cooldown but must not get a second sound.
await handlers.bonk(message({ id: 's3', userId: 'u-carol', label: 'carol' }), ['bob']);
await handlers.bonk(message({ id: 's4', userId: 'u-erin', label: 'erin' }), ['bob']);
assert.equal(events.length, 1);
});
test('a self-bonk never announces a sound', async () => {
const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const { handlers, events } = createHarness({ sockets: [alice], activeDrivers: { 'rover-1': 's1' } });
await handlers.bonk(message(), ['alice']);
assert.equal(events.length, 0);
});
test('hug and slap do not announce a bonk sound', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.hug(message(), ['bob']);
await handlers.slap(message(), ['bob']);
assert.equal(events.length, 0);
});
test('a transport with no publishEvent still bonks normally', async () => {
const stats = createStatsDouble();
const handlers = createFunTextCommands({
io: { sockets: { sockets: new Map([[bob.id, bob]]) } },
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => ({ 'rover-1': 's2' }),
publishEvent: undefined,
sanitizeMentions: (text) => String(text || ''),
funStatsService: stats,
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
const msg = message();
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Bonked bob/);
});
test('wanted includes prior bonks when the target has any on record', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
const msg = message({ id: 's4', userId: 'u-erin', label: 'erin' });
await handlers.wanted(msg, ['bob']);
assert.match(msg.replies[0].content, /WANTED/);
assert.match(msg.replies[0].content, /Prior bonks on record: 1/);
});
@@ -98,10 +98,10 @@ function createGainCommand({
'**Audio gain boost**', '**Audio gain boost**',
'Raises a user\'s volume ceiling past the global gains, still bounded by the hard caps.', 'Raises a user\'s volume ceiling past the global gains, still bounded by the hard caps.',
'', '',
`\`${commandPrefix} gain list\` show everyone who holds the boost.`, `- \`${commandPrefix} gain list\`: show everyone who holds the boost.`,
`\`${commandPrefix} gain grant <vip>\` give the boost to a verified user.`, `- \`${commandPrefix} gain grant <vip>\`: give the boost to a verified user.`,
`\`${commandPrefix} gain revoke <vip>\` take the boost away.`, `- \`${commandPrefix} gain revoke <vip>\`: take the boost away.`,
`\`${commandPrefix} gain help\` show this.`, `- \`${commandPrefix} gain help\`: show this.`,
'', '',
'A user can be named by nickname, userId, or cookieUserId. Only verified (VIP)', 'A user can be named by nickname, userId, or cookieUserId. Only verified (VIP)',
'users can be granted the boost. If several accounts share a nickname, the one', 'users can be granted the boost. If several accounts share a nickname, the one',
@@ -218,6 +218,9 @@ test('help lists every subcommand', async () => {
for (const fragment of ['rs gain list', 'rs gain grant <vip>', 'rs gain revoke <vip>', 'rs gain help']) { for (const fragment of ['rs gain list', 'rs gain grant <vip>', 'rs gain revoke <vip>', 'rs gain help']) {
assert.ok(replies[0].content.includes(fragment), `help should mention ${fragment}`); assert.ok(replies[0].content.includes(fragment), `help should mention ${fragment}`);
} }
// Subcommand help follows the same copy-friendly punctuation rule as the
// shared `rs help` renderer instead of quietly reintroducing em dashes.
assert.doesNotMatch(replies[0].content, /—/);
}); });
test('an unknown subcommand falls back to the same help text', async () => { test('an unknown subcommand falls back to the same help text', async () => {
@@ -1,70 +0,0 @@
// Operator Command Cooldowns
// Purpose: Rate limits individual commands per actor without coupling to a transport.
// Scope: In-memory only; a restart clears every cooldown by design.
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000;
/*
Fun commands are reachable from both site chat and Discord, and site chat's own
rate limit only bounds messages per socket rather than a specific command. A
per-actor, per-command gate is what stops one person turning `rs honk` into a
siren, and it is deliberately in-memory: a cooldown that survives a restart
would be a moderation feature, not a spam guard.
*/
function createCooldownGate({ sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS } = {}) {
const expiries = new Map();
let lastSweep = 0;
function sweep(now) {
if (now - lastSweep < sweepIntervalMs) return;
lastSweep = now;
expiries.forEach((expiresAt, key) => {
if (expiresAt <= now) expiries.delete(key);
});
}
function remaining(key, now = Date.now()) {
const expiresAt = expiries.get(String(key));
if (!expiresAt) return 0;
return Math.max(0, expiresAt - now);
}
/*
Returns the remaining wait when the gate is closed, or 0 after arming the
next window. Callers therefore treat any non-zero result as a refusal, and a
refused call never extends the existing window.
*/
function consume(key, windowMs, now = Date.now()) {
const normalizedKey = String(key || '').trim();
if (!normalizedKey) return 0;
const window = Number(windowMs);
if (!Number.isFinite(window) || window <= 0) return 0;
sweep(now);
const wait = remaining(normalizedKey, now);
if (wait > 0) return wait;
expiries.set(normalizedKey, now + window);
return 0;
}
function clear(key) {
expiries.delete(String(key || '').trim());
}
function reset() {
expiries.clear();
lastSweep = 0;
}
return { consume, remaining, clear, reset };
}
function describeWait(waitMs) {
const seconds = Math.ceil(Number(waitMs || 0) / 1000);
if (seconds <= 1) return '1s';
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
}
module.exports = { createCooldownGate, describeWait };
@@ -1,53 +0,0 @@
// Operator Command Cooldown Tests
// Purpose: Verifies the per-actor rate limit opens and closes on the boundaries callers rely on.
// Scope: Pure; the gate takes an injected clock so no test needs to sleep.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createCooldownGate, describeWait } = require('./cooldowns');
test('first use passes and an immediate repeat is refused', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('bonk:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 1000, 0), 1000);
assert.equal(gate.consume('bonk:alice', 1000, 400), 600);
});
test('the window reopens exactly when it expires', () => {
const gate = createCooldownGate();
gate.consume('honk:alice', 1000, 0);
assert.equal(gate.consume('honk:alice', 1000, 999), 1);
assert.equal(gate.consume('honk:alice', 1000, 1000), 0);
});
test('a refused call does not extend the existing window', () => {
const gate = createCooldownGate();
gate.consume('honk:alice', 1000, 0);
// Hammering the gate at t=500 must not push the reopen time out to t=1500.
gate.consume('honk:alice', 1000, 500);
gate.consume('honk:alice', 1000, 900);
assert.equal(gate.consume('honk:alice', 1000, 1000), 0);
});
test('cooldowns are scoped per key so different actors and commands do not collide', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('bonk:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:bob', 1000, 0), 0);
assert.equal(gate.consume('hug:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 1000, 0), 1000);
});
test('a missing key or non-positive window never gates', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 0, 0), 0);
assert.equal(gate.consume('bonk:alice', -5, 0), 0);
// None of the above should have armed anything.
assert.equal(gate.remaining('bonk:alice', 0), 0);
});
test('describeWait rounds up and switches to minutes', () => {
assert.equal(describeWait(1), '1s');
assert.equal(describeWait(4200), '5s');
assert.equal(describeWait(60 * 1000), '1m');
assert.equal(describeWait(95 * 1000), '1m 35s');
});
@@ -25,7 +25,7 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = ''
const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic; const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic;
const categoryNames = requestedCategory && CATEGORIES[requestedCategory] const categoryNames = requestedCategory && CATEGORIES[requestedCategory]
? [requestedCategory] ? [requestedCategory]
: ['system', 'admin', 'features', 'fun', ...(includeDiscord ? ['discord'] : [])]; : ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])];
const output = ['**Rover Bot Commands**']; const output = ['**Rover Bot Commands**'];
for (const categoryName of categoryNames) { for (const categoryName of categoryNames) {
@@ -36,7 +36,9 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = ''
const entry = entries[name]; const entry = entries[name];
if (!entry?.usage?.length) continue; if (!entry?.usage?.length) continue;
const availability = entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature) ? ' *(unavailable)*' : ''; const availability = entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature) ? ' *(unavailable)*' : '';
output.push(`\`${entry.usage[0]}\`${entry.summary}${availability}`); // A colon stays readable in both Discord and site chat while avoiding the
// typographic punctuation that made command help awkward to copy or edit.
output.push(`- \`${entry.usage[0]}\`: ${entry.summary}${availability}`);
} }
} }
output.push('', `Use \`${prefix} help <command|category>\` for details.`); output.push('', `Use \`${prefix} help <command|category>\` for details.`);
@@ -0,0 +1,37 @@
// Operator Command Help Tests
// Purpose: Protects the shared chat-help layout and Discord's hard message-size boundary.
// Scope: Tests rendered help text only; command execution and permissions remain dispatcher concerns.
const test = require('node:test');
const assert = require('node:assert/strict');
const { formatHelp } = require('./help');
// Discord rejects a normal message above 2,000 characters instead of silently
// splitting it. Keeping this assertion beside the help renderer prevents a new
// command description from making `rs help` fail only on the Discord transport.
const DISCORD_MESSAGE_LIMIT = 2000;
test('complete Discord help fits in one message and uses simple punctuation', () => {
const help = formatHelp({ includeDiscord: true, isFeatureEnabled: () => true });
assert.ok(help.length <= DISCORD_MESSAGE_LIMIT, `help is ${help.length} characters`);
assert.doesNotMatch(help, /—/);
assert.match(help, /- `rs status \[rover\]`: Show rover status/);
assert.match(help, /\*\*Discord\*\*/);
});
test('removed fun commands and their category are absent from help', () => {
const help = formatHelp({ includeDiscord: true, isFeatureEnabled: () => true });
assert.doesNotMatch(help, /\*\*Fun\*\*/);
for (const command of ['bonk', 'hug', 'slap', 'bonkboard', '8ball', 'roll', 'coin', 'ship', 'rate', 'uwu', 'wanted', 'pet', 'snitch', 'honk', 'boo', 'spin', 'disco', 'vibecheck']) {
assert.doesNotMatch(help, new RegExp(`rs ${command}(?:\\s|\\x60)`), `${command} should not be advertised`);
}
});
test('detailed help keeps usage readable without em dashes', () => {
const help = formatHelp({ topic: 'deter', includeDiscord: true, isFeatureEnabled: () => true });
assert.match(help, /\*\*deter\*\*/);
assert.match(help, /Usage:\n- `rs deter list`/);
assert.doesNotMatch(help, /—/);
});
@@ -13,10 +13,6 @@ 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');
const { createNeatoCommand } = require('./commands/neato'); const { createNeatoCommand } = require('./commands/neato');
const { createFunTextCommands } = require('./commands/funText');
const { createFunStatsCommands } = require('./commands/funStats');
const { createFunRoverCommands } = require('./commands/funRover');
const { createCooldownGate } = require('./cooldowns');
const { getCommandConfig } = require('./config'); const { getCommandConfig } = require('./config');
const { buildCommandRegistry } = require('./registry'); const { buildCommandRegistry } = require('./registry');
@@ -69,20 +65,6 @@ function createCommandHandlers(deps) {
const handleLiftCommand = createLiftCommand(deps); const handleLiftCommand = createLiftCommand(deps);
const handleNeatoCommand = createNeatoCommand(deps); const handleNeatoCommand = createNeatoCommand(deps);
/*
Fun commands share one cooldown gate. Web chat rebuilds this router per
message, so an injected gate is what makes the cooldowns actually shared
across a user's messages; a gate created here would be discarded every time
and rate limit nothing. Falling back to a local gate keeps the router usable
on its own (and in tests) without making every caller supply one.
*/
const cooldowns = deps.commandCooldowns || createCooldownGate();
const funHandlers = {
...createFunTextCommands({ ...deps, cooldowns }),
...createFunStatsCommands({ ...deps, cooldowns }),
...createFunRoverCommands({ ...deps, cooldowns }),
};
function stripCommandPrefix(content) { function stripCommandPrefix(content) {
const trimmed = String(content || '').trim(); const trimmed = String(content || '').trim();
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
@@ -128,8 +110,6 @@ function createCommandHandlers(deps) {
// 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', 'gain', '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';
const isPublicCommand = commandDefinition?.permission === 'public';
const isFunCommand = commandDefinition?.category === 'fun';
// Feature commands are public activities while access is open or managed // Feature commands are public activities while access is open or managed
// by turns. In admin mode they follow the same admin-only boundary as rover // by turns. In admin mode they follow the same admin-only boundary as rover
@@ -141,17 +121,12 @@ function createCommandHandlers(deps) {
return; return;
} }
if (!isAccessModeCommand && !isPublicCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) { if (!isAccessModeCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) {
await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
/* if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
Lockdown exists to make the server quiet and controlled, so the whole fun
category is suspended alongside the moderation-sensitive actions rather than
leaving a horn command reachable by anyone while the fleet is locked down.
*/
if (mode === MODES.LOCKDOWN && (moderationActions.has(action) || isFunCommand) && !isLockdownAdmin) {
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
@@ -192,7 +167,6 @@ function createCommandHandlers(deps) {
case 'gain': case 'gain':
return handleGainCommand(request, tokens); return handleGainCommand(request, tokens);
default: default:
if (funHandlers[action]) return funHandlers[action](request, tokens);
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 }));
} }
} }
@@ -1,5 +1,5 @@
// Operator Command Dispatcher Tests // Operator Command Dispatcher Tests
// Purpose: Pins the permission, mode, and prefix policy that the registry-driven gate replaced a hardcoded action list with. // Purpose: Pins shared command permissions, access modes, and prefix parsing.
// Scope: Exercises the router with doubles; individual command behavior is covered by each command's own tests. // Scope: Exercises the router with doubles; individual command behavior is covered by each command's own tests.
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -10,8 +10,6 @@ const ADMIN_DENIAL = /Only admins can run that command/;
const LOCKDOWN_DENIAL = /Lockdown mode: only lockdown admins/; const LOCKDOWN_DENIAL = /Lockdown mode: only lockdown admins/;
const FEATURE_DENIAL = /Admin mode: only admins can run feature commands/; const FEATURE_DENIAL = /Admin mode: only admins can run feature commands/;
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) { function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
const rovers = new Map([['rover-1', { const rovers = new Map([['rover-1', {
id: 'rover-1', id: 'rover-1',
@@ -22,28 +20,15 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
const { handleCommand } = createCommandHandlers({ const { handleCommand } = createCommandHandlers({
logger: { warn: () => {}, info: () => {} }, logger: { warn: () => {}, info: () => {} },
io: { sockets: { sockets: new Map([[ALICE.id, ALICE]]) } }, io: { sockets: { sockets: new Map() } },
rovers, rovers,
roverManager: { roverManager: {},
canDrive: () => true,
getPrimaryRoverForSocket: () => 'rover-1',
applyPrivateDriveSafety: () => null,
},
getMode: () => mode, getMode: () => mode,
MODES, MODES,
getNickname: (entry) => entry?.data?.nickname || '', getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => ({}), getActiveDrivers: () => ({}),
getActorSocket: () => ALICE,
issueCommand: () => 'cmd-1',
isFeatureEnabled: () => featureEnabled, isFeatureEnabled: () => featureEnabled,
sanitizeMentions: (text) => String(text || ''), sanitizeMentions: (text) => String(text || ''),
funStatsService: {
bumpActorStats: () => ({}),
getActorStats: () => ({}),
listActorStats: () => [],
bumpRoverPets: () => 1,
getRoverPets: () => 0,
},
homeAssistantService: { getLightPolicyState: () => ({}), setAllControllableEntitiesState: () => Promise.resolve() }, homeAssistantService: { getLightPolicyState: () => ({}), setAllControllableEntitiesState: () => Promise.resolve() },
liftService: null, liftService: null,
neatoService: null, neatoService: null,
@@ -75,16 +60,6 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
const nonAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: false, isLockdownAdmin: false }; const nonAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: false, isLockdownAdmin: false };
const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: false }; const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: false };
const lockdownAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: true };
test('a non-admin can run fun commands', async () => {
const run = createRouter();
for (const command of ['rs coin', 'rs rate carpet', 'rs uwu hi', 'rs bonkboard', 'rs vibecheck', 'rs snitch']) {
const reply = await run(command, nonAdmin);
assert.doesNotMatch(reply, ADMIN_DENIAL, `${command} should be public`);
assert.ok(reply.length > 0, `${command} should reply`);
}
});
test('admin-only commands stay admin-only for a non-admin', async () => { test('admin-only commands stay admin-only for a non-admin', async () => {
const run = createRouter(); const run = createRouter();
@@ -109,30 +84,9 @@ test('system commands remain reachable by anyone', async () => {
assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/); assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/);
}); });
test('the fun category appears in help', async () => { test('admin mode restricts access-mode feature commands', async () => {
const run = createRouter();
const help = await run('rs help fun', nonAdmin);
assert.match(help, /\*\*Fun\*\*/);
for (const command of ['bonk', 'honk', 'disco', 'vibecheck', 'bonkboard']) {
assert.match(help, new RegExp(`rs ${command}`), `help should list ${command}`);
}
});
test('admin mode restricts access-mode feature commands but not the public fun ones', async () => {
const run = createRouter({ mode: MODES.ADMIN }); const run = createRouter({ mode: MODES.ADMIN });
assert.match(await run('rs lights on', nonAdmin), FEATURE_DENIAL); assert.match(await run('rs lights on', nonAdmin), FEATURE_DENIAL);
assert.match(await run('rs disco', nonAdmin), FEATURE_DENIAL);
assert.doesNotMatch(await run('rs coin', nonAdmin), FEATURE_DENIAL);
});
test('lockdown suspends the whole fun category for anyone but a lockdown admin', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
for (const command of ['rs coin', 'rs bonk bob', 'rs honk', 'rs disco', 'rs vibecheck']) {
assert.match(await run(command, nonAdmin), LOCKDOWN_DENIAL, `${command} should be suspended in lockdown`);
}
// A plain admin is not enough during lockdown.
assert.match(await run('rs coin', admin), LOCKDOWN_DENIAL);
assert.doesNotMatch(await run('rs coin', lockdownAdmin), LOCKDOWN_DENIAL);
}); });
test('lockdown still suspends the pre-existing moderation-sensitive commands', async () => { test('lockdown still suspends the pre-existing moderation-sensitive commands', async () => {
@@ -150,7 +104,6 @@ test('status and help survive lockdown', async () => {
test('a disabled required feature is reported before any permission check', async () => { test('a disabled required feature is reported before any permission check', async () => {
const run = createRouter({ featureEnabled: false }); const run = createRouter({ featureEnabled: false });
assert.match(await run('rs disco', nonAdmin), /Home Assistant feature is not configured/);
assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is not configured/); assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is not configured/);
}); });
@@ -171,11 +124,10 @@ test('an unknown command is not treated as public', async () => {
test('bot actors are ignored entirely', async () => { test('bot actors are ignored entirely', async () => {
const run = createRouter(); const run = createRouter();
assert.equal(await run('rs coin', { ...nonAdmin, bot: true }), ''); assert.equal(await run('rs status', { ...nonAdmin, bot: true }), '');
}); });
test('command matching is case insensitive', async () => { test('command matching is case insensitive', async () => {
const run = createRouter(); const run = createRouter();
assert.doesNotMatch(await run('RS COIN', nonAdmin), ADMIN_DENIAL);
assert.match(await run('Rs Status', nonAdmin), /\[status handler\]/); assert.match(await run('Rs Status', nonAdmin), /\[status handler\]/);
}); });
@@ -5,13 +5,6 @@ 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', 'gain'] }, 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'] },
fun: {
title: 'Fun',
names: [
'bonk', 'hug', 'slap', 'bonkboard', '8ball', 'roll', 'coin', 'ship', 'rate', 'uwu',
'wanted', 'pet', 'snitch', 'honk', 'boo', 'spin', 'disco', 'vibecheck',
],
},
discord: { title: 'Discord', names: ['bridge'] }, discord: { title: 'Discord', names: ['bridge'] },
}; };
@@ -68,40 +61,6 @@ function buildCommandRegistry(prefix, timeCommand) {
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' },
/*
Fun commands are the first entries to use `permission: 'public'`. Before
they existed, every non-admin-reachable command was named in a hardcoded
allowlist in the dispatcher; declaring the permission here instead means a
new fun command does not need a dispatcher edit to be usable.
*/
bonk: { category: 'fun', summary: 'Bonk someone. Keeps a running tally.', usage: [`${prefix} bonk <user>`], access: 'Public', permission: 'public' },
hug: { category: 'fun', summary: 'Hug someone.', usage: [`${prefix} hug <user>`], access: 'Public', permission: 'public' },
slap: { category: 'fun', summary: 'Slap someone with a random object.', usage: [`${prefix} slap <user>`], access: 'Public', permission: 'public' },
bonkboard: { category: 'fun', summary: 'Show the bonk and hug leaderboards.', usage: [`${prefix} bonkboard`], access: 'Public', permission: 'public' },
'8ball': { category: 'fun', summary: 'Ask the magic 8 ball. Same question always gets the same answer.', usage: [`${prefix} 8ball <question>`], access: 'Public', permission: 'public' },
roll: { category: 'fun', summary: 'Roll dice.', usage: [`${prefix} roll [NdN]`], access: 'Public', permission: 'public' },
coin: { category: 'fun', summary: 'Flip a coin.', usage: [`${prefix} coin`], access: 'Public', permission: 'public' },
ship: { category: 'fun', summary: 'Rate a pairing out of 100.', usage: [`${prefix} ship <a> and <b>`], access: 'Public', permission: 'public' },
rate: { category: 'fun', summary: 'Rate anything out of 10.', usage: [`${prefix} rate <thing>`], access: 'Public', permission: 'public' },
uwu: { category: 'fun', summary: 'Ruin some text.', usage: [`${prefix} uwu <text>`], access: 'Public', permission: 'public' },
wanted: { category: 'fun', summary: 'Issue a wanted poster.', usage: [`${prefix} wanted <user>`], access: 'Public', permission: 'public' },
pet: { category: 'fun', summary: 'Pet a rover. Each rover keeps its own count.', usage: [`${prefix} pet [rover]`], access: 'Public', permission: 'public' },
snitch: { category: 'fun', summary: 'Report who is driving what.', usage: [`${prefix} snitch`], access: 'Public', permission: 'public' },
// honk and spin move hardware, so their handlers additionally require that the
// caller actually holds control of the rover they name.
honk: { category: 'fun', summary: 'Sound a short horn toot on a rover you control.', usage: [`${prefix} honk [rover]`], access: 'Public; requires control of the rover', permission: 'public' },
boo: { category: 'fun', summary: 'Speak a taunt through the rover someone is driving.', usage: [`${prefix} boo <user>`], access: 'Public', permission: 'public' },
spin: { category: 'fun', summary: 'Make a rover you control do a spin.', usage: [`${prefix} spin [rover]`], access: 'Public; requires control of the rover', permission: 'public' },
vibecheck: { category: 'fun', summary: 'Judge a rover\'s vibes and report its battery.', usage: [`${prefix} vibecheck [rover]`], access: 'Public', permission: 'public' },
disco: {
category: 'fun',
summary: 'Strobe the room lights briefly.',
usage: [`${prefix} disco`],
access: 'Public unless server access is restricted; obeys the room-light lock',
permission: 'access-mode',
requiredFeature: 'homeAssistant',
unavailableLabel: 'Home Assistant',
},
}; };
} }