feat(commands): add a fun command category with 18 public rs commands

Adds a `fun` category to the operator command registry, reachable identically
from site chat and Discord:

- text: bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted
- counters: bonkboard, pet, snitch
- hardware: honk, boo, spin, disco, vibecheck

Supporting pieces:

- `permission: 'public'` in the registry. The dispatcher previously decided
  non-admin access with a hardcoded chain of `action !== '...'` comparisons, so
  every new public command needed a dispatcher edit. That chain is replaced by a
  registry lookup plus SELF_GATED_ACTIONS, which names the commands that enforce
  their own permissions internally (goal/reason are read-public write-admin;
  verify/deter reject non-lockdown-admins themselves). Existing behavior for
  every pre-existing command is unchanged.
- `cooldowns.js`, a per-actor per-command in-memory gate. Site chat's own rate
  limit is per-socket-per-message and does not bound a specific command, so
  without this one person could turn `rs honk` into a siren. Site chat rebuilds
  its router per message, so the gate is created at module scope there and
  injected.
- `funStatsService`, a small JSON store for the persistent tallies. Counters are
  keyed by an actor key spanning transports (`user:<id>` / `discord:<id>`), and a
  Discord id has no row in `users`, so `user_feature_state` could not hold them
  without violating its foreign key.

Safety notes:

- `issueCommand` is the raw rover transport and performs none of the ownership,
  deterrence, or private-safety checks the socket `command` handler applies, so
  honk and spin re-check `canDrive` themselves and spin re-applies
  `applyPrivateDriveSafety`. Both are therefore site-chat only: a Discord message
  has no socket and can never satisfy those checks.
- `boo` speaks a canned taunt rather than caller-supplied text, so it cannot
  become an unmoderated TTS channel aimed at whoever is nearest a rover.
