From 9702cf0f829c958ba925a5dae241d5a61bea70d0 Mon Sep 17 00:00:00 2001 From: legop3 Date: Fri, 7 Aug 2026 15:54:28 -0400 Subject: [PATCH] gruh i hate fun!! --- .gitignore | 1 + server/install_server.sh | 2 - .../services/audioForwardService/bonkSound.js | 55 --- .../audioForwardService/bonkSound.test.js | 84 ---- .../src/services/audioForwardService/index.js | 6 - .../src/services/chatService/textCommands.js | 29 -- .../src/services/discordBotService/index.js | 21 +- server/src/services/funStatsService/index.js | 178 --------- .../services/funStatsService/index.test.js | 131 ------- .../services/mediaMtxService/supervisor.js | 15 +- .../commands/funHelpers.js | 174 --------- .../commands/funHelpers.test.js | 162 -------- .../commands/funRover.js | 285 -------------- .../commands/funRover.test.js | 297 -------------- .../commands/funStats.js | 118 ------ .../commands/funStats.test.js | 166 -------- .../commands/funText.js | 361 ------------------ .../commands/funText.test.js | 304 --------------- .../operatorCommandService/commands/gain.js | 8 +- .../commands/gain.test.js | 3 + .../operatorCommandService/cooldowns.js | 70 ---- .../operatorCommandService/cooldowns.test.js | 53 --- .../services/operatorCommandService/help.js | 6 +- .../operatorCommandService/help.test.js | 37 ++ .../services/operatorCommandService/index.js | 30 +- .../operatorCommandService/index.test.js | 58 +-- .../operatorCommandService/registry.js | 41 -- 27 files changed, 67 insertions(+), 2628 deletions(-) delete mode 100644 server/src/services/audioForwardService/bonkSound.js delete mode 100644 server/src/services/audioForwardService/bonkSound.test.js delete mode 100644 server/src/services/funStatsService/index.js delete mode 100644 server/src/services/funStatsService/index.test.js delete mode 100644 server/src/services/operatorCommandService/commands/funHelpers.js delete mode 100644 server/src/services/operatorCommandService/commands/funHelpers.test.js delete mode 100644 server/src/services/operatorCommandService/commands/funRover.js delete mode 100644 server/src/services/operatorCommandService/commands/funRover.test.js delete mode 100644 server/src/services/operatorCommandService/commands/funStats.js delete mode 100644 server/src/services/operatorCommandService/commands/funStats.test.js delete mode 100644 server/src/services/operatorCommandService/commands/funText.js delete mode 100644 server/src/services/operatorCommandService/commands/funText.test.js delete mode 100644 server/src/services/operatorCommandService/cooldowns.js delete mode 100644 server/src/services/operatorCommandService/cooldowns.test.js create mode 100644 server/src/services/operatorCommandService/help.test.js diff --git a/.gitignore b/.gitignore index c4a49cc5..9e73a2d5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ server/data/admin-reason.json server/data/buttonbox-state.json server/data/barcode-tts-cache/ server/data/rover-odometers.json +server/data/mediamtx.yml webui/package-lock.json !server/data/ !server/data/barcode-registry.json diff --git a/server/install_server.sh b/server/install_server.sh index 01660661..4ef62c5d 100755 --- a/server/install_server.sh +++ b/server/install_server.sh @@ -300,8 +300,6 @@ Environment=SERVER_CONFIG=$CONFIG_PATH Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN -RuntimeDirectory=multirover -RuntimeDirectoryMode=0750 ExecStart=$NODE_BIN $SERVER_DIR/index.js Restart=on-failure RestartSec=2 diff --git a/server/src/services/audioForwardService/bonkSound.js b/server/src/services/audioForwardService/bonkSound.js deleted file mode 100644 index 33bd6e2b..00000000 --- a/server/src/services/audioForwardService/bonkSound.js +++ /dev/null @@ -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, -}; diff --git a/server/src/services/audioForwardService/bonkSound.test.js b/server/src/services/audioForwardService/bonkSound.test.js deleted file mode 100644 index 70db57c6..00000000 --- a/server/src/services/audioForwardService/bonkSound.test.js +++ /dev/null @@ -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 }); -}); diff --git a/server/src/services/audioForwardService/index.js b/server/src/services/audioForwardService/index.js index 04a89404..7778e32c 100644 --- a/server/src/services/audioForwardService/index.js +++ b/server/src/services/audioForwardService/index.js @@ -14,7 +14,6 @@ const { createAudioForwardPolicy } = require('./policy'); const { createAudioForwardWorkerEngine } = require('./workerEngine'); const { registerAudioForwardHooks } = require('./hooks'); const { registerChargeCompleteSound } = require('./chargeCompleteSound'); -const { registerBonkSound } = require('./bonkSound'); const audioForwardEvents = new EventEmitter(); const config = loadConfig(); @@ -152,11 +151,6 @@ registerChargeCompleteSound({ playServerAudioFile, }); -registerBonkSound({ - logger, - playServerAudioFile, -}); - module.exports = { getAudioForwardState, audioForwardEvents, diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index f979d012..8c15d39b 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -28,10 +28,8 @@ const { } = require('../verificationService'); const { publishEvent } = require('../eventBus'); const assignmentService = require('../assignmentService'); -const funStatsService = require('../funStatsService'); const { loadConfig } = require('../../helpers/configLoader'); const { createCommandHandlers } = require('../operatorCommandService'); -const { createCooldownGate } = require('../operatorCommandService/cooldowns'); const { parseCommandText } = require('../operatorCommandService/config'); const { createWebTransportHandlers } = require('../operatorCommandService/webTransport'); const { commandReplyToText } = require('./commandResultFormatter'); @@ -44,14 +42,6 @@ const { const config = loadConfig(); 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) { return parseCommandText(text, config).matched; } @@ -133,12 +123,6 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) { actor: { bot: false, 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, isAdmin: isAdmin(socket), isLockdownAdmin: isLockdownAdmin(socket), @@ -207,19 +191,6 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) { grantAudioGainBoost, revokeAudioGainBoost, 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, isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index e187dfee..7a671993 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -56,13 +56,10 @@ const { approveRequest: approvePrivateAccessRequest, denyRequest: denyPrivateAccessRequest, } = require('../privateRoverAccessRequestService'); -const { subscribe, publishEvent } = require('../eventBus'); -const funStatsService = require('../funStatsService'); -const { issueCommand } = require('../commandService'); +const { subscribe } = require('../eventBus'); const { createPresenceManager } = require('./presence'); const { createChannelIO } = require('./channelIO'); const { createCommandHandlers } = require('../operatorCommandService'); -const { createCooldownGate } = require('../operatorCommandService/cooldowns'); const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter'); const { createIntegrations } = require('./integrations'); 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 = { logger, client, @@ -264,18 +257,6 @@ const commandDependencies = { grantAudioGainBoost, revokeAudioGainBoost, 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, isAdminUser, isLockdownAdminUser, diff --git a/server/src/services/funStatsService/index.js b/server/src/services/funStatsService/index.js deleted file mode 100644 index cff4d5e7..00000000 --- a/server/src/services/funStatsService/index.js +++ /dev/null @@ -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:` for site chat, `discord:` 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, -}; diff --git a/server/src/services/funStatsService/index.test.js b/server/src/services/funStatsService/index.test.js deleted file mode 100644 index 45bfe083..00000000 --- a/server/src/services/funStatsService/index.test.js +++ /dev/null @@ -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 }); -}); diff --git a/server/src/services/mediaMtxService/supervisor.js b/server/src/services/mediaMtxService/supervisor.js index 64cbc8a0..e49c9784 100644 --- a/server/src/services/mediaMtxService/supervisor.js +++ b/server/src/services/mediaMtxService/supervisor.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const yaml = require('js-yaml'); +const { resolveDataPath } = require('../../helpers/dataPaths'); const { buildMediaMtxConfig } = require('./config'); function createMediaMtxSupervisor(deps) { @@ -13,7 +14,7 @@ function createMediaMtxSupervisor(deps) { serverPort, logger, 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', spawnProcess = spawn, } = deps; @@ -39,10 +40,14 @@ function createMediaMtxSupervisor(deps) { function start() { if (child) return child; - const runtimeConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }); - const configPath = path.join(runtimeDir, 'mediamtx.yml'); - fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o750 }); - fs.writeFileSync(configPath, yaml.dump(runtimeConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 }); + const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }); + /* + Generated MediaMTX state belongs beside the server's other owned data. Using the shared + 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}`); child = spawnProcess(mediaMtxBin, [configPath], { diff --git a/server/src/services/operatorCommandService/commands/funHelpers.js b/server/src/services/operatorCommandService/commands/funHelpers.js deleted file mode 100644 index 742a7d00..00000000 --- a/server/src/services/operatorCommandService/commands/funHelpers.js +++ /dev/null @@ -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} \`` }; - - // 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, -}; diff --git a/server/src/services/operatorCommandService/commands/funHelpers.test.js b/server/src/services/operatorCommandService/commands/funHelpers.test.js deleted file mode 100644 index 91376b82..00000000 --- a/server/src/services/operatorCommandService/commands/funHelpers.test.js +++ /dev/null @@ -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/); -}); diff --git a/server/src/services/operatorCommandService/commands/funRover.js b/server/src/services/operatorCommandService/commands/funRover.js deleted file mode 100644 index 80b7df7c..00000000 --- a/server/src/services/operatorCommandService/commands/funRover.js +++ /dev/null @@ -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 \``); - - 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 \``); - 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 }; diff --git a/server/src/services/operatorCommandService/commands/funRover.test.js b/server/src/services/operatorCommandService/commands/funRover.test.js deleted file mode 100644 index f6f349ca..00000000 --- a/server/src/services/operatorCommandService/commands/funRover.test.js +++ /dev/null @@ -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'); -}); diff --git a/server/src/services/operatorCommandService/commands/funStats.js b/server/src/services/operatorCommandService/commands/funStats.js deleted file mode 100644 index dc2c7656..00000000 --- a/server/src/services/operatorCommandService/commands/funStats.js +++ /dev/null @@ -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 \`.`); - } - 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 }; diff --git a/server/src/services/operatorCommandService/commands/funStats.test.js b/server/src/services/operatorCommandService/commands/funStats.test.js deleted file mode 100644 index ffcf2e67..00000000 --- a/server/src/services/operatorCommandService/commands/funStats.test.js +++ /dev/null @@ -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/); -}); diff --git a/server/src/services/operatorCommandService/commands/funText.js b/server/src/services/operatorCommandService/commands/funText.js deleted file mode 100644 index d3e856bf..00000000 --- a/server/src/services/operatorCommandService/commands/funText.js +++ /dev/null @@ -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} \``); - } - - 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} \``); - - 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 \``); - - 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 and \``); - } - - 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 \``); - - 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 \``); - - 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 \``); - - 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 \``); - - 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, -}; diff --git a/server/src/services/operatorCommandService/commands/funText.test.js b/server/src/services/operatorCommandService/commands/funText.test.js deleted file mode 100644 index 9e137171..00000000 --- a/server/src/services/operatorCommandService/commands/funText.test.js +++ /dev/null @@ -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 `/); - - 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/); -}); diff --git a/server/src/services/operatorCommandService/commands/gain.js b/server/src/services/operatorCommandService/commands/gain.js index 1a4482d4..2faf06c8 100644 --- a/server/src/services/operatorCommandService/commands/gain.js +++ b/server/src/services/operatorCommandService/commands/gain.js @@ -98,10 +98,10 @@ function createGainCommand({ '**Audio gain boost**', '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 grant \` — give the boost to a verified user.`, - `\`${commandPrefix} gain revoke \` — take the boost away.`, - `\`${commandPrefix} gain help\` — show this.`, + `- \`${commandPrefix} gain list\`: show everyone who holds the boost.`, + `- \`${commandPrefix} gain grant \`: give the boost to a verified user.`, + `- \`${commandPrefix} gain revoke \`: take the boost away.`, + `- \`${commandPrefix} gain help\`: show this.`, '', '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', diff --git a/server/src/services/operatorCommandService/commands/gain.test.js b/server/src/services/operatorCommandService/commands/gain.test.js index 0b6af2b8..cbf69123 100644 --- a/server/src/services/operatorCommandService/commands/gain.test.js +++ b/server/src/services/operatorCommandService/commands/gain.test.js @@ -218,6 +218,9 @@ test('help lists every subcommand', async () => { for (const fragment of ['rs gain list', 'rs gain grant ', 'rs gain revoke ', 'rs gain help']) { 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 () => { diff --git a/server/src/services/operatorCommandService/cooldowns.js b/server/src/services/operatorCommandService/cooldowns.js deleted file mode 100644 index 2c51bcfd..00000000 --- a/server/src/services/operatorCommandService/cooldowns.js +++ /dev/null @@ -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 }; diff --git a/server/src/services/operatorCommandService/cooldowns.test.js b/server/src/services/operatorCommandService/cooldowns.test.js deleted file mode 100644 index 1896632c..00000000 --- a/server/src/services/operatorCommandService/cooldowns.test.js +++ /dev/null @@ -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'); -}); diff --git a/server/src/services/operatorCommandService/help.js b/server/src/services/operatorCommandService/help.js index 9b9f740b..7a92dd8b 100644 --- a/server/src/services/operatorCommandService/help.js +++ b/server/src/services/operatorCommandService/help.js @@ -25,7 +25,7 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = '' const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic; const categoryNames = requestedCategory && CATEGORIES[requestedCategory] ? [requestedCategory] - : ['system', 'admin', 'features', 'fun', ...(includeDiscord ? ['discord'] : [])]; + : ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])]; const output = ['**Rover Bot Commands**']; for (const categoryName of categoryNames) { @@ -36,7 +36,9 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = '' const entry = entries[name]; if (!entry?.usage?.length) continue; 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 \` for details.`); diff --git a/server/src/services/operatorCommandService/help.test.js b/server/src/services/operatorCommandService/help.test.js new file mode 100644 index 00000000..a9136a16 --- /dev/null +++ b/server/src/services/operatorCommandService/help.test.js @@ -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, /—/); +}); diff --git a/server/src/services/operatorCommandService/index.js b/server/src/services/operatorCommandService/index.js index 09160a77..b6aab4a9 100644 --- a/server/src/services/operatorCommandService/index.js +++ b/server/src/services/operatorCommandService/index.js @@ -13,10 +13,6 @@ const { createLightsCommand } = require('./commands/lights'); const { createKickCommand } = require('./commands/kick'); const { createLiftCommand } = require('./commands/lift'); 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 { buildCommandRegistry } = require('./registry'); @@ -69,20 +65,6 @@ function createCommandHandlers(deps) { const handleLiftCommand = createLiftCommand(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) { const trimmed = String(content || '').trim(); const lower = trimmed.toLowerCase(); @@ -128,8 +110,6 @@ function createCommandHandlers(deps) { // 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 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 // by turns. In admin mode they follow the same admin-only boundary as rover @@ -141,17 +121,12 @@ function createCommandHandlers(deps) { 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 } }); return; } - /* - 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) { + if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) { await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); return; } @@ -192,7 +167,6 @@ function createCommandHandlers(deps) { case 'gain': return handleGainCommand(request, tokens); default: - if (funHandlers[action]) return funHandlers[action](request, tokens); return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled })); } } diff --git a/server/src/services/operatorCommandService/index.test.js b/server/src/services/operatorCommandService/index.test.js index e7877a98..6e945b8a 100644 --- a/server/src/services/operatorCommandService/index.test.js +++ b/server/src/services/operatorCommandService/index.test.js @@ -1,5 +1,5 @@ // 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. const test = require('node:test'); 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 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 } = {}) { const rovers = new Map([['rover-1', { id: 'rover-1', @@ -22,28 +20,15 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) { const { handleCommand } = createCommandHandlers({ logger: { warn: () => {}, info: () => {} }, - io: { sockets: { sockets: new Map([[ALICE.id, ALICE]]) } }, + io: { sockets: { sockets: new Map() } }, rovers, - roverManager: { - canDrive: () => true, - getPrimaryRoverForSocket: () => 'rover-1', - applyPrivateDriveSafety: () => null, - }, + roverManager: {}, getMode: () => mode, MODES, getNickname: (entry) => entry?.data?.nickname || '', getActiveDrivers: () => ({}), - getActorSocket: () => ALICE, - issueCommand: () => 'cmd-1', isFeatureEnabled: () => featureEnabled, sanitizeMentions: (text) => String(text || ''), - funStatsService: { - bumpActorStats: () => ({}), - getActorStats: () => ({}), - listActorStats: () => [], - bumpRoverPets: () => 1, - getRoverPets: () => 0, - }, homeAssistantService: { getLightPolicyState: () => ({}), setAllControllableEntitiesState: () => Promise.resolve() }, liftService: 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 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 () => { 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/); }); -test('the fun category appears in help', 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 () => { +test('admin mode restricts access-mode feature commands', async () => { const run = createRouter({ mode: MODES.ADMIN }); 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 () => { @@ -150,7 +104,6 @@ test('status and help survive lockdown', async () => { test('a disabled required feature is reported before any permission check', async () => { 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/); }); @@ -171,11 +124,10 @@ test('an unknown command is not treated as public', async () => { test('bot actors are ignored entirely', async () => { 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 () => { const run = createRouter(); - assert.doesNotMatch(await run('RS COIN', nonAdmin), ADMIN_DENIAL); assert.match(await run('Rs Status', nonAdmin), /\[status handler\]/); }); diff --git a/server/src/services/operatorCommandService/registry.js b/server/src/services/operatorCommandService/registry.js index c2d53a25..094fc725 100644 --- a/server/src/services/operatorCommandService/registry.js +++ b/server/src/services/operatorCommandService/registry.js @@ -5,13 +5,6 @@ const CATEGORIES = { system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] }, admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'gain'] }, 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'] }, }; @@ -68,40 +61,6 @@ function buildCommandRegistry(prefix, timeCommand) { lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift `], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' }, neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato `], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' }, bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here `, `${prefix} bridge mode `, `${prefix} bridge off`], access: 'Discord server manager' }, - /* - 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 `], access: 'Public', permission: 'public' }, - hug: { category: 'fun', summary: 'Hug someone.', usage: [`${prefix} hug `], access: 'Public', permission: 'public' }, - slap: { category: 'fun', summary: 'Slap someone with a random object.', usage: [`${prefix} slap `], 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 `], 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 and `], access: 'Public', permission: 'public' }, - rate: { category: 'fun', summary: 'Rate anything out of 10.', usage: [`${prefix} rate `], access: 'Public', permission: 'public' }, - uwu: { category: 'fun', summary: 'Ruin some text.', usage: [`${prefix} uwu `], access: 'Public', permission: 'public' }, - wanted: { category: 'fun', summary: 'Issue a wanted poster.', usage: [`${prefix} wanted `], 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 `], 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', - }, }; }