mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
182 lines
9.2 KiB
JavaScript
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 };
|