- `disco` obeys the existing room-light lock and the homeAssistant feature gate.
- The whole fun category is suspended in lockdown mode.
- Mute and deterrence already stop command-shaped chat before the router runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Saul5662
2026-07-29 04:51:40 +01:00
co-authored by Claude Opus 5
parent d000b8f4f8
commit a2fbbc100e
11 changed files with 1088 additions and 3 deletions
@@ -25,8 +25,10 @@ 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');
@@ -39,6 +41,14 @@ 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;
}
@@ -120,6 +130,12 @@ 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),
@@ -185,6 +201,16 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
muteUser,
unmuteUser,
sanitizeMentions,
funStatsService,
commandCooldowns,
/*
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),
@@ -54,9 +54,12 @@ const {
denyRequest: denyPrivateAccessRequest,
} = require('../privateRoverAccessRequestService');
const { subscribe } = require('../eventBus');
const funStatsService = require('../funStatsService');
const { issueCommand } = require('../commandService');
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');
@@ -206,6 +209,10 @@ 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,
@@ -251,6 +258,15 @@ const commandDependencies = {
muteUser,
unmuteUser,
sanitizeMentions,
funStatsService,
commandCooldowns,
/*
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,
@@ -0,0 +1,178 @@
// Fun Stats Service
// Purpose: Persists the running counters behind the social `rs` fun commands.
// Scope: Owns storage and clamping only; command handlers decide what a counter means.
const fs = require('fs');
const path = require('path');
const logger = require('../../globals/logger').child('funStatsService');
const { resolveDataPath } = require('../../helpers/dataPaths');
const STORE_PATH = resolveDataPath('fun-stats.json');
// Counters are additive and never authoritative for anything but bragging
// rights, so the ceiling only exists to keep a runaway loop from writing an
// unbounded integer into the store.
const MAX_COUNT = 1_000_000;
const MAX_LABEL_LENGTH = 64;
const ACTOR_COUNTERS = [
'bonksGiven',
'bonksTaken',
'hugsGiven',
'hugsTaken',
'slapsGiven',
'slapsTaken',
];
/*
This service deliberately keeps its own tiny JSON store rather than reusing
identityService.createJsonStore. Fun counters are keyed by an actor key that
spans transports (`user:<id>` for site chat, `discord:<id>` for Discord), and
a Discord id has no row in `users`, so it cannot live in `user_feature_state`
without violating that table's foreign key. Keeping storage local also means
the counters can be unit tested without opening the identity database.
*/
let cache = null;
function clampCount(value) {
const count = Number(value);
if (!Number.isFinite(count) || count <= 0) return 0;
return Math.min(Math.floor(count), MAX_COUNT);
}
function normalizeLabel(value) {
const label = String(value || '').trim().replace(/\s+/g, ' ');
if (!label) return null;
return label.slice(0, MAX_LABEL_LENGTH);
}
function normalizeActor(raw = {}) {
const actor = { label: normalizeLabel(raw.label) };
ACTOR_COUNTERS.forEach((key) => {
actor[key] = clampCount(raw[key]);
});
actor.updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : null;
return actor;
}
function normalizeStore(raw = {}) {
const actors = {};
const rawActors = raw && typeof raw.actors === 'object' && raw.actors ? raw.actors : {};
Object.keys(rawActors).forEach((key) => {
const actorKey = String(key || '').trim();
if (!actorKey) return;
actors[actorKey] = normalizeActor(rawActors[actorKey]);
});
const rovers = {};
const rawRovers = raw && typeof raw.rovers === 'object' && raw.rovers ? raw.rovers : {};
Object.keys(rawRovers).forEach((key) => {
const roverId = String(key || '').trim();
if (!roverId) return;
const entry = rawRovers[roverId] || {};
rovers[roverId] = {
pets: clampCount(entry.pets),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : null,
};
});
return { actors, rovers };
}
function loadState() {
if (cache) return cache;
try {
cache = normalizeStore(JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')));
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalizeStore({});
}
return cache;
}
function persistState(next) {
const normalized = normalizeStore(next);
try {
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
} catch (err) {
// A failed write must not break the command that triggered it. The joke
// still lands; only the tally is lost.
logger.warn('Failed to persist fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalized;
return cache;
}
function getActorStats(actorKey) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
return { ...(loadState().actors[key] || normalizeActor({})) };
}
/*
`patch` is a map of counter name to increment. Unknown counter names are
ignored rather than stored so a typo in a handler cannot quietly create a
parallel counter that never shows up on the leaderboard.
*/
function bumpActorStats(actorKey, { label = null, ...patch } = {}) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
const state = loadState();
const current = state.actors[key] || normalizeActor({});
const next = { ...current };
const resolvedLabel = normalizeLabel(label);
if (resolvedLabel) next.label = resolvedLabel;
ACTOR_COUNTERS.forEach((counter) => {
const delta = Number(patch[counter]);
if (!Number.isFinite(delta) || delta === 0) return;
next[counter] = clampCount(current[counter] + delta);
});
next.updatedAt = Date.now();
persistState({ ...state, actors: { ...state.actors, [key]: next } });
return { ...next };
}
function listActorStats() {
const { actors } = loadState();
return Object.keys(actors).map((actorKey) => ({ actorKey, ...actors[actorKey] }));
}
function bumpRoverPets(roverId, by = 1) {
const id = String(roverId || '').trim();
if (!id) return 0;
const state = loadState();
const current = state.rovers[id] || { pets: 0, updatedAt: null };
const delta = Number(by);
const next = {
pets: clampCount(current.pets + (Number.isFinite(delta) ? delta : 0)),
updatedAt: Date.now(),
};
persistState({ ...state, rovers: { ...state.rovers, [id]: next } });
return next.pets;
}
function getRoverPets(roverId) {
const id = String(roverId || '').trim();
if (!id) return 0;
return loadState().rovers[id]?.pets || 0;
}
// Tests drive the store through a temporary SERVER_DATA_DIR, so they need a way
// to drop the module-level cache between cases.
function resetCacheForTests() {
cache = null;
}
module.exports = {
ACTOR_COUNTERS,
STORE_PATH,
getActorStats,
bumpActorStats,
listActorStats,
bumpRoverPets,
getRoverPets,
resetCacheForTests,
};
@@ -0,0 +1,285 @@
// Operator Fun Rover Commands
// Purpose: Implements the fun commands that actually make the fleet or the room do something (honk, boo, disco, spin, vibecheck).
// Scope: Every handler here re-checks control and feature gating itself, because issueCommand bypasses the socket command guards.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const {
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
createRoverResolver,
hashSeed,
pickBySeed,
resolveFunTarget,
} = require('./funHelpers');
// Durations are deliberately short and are also bounded rover-side: roverd
// enforces its own horn MaxDuration, so a lost stop command cannot leave a horn
// sounding forever.
const HONK_MS = 600;
const HONK_FREQ_HZ = 440;
const SPIN_MS = 1200;
const SPIN_SPEED = 120;
const DISCO_MS = 12 * 1000;
const DISCO_TICK_MS = 750;
const HONK_ACTOR_COOLDOWN_MS = 20 * 1000;
const HONK_ROVER_COOLDOWN_MS = 8 * 1000;
const BOO_COOLDOWN_MS = 30 * 1000;
const SPIN_COOLDOWN_MS = 25 * 1000;
const DISCO_COOLDOWN_MS = 2 * 60 * 1000;
const VIBECHECK_COOLDOWN_MS = 5 * 1000;
/*
Taunts are a fixed list rather than caller-supplied text on purpose. `boo` puts
audio out of a speaker in a room full of people, so letting it read arbitrary
input would turn a joke command into an unmoderated TTS channel aimed at
whoever is nearest the rover.
*/
const BOO_TAUNTS = [
'Boo.', 'Your driving is being reviewed.', 'That was a choice.',
'The wall was right there.', 'Someone in chat is laughing at you.',
'I have seen better parking from the Neato.', 'Boo. Respectfully.',
'This is a citizen\'s arrest.', 'Turn left. No, the other left.',
];
const VIBE_VERDICTS = [
'immaculate', 'acceptable', 'questionable', 'concerning', 'dire', 'unwell',
];
function describeBattery(batteryState) {
const display = Number(batteryState?.percentDisplay);
if (Number.isFinite(display)) return `${Math.round(display)}%`;
const percent = Number(batteryState?.percent);
if (Number.isFinite(percent)) return `${Math.round(percent * 100)}%`;
return 'unknown';
}
function createFunRoverCommands({
io,
rovers,
roverManager,
getNickname,
getActiveDrivers,
getActorSocket,
issueCommand,
homeAssistantService,
isFeatureEnabled,
sanitizeMentions,
cooldowns,
logger,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
/*
issueCommand is the raw rover transport: it performs none of the ownership,
deterrence, or private-safety checks that the socket `command` handler applies.
Any fun command that moves hardware therefore has to prove control here, which
also means these commands are inherently site-chat only — a Discord message has
no socket and so can never satisfy canDrive.
*/
function requireDriveControl(action, selector) {
const socket = getActorSocket?.() || null;
if (!socket) {
return { error: `\`${commandPrefix} ${action}\` only works from site chat, where you can actually be driving.` };
}
const rover = resolveTargetRover(selector, action);
if (rover.error) return { error: rover.error };
if (!roverManager?.canDrive?.(rover.id, socket)) {
return { error: `You need control of ${rover.name} to do that.` };
}
return { rover, socket };
}
function safeIssue(roverId, payload, context) {
try {
issueCommand(roverId, payload);
return true;
} catch (err) {
// Deferred stop commands routinely land after a rover drops off. That is
// expected, not an incident, so it is logged at debug volume and swallowed.
logger?.warn?.('Fun command could not reach rover', { roverId, context, error: err.message });
return false;
}
}
async function handleHonk(message, tokens = []) {
const control = requireDriveControl('honk', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover } = control;
if (rover.record?.meta?.horn?.enabled === false) {
return reply(message, `${rover.name} has no horn fitted.`);
}
const gated = gate(message, 'honk', HONK_ACTOR_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
// A second, rover-scoped window stops a group of drivers taking turns to
// honk the same rover continuously while each stays inside their own limit.
const roverWait = cooldowns.consume(`honk:rover:${rover.id}`, HONK_ROVER_COOLDOWN_MS);
if (roverWait > 0) {
return reply(message, `${rover.name} was just honked. Give it ${describeWait(roverWait)}.`);
}
if (!safeIssue(rover.id, { type: 'horn', horn: { action: 'start', waveform: 'sine', freqs: [HONK_FREQ_HZ] } }, 'honk:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'horn', horn: { action: 'stop' } }, 'honk:stop'), HONK_MS);
return reply(message, `📢 HONK. (${rover.name})`);
}
async function handleSpin(message, tokens = []) {
const control = requireDriveControl('spin', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover, socket } = control;
const gated = gate(message, 'spin', SPIN_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const maxWheelSpeed = Number(rover.record?.meta?.maxWheelSpeed);
const speed = Math.max(1, Math.min(SPIN_SPEED, Number.isFinite(maxWheelSpeed) && maxWheelSpeed > 0 ? maxWheelSpeed : SPIN_SPEED));
let driveDirect = { left: speed, right: -speed };
/*
Private rovers can carry a reduced speed ceiling that the socket path would
normally apply. Applying it explicitly keeps a fun command from being the one
way to exceed a limit an admin set for a specific rover.
*/
const safeDrive = roverManager?.applyPrivateDriveSafety?.(rover.id, socket, driveDirect);
if (safeDrive) driveDirect = safeDrive;
if (!safeIssue(rover.id, { type: 'drive', driveDirect }, 'spin:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'drive', driveDirect: { left: 0, right: 0 } }, 'spin:stop'), SPIN_MS);
return reply(message, `🌀 ${rover.name} is doing a spin.`);
}
async function handleBoo(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
const gated = gate(message, 'boo', BOO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const resolved = resolveFunTarget({ io, getNickname, selector });
if (!resolved) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
if (!resolved.online || !resolved.socket) {
return reply(message, `${resolved.label} is not here to be booed.`);
}
// Boo lands on the rover the target is actually driving, so it needs the
// active-driver map rather than merely which rovers they are watching.
const drivers = getActiveDrivers?.() || {};
const roverId = Object.keys(drivers).find((id) => drivers[id] === resolved.socket.id) || null;
if (!roverId) return reply(message, `${resolved.label} is not driving anything right now.`);
const record = rovers.get(String(roverId));
const roverName = record?.meta?.name || roverId;
if (record?.meta?.audio?.ttsEnabled === false) {
return reply(message, `${roverName} cannot speak.`);
}
const taunt = pickBySeed(BOO_TAUNTS, hashSeed(`${gated.actorKey}:${resolved.label}`));
if (!safeIssue(roverId, { type: 'tts', tts: { text: taunt, speak: true, engine: 'chromegtts' } }, 'boo')) {
return reply(message, `${roverName} is offline.`);
}
return reply(message, `👻 Booed ${resolved.label} through ${roverName}.`);
}
async function handleDisco(message) {
if (!homeAssistantService || !isFeatureEnabled?.('homeAssistant')) {
return reply(message, 'Room light controls are unavailable.');
}
// An admin lock on the room lights is a policy boundary. Disco is a scene
// change like `rs lights on`, so it must not be the one command that ignores it.
const lightPolicy = homeAssistantService.getLightPolicyState?.() || {};
if (lightPolicy.locked) {
return reply(message, 'Room lights are locked. No disco.');
}
const gated = gate(message, 'disco', DISCO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const setAll = homeAssistantService.setAllControllableEntitiesState;
if (typeof setAll !== 'function') {
return reply(message, 'Room light controls are unavailable.');
}
const endsAt = Date.now() + DISCO_MS;
let on = false;
/*
Held in a local interval rather than the rewards effect store because a
disco is short and disposable. Nothing needs to survive a restart, and the
final tick always restores the lights to on.
*/
const timer = setInterval(() => {
if (Date.now() >= endsAt) {
clearInterval(timer);
Promise.resolve(setAll('on')).catch((err) => {
logger?.warn?.('Disco could not restore lights', { error: err.message });
});
return;
}
on = !on;
Promise.resolve(setAll(on ? 'on' : 'off')).catch((err) => {
logger?.warn?.('Disco tick failed', { error: err.message });
});
}, DISCO_TICK_MS);
return reply(message, `🪩 Disco for ${Math.round(DISCO_MS / 1000)} seconds. Started by ${gated.label}.`);
}
async function handleVibecheck(message, tokens = []) {
const gated = gate(message, 'vibecheck', VIBECHECK_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'vibecheck');
if (rover.error) return reply(message, rover.error);
const record = rover.record || rovers.get(rover.id) || null;
const battery = describeBattery(record?.batteryState);
const offline = !record?.ws;
const locked = Boolean(record?.locked);
const urgent = Boolean(record?.batteryState?.urgentActive);
const warn = Boolean(record?.batteryState?.warnActive);
let verdict;
if (offline) verdict = 'nonexistent — it is offline';
else if (urgent) verdict = 'dying';
else if (warn) verdict = 'running low';
else if (locked) verdict = 'locked out and sulking';
else verdict = pickBySeed(VIBE_VERDICTS, hashSeed(`${rover.id}:${battery}`));
return reply(message, `🔍 ${rover.name}: vibes are **${verdict}**. Battery ${battery}.`);
}
return {
honk: handleHonk,
boo: handleBoo,
disco: handleDisco,
spin: handleSpin,
vibecheck: handleVibecheck,
};
}
module.exports = { createFunRoverCommands, describeBattery };
@@ -0,0 +1,118 @@
// Operator Fun Stats Commands
// Purpose: Implements the fun commands that read or extend persistent counters (bonkboard, pet, snitch).
// Scope: Reads the roster and the fun stats store; issues no rover commands.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const { PLAIN_MENTIONS, actorLabel, buildActorKey, createRoverResolver } = require('./funHelpers');
const PET_COOLDOWN_MS = 10 * 1000;
const READ_COOLDOWN_MS = 5 * 1000;
const LEADERBOARD_SIZE = 10;
function formatLeaderboard(title, rows, counter) {
const ranked = rows
.filter((row) => Number(row[counter]) > 0)
.sort((left, right) => Number(right[counter]) - Number(left[counter]))
.slice(0, LEADERBOARD_SIZE);
if (!ranked.length) return null;
const lines = ranked.map((row, index) => `${index + 1}. ${row.label || 'unknown'}${row[counter]}`);
return [`**${title}**`, ...lines].join('\n');
}
function createFunStatsCommands({
io,
rovers,
getNickname,
getActiveDrivers,
getActorSocket,
roverManager,
sanitizeMentions,
funStatsService,
cooldowns,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
async function handleBonkboard(message) {
const gated = gate(message, 'bonkboard', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rows = funStatsService.listActorStats();
const sections = [
formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'),
formatLeaderboard('Most bonks taken', rows, 'bonksTaken'),
formatLeaderboard('Most hugs given', rows, 'hugsGiven'),
].filter(Boolean);
if (!sections.length) {
return reply(message, `Nobody has been bonked yet. Fix that with \`${commandPrefix} bonk <user>\`.`);
}
return reply(message, sections.join('\n\n').slice(0, 1900));
}
async function handlePet(message, tokens = []) {
const gated = gate(message, 'pet', PET_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'pet');
if (rover.error) return reply(message, rover.error);
const pets = funStatsService.bumpRoverPets(rover.id, 1);
return reply(message, `🤖 ${gated.label} pets ${rover.name}. It has now been petted ${pets} time${pets === 1 ? '' : 's'}.`);
}
/*
Reads the same active-driver map the turn system uses, so it reports real
control rather than who merely has the page open. Rovers with nobody driving
are listed too — an empty fleet is exactly what a snitch should report.
*/
async function handleSnitch(message) {
const gated = gate(message, 'snitch', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const drivers = getActiveDrivers?.() || {};
const sockets = io?.sockets?.sockets;
const lines = [];
rovers.forEach((record, roverId) => {
const id = String(roverId);
const name = record?.meta?.name || id;
const socketId = drivers[id];
const socket = socketId && sockets?.get ? sockets.get(socketId) : null;
const nickname = socket ? getNickname?.(socket) : null;
if (nickname) {
lines.push(`${name}${nickname}`);
} else if (socketId) {
lines.push(`${name} — someone who will not say their name`);
} else {
lines.push(`${name} — nobody`);
}
});
if (!lines.length) return reply(message, 'No rovers are online to snitch about.');
return reply(message, ['🕵️ Currently driving:', ...lines].join('\n').slice(0, 1900));
}
return {
bonkboard: handleBonkboard,
pet: handlePet,
snitch: handleSnitch,
};
}
module.exports = { createFunStatsCommands, formatLeaderboard };
@@ -0,0 +1,316 @@
// 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;
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, 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 });
}
/*
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 }) {
return async function handleInteraction(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) {
return reply(message, `Usage: \`${commandPrefix} ${action} <user>\``);
}
const gated = gate(message, action);
if (gated.error) return reply(message, gated.error);
const resolved = target(selector);
if (!resolved) return reply(message, `Usage: \`${commandPrefix} ${action} <user>\``);
if (resolved.actorKey && resolved.actorKey === gated.actorKey) {
return reply(message, selfReply(gated.label));
}
funStatsService.bumpActorStats(gated.actorKey, { label: gated.label, [counterGiven]: 1 });
const targetStats = resolved.actorKey
? funStatsService.bumpActorStats(resolved.actorKey, { label: resolved.label, [counterTaken]: 1 })
: null;
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.`,
render: ({ targetLabel, takenCount }) => {
const tally = takenCount ? ` That is their ${ordinal(takenCount)} bonk.` : '';
return `🔨 Bonked ${targetLabel}.${tally}`;
},
});
const handleHug = createInteraction({
action: 'hug',
counterGiven: 'hugsGiven',
counterTaken: 'hugsTaken',
selfReply: (label) => `${label} hugged themselves. Genuinely fine. No notes.`,
render: ({ actor, targetLabel, takenCount }) => {
const flavour = pickBySeed(HUG_FLAVOURS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
const tally = takenCount ? ` (${takenCount} total)` : '';
return `🫂 ${actor} hugs ${targetLabel} ${flavour}.${tally}`;
},
});
const handleSlap = createInteraction({
action: 'slap',
counterGiven: 'slapsGiven',
counterTaken: 'slapsTaken',
selfReply: (label) => `${label} slapped themselves with a large trout. Bold.`,
render: ({ actor, targetLabel, takenCount }) => {
// Seeding on the running count means the weapon changes every time without
// being unpredictable for the same repeat number.
const item = pickBySeed(SLAP_ITEMS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
return `🐟 ${actor} slaps ${targetLabel} around a bit with ${item}.`;
},
});
async function handleEightBall(message, tokens = []) {
const question = clampEcho(tokens.join(' '));
if (!question) return reply(message, `Usage: \`${commandPrefix} 8ball <question>\``);
const gated = gate(message, '8ball');
if (gated.error) return reply(message, gated.error);
const answer = pickBySeed(EIGHT_BALL_ANSWERS, hashSeed(question));
return reply(message, `🎱 ${question}\n${answer}`);
}
async function handleRoll(message, tokens = []) {
const gated = gate(message, 'roll');
if (gated.error) return reply(message, gated.error);
const spec = parseDiceSpec(tokens.join(''));
if (spec.error) return reply(message, spec.error);
const { rolls, total } = rollDice(spec.count, spec.sides);
const detail = rolls.length > 1 ? ` (${rolls.join(' + ')})` : '';
return reply(message, `🎲 ${gated.label} rolled ${spec.count}d${spec.sides}: **${total}**${detail}`);
}
async function handleCoin(message) {
const gated = gate(message, 'coin');
if (gated.error) return reply(message, gated.error);
const side = Math.random() < 0.5 ? 'Heads' : 'Tails';
return reply(message, `🪙 ${side}.`);
}
async function handleShip(message, tokens = []) {
const parts = tokens.join(' ').split(/\s+(?:and|\+|&)\s+|\s*,\s*/i).map((part) => clampEcho(part)).filter(Boolean);
const [left, right] = parts.length >= 2 ? parts : [parts[0], null];
if (!left || !right) {
return reply(message, `Usage: \`${commandPrefix} ship <a> and <b>\``);
}
const gated = gate(message, 'ship');
if (gated.error) return reply(message, gated.error);
const score = percentFromSeed(pairSeed(left, right));
const verdict = score >= 90 ? 'Get them a shared charging dock.'
: score >= 65 ? 'Promising.'
: score >= 35 ? 'Needs work.'
: score >= 10 ? 'The sensors are not hopeful.'
: 'Absolutely not.';
return reply(message, `💞 ${left} + ${right} = **${score}%**. ${verdict}`);
}
async function handleRate(message, tokens = []) {
const thing = clampEcho(tokens.join(' '));
if (!thing) return reply(message, `Usage: \`${commandPrefix} rate <thing>\``);
const gated = gate(message, 'rate');
if (gated.error) return reply(message, gated.error);
const seed = hashSeed(thing);
const score = seed % 11;
const suffix = pickBySeed(RATE_SUFFIXES, seed);
return reply(message, `📊 I rate ${thing} **${score}/10**. ${suffix}`);
}
async function handleUwu(message, tokens = []) {
const text = clampEcho(tokens.join(' '));
if (!text) return reply(message, `Usage: \`${commandPrefix} uwu <text>\``);
const gated = gate(message, 'uwu');
if (gated.error) return reply(message, gated.error);
return reply(message, uwuify(text));
}
async function handleWanted(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} wanted <user>\``);
const gated = gate(message, 'wanted');
if (gated.error) return reply(message, gated.error);
const resolved = target(selector);
if (!resolved) return reply(message, `Usage: \`${commandPrefix} wanted <user>\``);
const seed = hashSeed(resolved.label);
const crime = pickBySeed(WANTED_CRIMES, seed);
// Bounty is seeded so a given name always carries the same price. Somebody
// being permanently worth 12 credits is funnier than a fresh number each time.
const bounty = 25 + (seed % 4776);
const stats = resolved.actorKey ? funStatsService.getActorStats(resolved.actorKey) : null;
const priors = stats && stats.bonksTaken ? `\nPrior bonks on record: ${stats.bonksTaken}.` : '';
return reply(message, [
'```',
' WANTED',
` ${resolved.label}`,
` for ${crime}`,
` reward: ${bounty} credits`,
'```',
].join('\n') + priors);
}
return {
bonk: handleBonk,
hug: handleHug,
slap: handleSlap,
'8ball': handleEightBall,
roll: handleRoll,
coin: handleCoin,
ship: handleShip,
rate: handleRate,
uwu: handleUwu,
wanted: handleWanted,
};
}
module.exports = {
createFunTextCommands,
// Exported for tests: the parsing and transform rules are the parts most
// likely to regress, and they are pure.
parseDiceSpec,
uwuify,
};
@@ -0,0 +1,70 @@
// 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 };
@@ -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', ...(includeDiscord ? ['discord'] : [])];
: ['system', 'admin', 'features', 'fun', ...(includeDiscord ? ['discord'] : [])];
const output = ['**Rover Bot Commands**'];
for (const categoryName of categoryNames) {
@@ -12,9 +12,22 @@ 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');
/*
Commands that enforce their own permissions inside their handler rather than at
the dispatcher. `goal` and `reason` are readable by anyone but only writable by
an admin; `verify` and `deter` reject non-lockdown-admins themselves so they can
explain which role is missing. Listing them here preserves that behavior now
that the general non-admin gate is driven by registry metadata.
*/
const SELF_GATED_ACTIONS = new Set(['', 'status', 'help', 'replay', 'bridge', 'goal', 'reason', 'verify', 'deter']);
function createCommandHandlers(deps) {
const {
getMode,
@@ -54,6 +67,20 @@ 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();
@@ -99,6 +126,8 @@ function createCommandHandlers(deps) {
// lockdown admin while the entire server is in lockdown.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
const 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
@@ -110,12 +139,17 @@ function createCommandHandlers(deps) {
return;
}
if (!isAccessModeCommand && !isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
if (!isAccessModeCommand && !isPublicCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) {
await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
/*
Lockdown exists to make the server quiet and controlled, so the whole fun
category is suspended alongside the moderation-sensitive actions rather than
leaving a horn command reachable by anyone while the fleet is locked down.
*/
if (mode === MODES.LOCKDOWN && (moderationActions.has(action) || isFunCommand) && !isLockdownAdmin) {
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
@@ -154,6 +188,7 @@ function createCommandHandlers(deps) {
case 'deter':
return handleDeterCommand(request, tokens);
default:
if (funHandlers[action]) return funHandlers[action](request, tokens);
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
}
}
@@ -5,6 +5,13 @@ const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter'] },
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'] },
};
@@ -49,6 +56,40 @@ function buildCommandRegistry(prefix, timeCommand) {
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
/*
Fun commands are the first entries to use `permission: 'public'`. Before
they existed, every non-admin-reachable command was named in a hardcoded
allowlist in the dispatcher; declaring the permission here instead means a
new fun command does not need a dispatcher edit to be usable.
*/
bonk: { category: 'fun', summary: 'Bonk someone. Keeps a running tally.', usage: [`${prefix} bonk <user>`], access: 'Public', permission: 'public' },
hug: { category: 'fun', summary: 'Hug someone.', usage: [`${prefix} hug <user>`], access: 'Public', permission: 'public' },
slap: { category: 'fun', summary: 'Slap someone with a random object.', usage: [`${prefix} slap <user>`], access: 'Public', permission: 'public' },
bonkboard: { category: 'fun', summary: 'Show the bonk and hug leaderboards.', usage: [`${prefix} bonkboard`], access: 'Public', permission: 'public' },
'8ball': { category: 'fun', summary: 'Ask the magic 8 ball. Same question always gets the same answer.', usage: [`${prefix} 8ball <question>`], access: 'Public', permission: 'public' },
roll: { category: 'fun', summary: 'Roll dice.', usage: [`${prefix} roll [NdN]`], access: 'Public', permission: 'public' },
coin: { category: 'fun', summary: 'Flip a coin.', usage: [`${prefix} coin`], access: 'Public', permission: 'public' },
ship: { category: 'fun', summary: 'Rate a pairing out of 100.', usage: [`${prefix} ship <a> and <b>`], access: 'Public', permission: 'public' },
rate: { category: 'fun', summary: 'Rate anything out of 10.', usage: [`${prefix} rate <thing>`], access: 'Public', permission: 'public' },
uwu: { category: 'fun', summary: 'Ruin some text.', usage: [`${prefix} uwu <text>`], access: 'Public', permission: 'public' },
wanted: { category: 'fun', summary: 'Issue a wanted poster.', usage: [`${prefix} wanted <user>`], access: 'Public', permission: 'public' },
pet: { category: 'fun', summary: 'Pet a rover. Each rover keeps its own count.', usage: [`${prefix} pet [rover]`], access: 'Public', permission: 'public' },
snitch: { category: 'fun', summary: 'Report who is driving what.', usage: [`${prefix} snitch`], access: 'Public', permission: 'public' },
// honk and spin move hardware, so their handlers additionally require that the
// caller actually holds control of the rover they name.
honk: { category: 'fun', summary: 'Sound a short horn toot on a rover you control.', usage: [`${prefix} honk [rover]`], access: 'Public; requires control of the rover', permission: 'public' },
boo: { category: 'fun', summary: 'Speak a taunt through the rover someone is driving.', usage: [`${prefix} boo <user>`], access: 'Public', permission: 'public' },
spin: { category: 'fun', summary: 'Make a rover you control do a spin.', usage: [`${prefix} spin [rover]`], access: 'Public; requires control of the rover', permission: 'public' },
vibecheck: { category: 'fun', summary: 'Judge a rover\'s vibes and report its battery.', usage: [`${prefix} vibecheck [rover]`], access: 'Public', permission: 'public' },
disco: {
category: 'fun',
summary: 'Strobe the room lights briefly.',
usage: [`${prefix} disco`],
access: 'Public unless server access is restricted; obeys the room-light lock',
permission: 'access-mode',
requiredFeature: 'homeAssistant',
unavailableLabel: 'Home Assistant',
},
};
}