mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
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:
co-authored by
Claude Opus 5
parent
d000b8f4f8
commit
a2fbbc100e
@@ -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,
|
||||
};
|
||||
Binary file not shown.
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user