Files
MultiRoombaRover/server/src/services/operatorCommandService/index.js
T
2026-08-08 23:59:47 -04:00

182 lines
9.2 KiB
JavaScript

// Operator Command Service
// Purpose: Routes transport-neutral operator command requests to registered server handlers.
// Scope: Owns shared parsing, authorization, feature gating, help, and execution without importing Discord.js.
const { formatHelp } = require('./help');
const { createLockCommand } = require('./commands/lock');
const { createModeCommand } = require('./commands/mode');
const { createReasonCommand } = require('./commands/reason');
const { createGoalCommand } = require('./commands/goal');
const { createVerifyCommand } = require('./commands/verify');
const { createDeterCommand } = require('./commands/deter');
const { createPermissionsCommand } = require('./commands/permissions');
const { createLightsCommand } = require('./commands/lights');
const { createGreenCommand } = require('./commands/green');
const { createKickCommand } = require('./commands/kick');
const { createLiftCommand } = require('./commands/lift');
const { createNeatoCommand } = require('./commands/neato');
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,
MODES,
} = deps;
// The prefix belongs to the always-available command system so every
// transport parses the same namespace instead of maintaining local defaults.
const { prefix: commandPrefix, timeStatusCommand } = getCommandConfig(deps.config);
// The legacy time command is a bare word rather than a prefixed command. It
// therefore needs its own configurable value, and `null` intentionally
// disables it so multiple bots do not all answer `ts` in the same channel.
// Lowercase cached copies avoid re-normalizing every message and keep command
// matching case-insensitive without changing the original configured text
// that is shown in help output.
const normalizedCommandPrefix = commandPrefix.toLowerCase();
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
const registry = buildCommandRegistry(commandPrefix, timeStatusCommand);
// Status, time status, replay delivery, and transport extensions may have
// different presentation needs. Adapters inject those focused handlers while
// the core retains parsing, policy, and command discovery ownership.
const transportHandlers = deps.transportHandlers || {};
const handleStatusCommand = transportHandlers.status;
const handleReplayCommand = deps.createReplayTextCommand
? deps.createReplayTextCommand(deps)
: transportHandlers.replay;
const handleLockCommand = createLockCommand(deps);
const handleModeCommand = createModeCommand(deps);
const handleReasonCommand = createReasonCommand(deps);
const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps);
const handlePermissionsCommand = createPermissionsCommand(deps);
const handleBridgeCommand = transportHandlers.bridge;
const handleTimeStatusCommand = transportHandlers.timeStatus;
const handleLightsCommand = createLightsCommand(deps);
const handleGreenCommand = createGreenCommand(deps);
const handleKickCommand = createKickCommand(deps);
const handleLiftCommand = createLiftCommand(deps);
const handleNeatoCommand = createNeatoCommand(deps);
function stripCommandPrefix(content) {
const trimmed = String(content || '').trim();
const lower = trimmed.toLowerCase();
if (!lower.startsWith(normalizedCommandPrefix)) return null;
const nextCharacter = trimmed.charAt(commandPrefix.length);
// Prefixes are matched as whole command tokens so an instance using `rs`
// still ignores ordinary words such as `rsvp`. This mirrors the old regex
// behavior while letting each Discord bot instance use its own prefix.
if (nextCharacter && !/\s/.test(nextCharacter)) return null;
return trimmed.slice(commandPrefix.length).trim();
}
async function handleCommand(request) {
if (request.actor?.bot) return;
const content = (request.content || '').trim();
const lower = content.toLowerCase();
// Commands are intentionally matched as whole prefixes. The previous
// startsWith checks made ordinary messages such as "rsvp" or "tshirt" look
// like commands, which is especially bad now that web chat will run the
// same server-side dispatcher before broadcasting user text.
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return handleTimeStatusCommand?.(request);
const commandBody = stripCommandPrefix(content);
if (commandBody === null) return;
const tokens = commandBody ? commandBody.split(/\s+/) : [];
const action = (tokens.shift() || '').toLowerCase();
const rest = tokens.join(' ').trim();
const isAdmin = Boolean(request.actor?.isAdmin);
const isLockdownAdmin = Boolean(request.actor?.isLockdownAdmin);
const mode = getMode();
const commandDefinition = registry[action];
if (commandDefinition?.requiredFeature && !deps.isFeatureEnabled(commandDefinition.requiredFeature)) {
await request.reply({ content: `${commandDefinition.unavailableLabel || commandDefinition.requiredFeature} feature is not configured.` });
return;
}
// Actions in this set can change operational safety or access policy, so
// lockdown mode narrows them from normal admins to lockdown admins. Lights
// is included because its lock/unlock subcommands change room policy. Its
// ordinary on/off/color actions are also intentionally restricted to a
// lockdown admin while the entire server is in lockdown.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'permissions', 'lights', 'green', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
// 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
// access, and lockdown continues to require the stricter lockdown role.
// Keeping this policy in the shared dispatcher makes web chat and Discord
// behave identically instead of each transport interpreting modes itself.
if (isAccessModeCommand && mode === MODES.ADMIN && !isAdmin) {
await request.reply({ content: 'Admin mode: only admins can run feature commands.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (!isAccessModeCommand && !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) {
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
switch (action) {
case '':
case 'status':
return handleStatusCommand?.(request, rest);
case 'help':
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, topic: rest, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
case 'replay':
return handleReplayCommand?.(request, tokens.join(' '));
case 'bridge':
if (!handleBridgeCommand) return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: false, isFeatureEnabled: deps.isFeatureEnabled }));
return handleBridgeCommand(request, tokens);
case 'lights':
return handleLightsCommand(request, tokens);
case 'green':
return handleGreenCommand(request, tokens);
case 'kick':
return handleKickCommand(request, rest);
case 'lift':
return handleLiftCommand(request, tokens);
case 'neato':
return handleNeatoCommand(request, tokens);
case 'lock':
return handleLockCommand(request, rest, true);
case 'unlock':
return handleLockCommand(request, rest, false);
case 'mode':
return handleModeCommand(request, tokens);
case 'goal':
return handleGoalCommand(request, tokens);
case 'reason':
return handleReasonCommand(request, tokens);
case 'verify':
return handleVerifyCommand(request, tokens);
case 'deter':
return handleDeterCommand(request, tokens);
case 'permissions':
return handlePermissionsCommand(request, tokens);
default:
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
}
}
return { handleCommand };
}
module.exports = { createCommandHandlers };