mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
the big
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
// Operator Deter Command
|
||||
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
||||
// Scope: Supports list, ban, and unban subcommands.
|
||||
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||
|
||||
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, sanitizeMentions, config }) {
|
||||
// Moderation usage errors use the same core prefix shown by organized help.
|
||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||
|
||||
return async function handleDeterCommand(message, tokens) {
|
||||
if (!message.actor?.isLockdownAdmin) {
|
||||
await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
const action = (tokens.shift() || 'list').toLowerCase();
|
||||
if (action === 'list') {
|
||||
const users = listDeterredUsers();
|
||||
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
|
||||
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
if (action === 'ban') {
|
||||
const selector = tokens.join(' ').trim();
|
||||
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||
try {
|
||||
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
||||
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
// Ban reasons were deliberately removed from the command grammar. The
|
||||
// full remaining text is now always the selector, which lets lockdown
|
||||
// admins deter multi-word nicknames without quoting or delimiter rules.
|
||||
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
|
||||
const deterred = deterUser(stableSelector, { actor: message.actor?.id || null });
|
||||
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
} catch (err) {
|
||||
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
}
|
||||
if (action === 'unban') {
|
||||
const selector = tokens.join(' ').trim();
|
||||
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter unban <id|cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||
try {
|
||||
const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true });
|
||||
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
const removed = undeterUser(resolved.record.id || resolved.record.cookieUserId || selector, message.actor?.id || null);
|
||||
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
} catch (err) {
|
||||
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
}
|
||||
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, or \`${commandPrefix} deter unban <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createDeterCommand };
|
||||
@@ -0,0 +1,31 @@
|
||||
// Operator Goal Command
|
||||
// Purpose: Handles global objective view/update/clear operations.
|
||||
// Scope: Allows read by all and write by admins.
|
||||
function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobalObjective, sanitizeMentions }) {
|
||||
return async function handleGoalCommand(message, tokens) {
|
||||
const query = tokens.join(' ').trim();
|
||||
const lower = query.toLowerCase();
|
||||
if (!query) {
|
||||
const goal = getGlobalObjective();
|
||||
await message.reply({ content: goal?.text ? `Global objective: ${sanitizeMentions(goal.text)}` : 'No global objective set.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
if (!message.actor?.isAdmin) {
|
||||
await message.reply({ content: 'Only admins can update the global objective.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (lower === 'clear') {
|
||||
clearGlobalObjective({ by: message.actor?.id || null });
|
||||
await message.reply({ content: 'Global objective cleared.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
} else {
|
||||
setGlobalObjective(query, { by: message.actor?.id || null });
|
||||
await message.reply({ content: sanitizeMentions(`Global objective set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
} catch (err) {
|
||||
await message.reply({ content: sanitizeMentions(`Failed to update goal: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createGoalCommand };
|
||||
@@ -0,0 +1,142 @@
|
||||
// Operator Kick Command
|
||||
// Purpose: Removes a connected user from their current rover without applying any persistent moderation state.
|
||||
// Scope: Resolves an online driver, sends them a UI-visible reason, and releases their current rover assignment.
|
||||
const Fuse = require('fuse.js');
|
||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||
|
||||
const DEFAULT_KICK_REASON = 'Removed from rover by admin.';
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function splitSelectorAndReason(rawText) {
|
||||
const text = normalizeText(rawText);
|
||||
if (!text) return { selector: '', reason: '' };
|
||||
const pipeIndex = text.indexOf('|');
|
||||
if (pipeIndex >= 0) {
|
||||
/*
|
||||
A pipe delimiter is the escape hatch for multi-word nicknames. Without a
|
||||
delimiter the command intentionally treats the first token as the selector
|
||||
so quick admin commands stay short, for example:
|
||||
`<configured-prefix> kick bob being reckless`.
|
||||
*/
|
||||
return {
|
||||
selector: normalizeText(text.slice(0, pipeIndex)),
|
||||
reason: normalizeText(text.slice(pipeIndex + 1)),
|
||||
};
|
||||
}
|
||||
const parts = text.split(/\s+/);
|
||||
return {
|
||||
selector: normalizeText(parts.shift()),
|
||||
reason: normalizeText(parts.join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
function buildKickCandidates({ io, roverManager, assignmentService, getNickname }) {
|
||||
return Array.from(io.sockets.sockets.values())
|
||||
.map((socket) => {
|
||||
const socketId = normalizeText(socket?.id);
|
||||
const assignedRoverId = assignmentService?.getAssignedRover?.(socketId) || null;
|
||||
const primaryRoverId = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
const roverId = assignedRoverId || primaryRoverId || null;
|
||||
if (!socketId || !roverId) return null;
|
||||
const nickname = normalizeText(getNickname(socket));
|
||||
const username = normalizeText(socket?.data?.user?.username);
|
||||
return {
|
||||
socket,
|
||||
socketId,
|
||||
roverId,
|
||||
nickname,
|
||||
username,
|
||||
label: nickname || username || socketId.slice(0, 6),
|
||||
searchSocketId: normalizeSearchText(socketId),
|
||||
searchShortSocketId: normalizeSearchText(socketId.slice(0, 6)),
|
||||
searchNickname: normalizeSearchText(nickname),
|
||||
searchUsername: normalizeSearchText(username),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveKickTarget(selector, candidates, commandPrefix = 'rs') {
|
||||
const query = normalizeSearchText(selector);
|
||||
if (!query) return { error: `Specify a user to kick. Example: \`${commandPrefix} kick nickname reason\`` };
|
||||
const exact = candidates.filter((entry) => (
|
||||
entry.searchSocketId === query ||
|
||||
entry.searchShortSocketId === query ||
|
||||
entry.searchNickname === query ||
|
||||
entry.searchUsername === query
|
||||
));
|
||||
if (exact.length === 1) return { target: exact[0] };
|
||||
if (exact.length > 1) {
|
||||
return { error: `User matched multiple drivers: ${exact.map((entry) => entry.label).join(', ')}.` };
|
||||
}
|
||||
const fuse = new Fuse(candidates, {
|
||||
includeScore: true,
|
||||
threshold: 0.38,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'nickname', weight: 0.7 },
|
||||
{ name: 'username', weight: 0.2 },
|
||||
{ name: 'socketId', weight: 0.1 },
|
||||
],
|
||||
});
|
||||
const results = fuse.search(selector);
|
||||
if (!results.length) return { error: 'User not found among current rover drivers.' };
|
||||
const first = results[0];
|
||||
const second = results[1];
|
||||
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < 0.08) {
|
||||
return {
|
||||
error: `User matched multiple drivers: ${results.slice(0, 5).map((entry) => entry.item.label).join(', ')}.`,
|
||||
};
|
||||
}
|
||||
return { target: first.item };
|
||||
}
|
||||
|
||||
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, config }) {
|
||||
// The kick parser itself does not need the prefix, but its validation message
|
||||
// does. Keeping this local avoids passing display-only config through the
|
||||
// lower-level fuzzy target resolver except when an error string is needed.
|
||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||
return async function handleKickCommand(message, rawText) {
|
||||
const { selector, reason } = splitSelectorAndReason(rawText);
|
||||
const assignmentService = require('../../assignmentService');
|
||||
const candidates = buildKickCandidates({
|
||||
io,
|
||||
roverManager,
|
||||
assignmentService,
|
||||
getNickname,
|
||||
});
|
||||
const resolved = resolveKickTarget(selector, candidates, commandPrefix);
|
||||
if (resolved.error) {
|
||||
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
const target = resolved.target;
|
||||
const removalReason = reason || DEFAULT_KICK_REASON;
|
||||
/*
|
||||
The command deliberately calls the notice-aware release helper instead of
|
||||
roverManager.releaseControl. That keeps admin kicks aligned with automated
|
||||
removals and gives the driver a stable explanation in the video panel.
|
||||
*/
|
||||
assignmentService.forceReleaseWithNotice(target.roverId, target.socketId, {
|
||||
title: 'Removed by admin',
|
||||
message: removalReason,
|
||||
reasonCode: 'admin-kick',
|
||||
actor: message.actor?.id || null,
|
||||
});
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createKickCommand,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// Lift Feature Command
|
||||
// Purpose: Exposes lift state and movement through the shared text command route.
|
||||
// Scope: Delegates interlocks, cooldowns, Home Assistant access, and runtime safety to liftService.
|
||||
function describeState(state = {}) {
|
||||
const position = state.position || 'unknown';
|
||||
const connection = state.connected ? 'connected' : 'offline';
|
||||
const activity = state.busy ? `moving ${state.target || ''}`.trim() : 'idle';
|
||||
return `Lift: ${connection}; position ${position}; ${activity}.`;
|
||||
}
|
||||
|
||||
function createLiftCommand({ liftService, sanitizeMentions }) {
|
||||
return async function handleLiftCommand(message, tokens = []) {
|
||||
const action = String(tokens.shift() || 'status').toLowerCase();
|
||||
if (action === 'status') return message.reply({ content: describeState(liftService.getState()) });
|
||||
|
||||
try {
|
||||
if (action === 'up') await liftService.moveUp(`command:${message.actor?.id || 'unknown'}`);
|
||||
else if (action === 'down') await liftService.moveDown(`command:${message.actor?.id || 'unknown'}`);
|
||||
else return message.reply({ content: 'Invalid lift command. Use `lift status`, `lift up`, or `lift down`.' });
|
||||
return message.reply({ content: `Lift moving ${action}.` });
|
||||
} catch (err) {
|
||||
return message.reply({ content: sanitizeMentions(`Lift command failed: ${err.message}`) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLiftCommand };
|
||||
@@ -0,0 +1,77 @@
|
||||
// Operator Lights Command
|
||||
// Purpose: Handles admin room-light lock policy commands from Discord and web chat.
|
||||
// Scope: Delegates all actual Home Assistant policy behavior to homeAssistantService.
|
||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||
|
||||
function describeLightPolicy(lightPolicy = {}) {
|
||||
// The HA service exposes both the newer explicit lockState and the older
|
||||
// lockedOn boolean. Prefer lockState because it can distinguish locked-on
|
||||
// from locked-off, but keep lockedOn as a defensive fallback for any caller
|
||||
// that passes an older or partial policy object.
|
||||
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
|
||||
if (lockState === 'on') return 'Room lights are locked on.';
|
||||
if (lockState === 'off') return 'Room lights are locked off.';
|
||||
return 'Room lights are unlocked.';
|
||||
}
|
||||
|
||||
function createLightsCommand({ homeAssistantService, sanitizeMentions, config }) {
|
||||
// The HA policy behavior is prefix-agnostic; this value is only used so
|
||||
// invalid-command guidance points admins at this bot instance's namespace.
|
||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||
return async function handleLightsCommand(message, tokens = []) {
|
||||
// Defaulting to status makes the bare lights command safe to type while
|
||||
// still exposing explicit mutating forms under the configured prefix. This
|
||||
// matters when several bot instances share a Discord server and each one
|
||||
// needs its own command namespace.
|
||||
const action = String(tokens.shift() || 'status').trim().toLowerCase();
|
||||
|
||||
if (!homeAssistantService) {
|
||||
await message.reply({
|
||||
content: 'Room light controls are unavailable.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'status') {
|
||||
await message.reply({
|
||||
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action !== 'lock' && action !== 'unlock') {
|
||||
await message.reply({
|
||||
content: `Invalid lights command. Use \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const locked = action === 'lock';
|
||||
// The bot command intentionally calls the shared policy setter instead of
|
||||
// issuing direct Home Assistant entity commands. That keeps all secondary
|
||||
// behavior centralized: web UI controls become disabled through the
|
||||
// session lightPolicy update, entering lock-on still sets configured
|
||||
// lights to white where possible once, and commandService sees the same
|
||||
// update event that forces rover lasers off while the room is locked on.
|
||||
await homeAssistantService.setLightsLockedOn(locked, {
|
||||
source: `bot-command:lights:${action}`,
|
||||
});
|
||||
|
||||
await message.reply({
|
||||
content: sanitizeMentions(locked ? 'Room lights locked on.' : 'Room lights unlocked.'),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
} catch (err) {
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Failed to update room lights: ${err.message}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLightsCommand };
|
||||
@@ -0,0 +1,35 @@
|
||||
// Operator Lock Command
|
||||
// Purpose: Handles lock and unlock operations for rover availability control.
|
||||
// Scope: Applies lock state updates for a single rover ID.
|
||||
const { resolveRoverSelector } = require('./resolvers');
|
||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||
|
||||
function createLockCommand({ lockRover, sanitizeMentions, rovers, config }) {
|
||||
// Only the user-facing example depends on the prefix. The actual lock logic
|
||||
// still receives the already-parsed rover selector from the shared router.
|
||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||
return async function handleLockCommand(message, roverId, locked) {
|
||||
if (!roverId) {
|
||||
await message.reply({ content: `Specify a rover ID. Example: \`${commandPrefix} lock alpha\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resolved = resolveRoverSelector(roverId, rovers);
|
||||
if (resolved.error) {
|
||||
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
// Mutate by canonical id after fuzzy resolution. This avoids letting a
|
||||
// display-name typo create a new path through roverManager, and it also
|
||||
// makes the response name match the rover that was actually changed.
|
||||
// Preserve the established Discord reason while allowing other adapters
|
||||
// to identify themselves without pretending their request came from Discord.
|
||||
lockRover(resolved.id, locked, { reason: message.transport === 'discord' ? 'discord' : 'web-chat' });
|
||||
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${resolved.label || resolved.id}.`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
} catch (err) {
|
||||
await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLockCommand };
|
||||
@@ -0,0 +1,23 @@
|
||||
// Operator Mode Command
|
||||
// Purpose: Handles mode updates from authorized operators through the shared command prefix.
|
||||
// Scope: Validates mode values and applies mode changes with optional reason text.
|
||||
function createModeCommand({ MODES, setMode, setAdminReason, sanitizeMentions }) {
|
||||
return async function handleModeCommand(message, tokens = []) {
|
||||
const next = String(tokens.shift() || '').toLowerCase();
|
||||
const reasonText = tokens.join(' ').trim();
|
||||
if (!Object.values(MODES).includes(next)) {
|
||||
await message.reply({ content: 'Invalid mode. Use one of: open, turns, admin, lockdown.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const role = message.actor?.isLockdownAdmin ? 'lockdown' : 'admin';
|
||||
setMode(next, { data: { role, user: { username: `${message.transport}:${message.actor?.label || 'unknown'}` } } });
|
||||
if (reasonText) setAdminReason(reasonText, { by: message.actor?.id || null });
|
||||
await message.reply({ content: sanitizeMentions(`Mode set to ${next}.`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
} catch (err) {
|
||||
await message.reply({ content: sanitizeMentions(`Failed to set mode: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createModeCommand };
|
||||
@@ -0,0 +1,35 @@
|
||||
// Neato Feature Command
|
||||
// Purpose: Exposes Neato state and supported actions through the shared text command route.
|
||||
// Scope: Delegates device availability, Home Assistant calls, and operational errors to neatoService.
|
||||
function describeState(state = {}) {
|
||||
const telemetry = state.telemetry || {};
|
||||
const connection = state.connected ? 'connected' : 'offline';
|
||||
return `Neato: ${connection}; state ${telemetry.robotState || 'unknown'}; battery ${telemetry.batteryLevel ?? 'unknown'}%.`;
|
||||
}
|
||||
|
||||
function createNeatoCommand({ neatoService, sanitizeMentions }) {
|
||||
return async function handleNeatoCommand(message, tokens = []) {
|
||||
const action = String(tokens.shift() || 'status').toLowerCase();
|
||||
if (action === 'status') return message.reply({ content: describeState(neatoService.getState()) });
|
||||
|
||||
const actions = {
|
||||
start: ['starting cleaning', neatoService.startCleaning],
|
||||
home: ['returning home', neatoService.sendHome],
|
||||
locate: ['playing locate sound', neatoService.locateRobot],
|
||||
'clear-errors': ['clearing errors', neatoService.clearErrors],
|
||||
};
|
||||
const selected = actions[action];
|
||||
if (!selected) {
|
||||
return message.reply({ content: 'Invalid Neato command. Use `neato status`, `neato start`, `neato home`, `neato locate`, or `neato clear-errors`.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await selected[1]();
|
||||
return message.reply({ content: `Neato is ${selected[0]}.` });
|
||||
} catch (err) {
|
||||
return message.reply({ content: sanitizeMentions(`Neato command failed: ${err.message}`) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createNeatoCommand };
|
||||
@@ -0,0 +1,31 @@
|
||||
// Operator Reason Command
|
||||
// Purpose: Handles admin-mode reason view/update/clear operations.
|
||||
// Scope: Allows read by all and write by admins.
|
||||
function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason, sanitizeMentions }) {
|
||||
return async function handleReasonCommand(message, tokens) {
|
||||
const query = tokens.join(' ').trim();
|
||||
const lower = query.toLowerCase();
|
||||
if (!query) {
|
||||
const reason = getAdminReason();
|
||||
await message.reply({ content: reason?.text ? `Admin mode reason: ${sanitizeMentions(reason.text)}` : 'No admin mode reason set.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
if (!message.actor?.isAdmin) {
|
||||
await message.reply({ content: 'Only admins can update the admin mode reason.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (lower === 'clear') {
|
||||
clearAdminReason({ by: message.actor?.id || null });
|
||||
await message.reply({ content: 'Admin mode reason cleared.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
} else {
|
||||
setAdminReason(query, { by: message.actor?.id || null });
|
||||
await message.reply({ content: sanitizeMentions(`Admin mode reason set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
} catch (err) {
|
||||
await message.reply({ content: sanitizeMentions(`Failed to update reason: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createReasonCommand };
|
||||
@@ -0,0 +1,173 @@
|
||||
// Operator Command Resolvers
|
||||
// Purpose: Provides shared selector parsing and fuzzy matching for command handlers.
|
||||
// Scope: Keeps potentially destructive commands from each inventing their own lookup rules.
|
||||
const Fuse = require('fuse.js');
|
||||
const { normalizeIp } = require('../../../helpers/ipResolver');
|
||||
|
||||
const FUZZY_THRESHOLD = 0.38;
|
||||
const AMBIGUOUS_SCORE_GAP = 0.08;
|
||||
const MAX_SUGGESTIONS = 5;
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function compactJoin(parts, separator = ', ') {
|
||||
return (Array.isArray(parts) ? parts : []).map(normalizeText).filter(Boolean).join(separator);
|
||||
}
|
||||
|
||||
function uniqueBy(items, getKey) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||
const key = getKey(item);
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildResultError(kind, label, candidates = []) {
|
||||
const suggestions = uniqueBy(candidates, (entry) => entry.key).slice(0, MAX_SUGGESTIONS);
|
||||
const suffix = suggestions.length
|
||||
? ` Suggestions: ${compactJoin(suggestions.map((entry) => entry.label || entry.key))}.`
|
||||
: '';
|
||||
if (kind === 'ambiguous') return `${label} matched multiple records.${suffix}`;
|
||||
return `${label} not found.${suffix}`;
|
||||
}
|
||||
|
||||
function resolveRoverSelector(selector, rovers) {
|
||||
const query = normalizeText(selector);
|
||||
if (!query) return { error: 'Specify a rover.' };
|
||||
|
||||
const candidates = Array.from(rovers.values()).map((record) => {
|
||||
const id = normalizeText(record?.id);
|
||||
const name = normalizeText(record?.meta?.name || record?.name || id);
|
||||
return {
|
||||
key: id,
|
||||
id,
|
||||
label: name,
|
||||
record,
|
||||
searchId: normalizeSearchText(id),
|
||||
searchLabel: normalizeSearchText(name),
|
||||
};
|
||||
}).filter((entry) => entry.id);
|
||||
|
||||
const normalized = normalizeSearchText(query);
|
||||
const exact = candidates.filter((entry) => entry.searchId === normalized || entry.searchLabel === normalized);
|
||||
if (exact.length === 1) return { record: exact[0].record, id: exact[0].id, label: exact[0].label };
|
||||
if (exact.length > 1) return { error: buildResultError('ambiguous', 'Rover', exact) };
|
||||
|
||||
const fuse = new Fuse(candidates, {
|
||||
includeScore: true,
|
||||
threshold: FUZZY_THRESHOLD,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'label', weight: 0.65 },
|
||||
{ name: 'id', weight: 0.35 },
|
||||
],
|
||||
});
|
||||
const results = fuse.search(query);
|
||||
if (!results.length) return { error: buildResultError('not_found', 'Rover', candidates) };
|
||||
|
||||
const first = results[0];
|
||||
const second = results[1];
|
||||
// Fuzzy matches are allowed for convenience, but destructive commands should
|
||||
// not act when two targets are similarly plausible. The score gap keeps typo
|
||||
// tolerance without turning near-ties into accidental locks or removals.
|
||||
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < AMBIGUOUS_SCORE_GAP) {
|
||||
return { error: buildResultError('ambiguous', 'Rover', results.map((entry) => entry.item)) };
|
||||
}
|
||||
return { record: first.item.record, id: first.item.id, label: first.item.label };
|
||||
}
|
||||
|
||||
function createIdentityCandidates(records = [], { includeId = true } = {}) {
|
||||
return (Array.isArray(records) ? records : []).map((record) => {
|
||||
const id = normalizeText(record?.id);
|
||||
const userId = normalizeText(record?.userId);
|
||||
const cookieUserId = normalizeText(record?.cookieUserId);
|
||||
const fingerprintId = normalizeText(record?.fingerprintId);
|
||||
const nickname = normalizeText(record?.nickname);
|
||||
const knownIps = Array.isArray(record?.knownIps) ? record.knownIps.map(normalizeText).filter(Boolean) : [];
|
||||
return {
|
||||
key: userId || id || cookieUserId || fingerprintId || nickname,
|
||||
id,
|
||||
userId,
|
||||
cookieUserId,
|
||||
fingerprintId,
|
||||
nickname,
|
||||
knownIps,
|
||||
label: compactJoin([nickname || 'unknown', includeId && (userId || id) ? (userId || id) : '', cookieUserId ? mask(cookieUserId) : '']),
|
||||
record,
|
||||
searchId: normalizeSearchText(id),
|
||||
searchUserId: normalizeSearchText(userId),
|
||||
searchCookie: normalizeSearchText(cookieUserId),
|
||||
searchFingerprint: normalizeSearchText(fingerprintId),
|
||||
searchNickname: normalizeSearchText(nickname),
|
||||
searchIps: knownIps.map(normalizeSearchText),
|
||||
};
|
||||
}).filter((entry) => entry.key);
|
||||
}
|
||||
|
||||
function resolveIdentitySelector(selector, records = [], options = {}) {
|
||||
const query = normalizeText(selector);
|
||||
if (!query) return { error: 'Selector required.' };
|
||||
|
||||
const candidates = createIdentityCandidates(records, options);
|
||||
const normalized = normalizeSearchText(query);
|
||||
const ip = normalizeIp(query);
|
||||
|
||||
const exact = candidates.filter((entry) => (
|
||||
entry.searchId === normalized ||
|
||||
entry.searchUserId === normalized ||
|
||||
entry.searchCookie === normalized ||
|
||||
entry.searchFingerprint === normalized ||
|
||||
entry.searchNickname === normalized ||
|
||||
(ip && entry.searchIps.includes(normalizeSearchText(ip)))
|
||||
));
|
||||
if (exact.length === 1) return { record: exact[0].record, label: exact[0].label };
|
||||
if (exact.length > 1) return { error: buildResultError('ambiguous', 'Selector', exact) };
|
||||
|
||||
const fuse = new Fuse(candidates, {
|
||||
includeScore: true,
|
||||
threshold: FUZZY_THRESHOLD,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'nickname', weight: 0.78 },
|
||||
{ name: 'cookieUserId', weight: 0.12 },
|
||||
{ name: 'fingerprintId', weight: 0.08 },
|
||||
{ name: 'id', weight: 0.08 },
|
||||
{ name: 'knownIps', weight: 0.02 },
|
||||
],
|
||||
});
|
||||
const results = fuse.search(query);
|
||||
if (!results.length) return { error: buildResultError('not_found', 'Selector', candidates) };
|
||||
|
||||
const first = results[0];
|
||||
const second = results[1];
|
||||
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < AMBIGUOUS_SCORE_GAP) {
|
||||
return { error: buildResultError('ambiguous', 'Selector', results.map((entry) => entry.item)) };
|
||||
}
|
||||
return { record: first.item.record, label: first.item.label };
|
||||
}
|
||||
|
||||
function mask(v) {
|
||||
const key = normalizeText(v);
|
||||
if (!key) return 'n/a';
|
||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compactJoin,
|
||||
mask,
|
||||
normalizeText,
|
||||
normalizeSearchText,
|
||||
resolveIdentitySelector,
|
||||
resolveRoverSelector,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
// Operator Verify Command
|
||||
// Purpose: Handles verified-user moderation commands for lockdown admins.
|
||||
// Scope: Supports list and remove subcommands.
|
||||
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||
|
||||
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, sanitizeMentions, config }) {
|
||||
// Usage text comes from the same core prefix that both transports parse.
|
||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||
return async function handleVerifyCommand(message, tokens) {
|
||||
if (!message.actor?.isLockdownAdmin) {
|
||||
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
const action = (tokens.shift() || 'list').toLowerCase();
|
||||
if (action === 'list') {
|
||||
const users = listVerifiedUsers();
|
||||
if (!users.length) return message.reply({ content: 'No verified users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.nickname || 'unknown'} | ${entry.userId || entry.id} | ${mask(entry.cookieUserId)}`);
|
||||
return message.reply({ content: ['Verified users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
if (action === 'remove') {
|
||||
const selector = tokens.join(' ').trim();
|
||||
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} verify remove <cookieUserId|nickname>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||
try {
|
||||
const resolved = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
// The verification service owns the actual removal and event emission.
|
||||
// The command resolver only turns a human-friendly or fuzzy nickname
|
||||
// into the stable cookie id so the service does not need Discord/Web
|
||||
// command concerns baked into its storage API.
|
||||
const removed = removeVerifiedUser(resolved.record.userId || resolved.record.id || resolved.record.cookieUserId, message.actor?.id || null);
|
||||
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
|
||||
} catch (err) {
|
||||
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
}
|
||||
}
|
||||
return message.reply({ content: `Unknown verify command. Use \`${commandPrefix} verify list\` or \`${commandPrefix} verify remove <cookieUserId|nickname>\`.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createVerifyCommand };
|
||||
@@ -0,0 +1,45 @@
|
||||
// Operator Command Configuration
|
||||
// Purpose: Owns transport-neutral command names used by site chat and optional integrations.
|
||||
// Scope: Prevents Discord configuration from defining whether core server commands can be parsed.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
|
||||
function getCommandConfig(config = loadConfig()) {
|
||||
const commandConfig = config.commands || {};
|
||||
const prefix = String(commandConfig.prefix || 'rs').trim() || 'rs';
|
||||
const timeStatusCommand = commandConfig.timeStatusCommand === null
|
||||
? ''
|
||||
: String(commandConfig.timeStatusCommand || 'ts').trim();
|
||||
|
||||
return { prefix, timeStatusCommand };
|
||||
}
|
||||
|
||||
function parseCommandText(text, config = loadConfig()) {
|
||||
const clean = String(text || '').trim();
|
||||
const lower = clean.toLowerCase();
|
||||
const { prefix, timeStatusCommand } = getCommandConfig(config);
|
||||
const normalizedPrefix = prefix.toLowerCase();
|
||||
const normalizedTimeStatus = timeStatusCommand.toLowerCase();
|
||||
|
||||
if (normalizedTimeStatus && lower === normalizedTimeStatus) {
|
||||
return { matched: true, kind: 'time-status', body: '', action: 'time-status', tokens: [] };
|
||||
}
|
||||
|
||||
if (!lower.startsWith(normalizedPrefix)) return { matched: false };
|
||||
const nextCharacter = clean.charAt(prefix.length);
|
||||
if (nextCharacter && !/\s/.test(nextCharacter)) return { matched: false };
|
||||
|
||||
const body = clean.slice(prefix.length).trim();
|
||||
const tokens = body ? body.split(/\s+/) : [];
|
||||
return {
|
||||
matched: true,
|
||||
kind: 'prefixed',
|
||||
body,
|
||||
action: String(tokens[0] || '').toLowerCase(),
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCommandConfig,
|
||||
parseCommandText,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
// Operator Command Help
|
||||
// Purpose: Generates organized command help from one descriptive command catalogue.
|
||||
// Scope: Keeps shared command discovery consistent while allowing Discord-only extensions to stay transport-specific.
|
||||
const { CATEGORIES, buildCommandRegistry } = require('./registry');
|
||||
|
||||
function renderDetailed(name, entry, isFeatureEnabled) {
|
||||
const details = [`**${name}**`, entry.summary];
|
||||
if (entry.access) details.push(`Permission: ${entry.access}`);
|
||||
if (entry.requiredFeature) details.push(`Required feature: ${entry.requiredFeature}`);
|
||||
if (entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature)) details.push('Availability: unavailable on this server');
|
||||
details.push('Usage:', ...entry.usage.map((usage) => `- \`${usage}\``));
|
||||
return details.join('\n');
|
||||
}
|
||||
|
||||
function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = '', includeDiscord = true, isFeatureEnabled = () => true } = {}) {
|
||||
const prefix = String(commandPrefix || 'rs').trim() || 'rs';
|
||||
const timeCommand = timeStatusCommand ? String(timeStatusCommand).trim() : '';
|
||||
const entries = buildCommandRegistry(prefix, timeCommand);
|
||||
const normalizedTopic = String(topic || '').trim().toLowerCase();
|
||||
|
||||
if (entries[normalizedTopic] && (normalizedTopic !== 'bridge' || includeDiscord)) {
|
||||
return renderDetailed(normalizedTopic, entries[normalizedTopic], isFeatureEnabled);
|
||||
}
|
||||
|
||||
const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic;
|
||||
const categoryNames = requestedCategory && CATEGORIES[requestedCategory]
|
||||
? [requestedCategory]
|
||||
: ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])];
|
||||
|
||||
const output = ['**Rover Bot Commands**'];
|
||||
for (const categoryName of categoryNames) {
|
||||
if (categoryName === 'discord' && !includeDiscord) continue;
|
||||
const category = CATEGORIES[categoryName];
|
||||
output.push('', `**${category.title}**`);
|
||||
for (const name of category.names) {
|
||||
const entry = entries[name];
|
||||
if (!entry?.usage?.length) continue;
|
||||
const availability = entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature) ? ' *(unavailable)*' : '';
|
||||
output.push(`\`${entry.usage[0]}\` — ${entry.summary}${availability}`);
|
||||
}
|
||||
}
|
||||
output.push('', `Use \`${prefix} help <command|category>\` for details.`);
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { formatHelp };
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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 { createLightsCommand } = require('./commands/lights');
|
||||
const { createKickCommand } = require('./commands/kick');
|
||||
const { createLiftCommand } = require('./commands/lift');
|
||||
const { createNeatoCommand } = require('./commands/neato');
|
||||
const { getCommandConfig } = require('./config');
|
||||
const { buildCommandRegistry } = require('./registry');
|
||||
|
||||
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 handleBridgeCommand = transportHandlers.bridge;
|
||||
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
||||
const handleLightsCommand = createLightsCommand(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. Room
|
||||
// light locking belongs here because it can force the physical room lights
|
||||
// on and disables ordinary Home Assistant room controls for everyone else.
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
|
||||
|
||||
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
|
||||
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 '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);
|
||||
default:
|
||||
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
|
||||
}
|
||||
}
|
||||
|
||||
return { handleCommand };
|
||||
}
|
||||
|
||||
module.exports = { createCommandHandlers };
|
||||
@@ -0,0 +1,32 @@
|
||||
// Operator Command Registry
|
||||
// Purpose: Describes command categories, discovery text, permissions, and feature requirements in one place.
|
||||
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
||||
const CATEGORIES = {
|
||||
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
||||
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'lights', 'kick', 'verify', 'deter'] },
|
||||
features: { title: 'Features', names: ['lift', 'neato'] },
|
||||
discord: { title: 'Discord', names: ['bridge'] },
|
||||
};
|
||||
|
||||
function buildCommandRegistry(prefix, timeCommand) {
|
||||
return {
|
||||
help: { category: 'system', summary: 'Show command help.', usage: [`${prefix} help [command|category]`] },
|
||||
status: { category: 'system', summary: 'Show rover status; rover names can be fuzzy.', usage: [`${prefix} status [rover]`] },
|
||||
replay: { category: 'system', summary: 'Create an instant replay from selected sources.', usage: [`${prefix} replay [sources]`] },
|
||||
'time-status': { category: 'system', summary: 'Show the current time status.', usage: timeCommand ? [timeCommand] : [] },
|
||||
lock: { category: 'admin', summary: 'Lock a rover.', usage: [`${prefix} lock <rover>`], access: 'Admin', permission: 'admin' },
|
||||
unlock: { category: 'admin', summary: 'Unlock a rover.', usage: [`${prefix} unlock <rover>`], access: 'Admin', permission: 'admin' },
|
||||
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
|
||||
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
|
||||
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
|
||||
lights: { category: 'admin', summary: 'Show or change the room-light lock.', usage: [`${prefix} lights <status|lock|unlock>`], access: 'Admin', permission: 'admin' },
|
||||
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
|
||||
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
|
||||
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
|
||||
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Admin', permission: 'admin', requiredFeature: 'lift', unavailableLabel: 'Lift' },
|
||||
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Admin', permission: 'admin', 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' },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { CATEGORIES, buildCommandRegistry };
|
||||
@@ -0,0 +1,68 @@
|
||||
// Web Chat Command Transport
|
||||
// Purpose: Renders status-oriented operator commands as the same plain text web chat expects.
|
||||
// Scope: Avoids importing Discord.js merely to flatten an embed back into text.
|
||||
const { resolveRoverSelector } = require('./commands/resolvers');
|
||||
|
||||
function formatTimeInZone(date, timeZone) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', minute: '2-digit', hour12: false }).format(date);
|
||||
} catch (_err) {
|
||||
return 'n/a';
|
||||
}
|
||||
}
|
||||
|
||||
function createWebTransportHandlers({ rovers, roverManager, config, siteUrl = '' }) {
|
||||
return {
|
||||
async status(message, roverId) {
|
||||
const resolved = roverId ? resolveRoverSelector(roverId, rovers) : null;
|
||||
if (roverId && resolved?.error) return message.reply(`Rover Status\n\n${resolved.error}`);
|
||||
const records = roverId
|
||||
? [resolved.record]
|
||||
: Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
|
||||
if (!records.length) return message.reply('Rover Battery Status\n\nNo rovers online.');
|
||||
|
||||
// This mirrors the human-readable content of the established Discord
|
||||
// battery embed while remaining a plain transport-neutral chat result.
|
||||
const fields = records.map((record) => {
|
||||
const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || {};
|
||||
const battery = record.batteryState || {};
|
||||
const name = record.meta?.name || record.id;
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const chargingLabel = String(sensors?.chargingState?.label || 'unknown');
|
||||
const charging = ['waiting', 'full charging', 'trickle charging'].includes(chargingLabel.toLowerCase()) || [2, 3, 4].includes(sensors?.chargingState?.code);
|
||||
const lockLabel = record.locked ? `locked${record.lockReason ? ` (${record.lockReason})` : ''}` : 'unlocked';
|
||||
const charge = battery.charge != null && battery.capacity != null ? `${battery.charge}/${battery.capacity}mAh` : 'n/a';
|
||||
const percent = battery.percentDisplay != null ? `${battery.percentDisplay}%` : 'n/a';
|
||||
return [
|
||||
name,
|
||||
`Dock: ${docked ? 'docked' : 'undocked'}`,
|
||||
`Charging: ${charging ? `charging (${chargingLabel})` : 'not charging'}`,
|
||||
`Battery: ${charge} (${percent})`,
|
||||
`Voltage: ${sensors?.voltageMv == null ? 'n/a' : `${(sensors.voltageMv / 1000).toFixed(2)}V`}`,
|
||||
`Current: ${sensors?.currentMa == null ? 'n/a' : `${sensors.currentMa}mA`}`,
|
||||
`OI: ${String(sensors?.oiMode?.label || 'unknown').toLowerCase()}`,
|
||||
`Lock: ${lockLabel}`,
|
||||
].join('\n');
|
||||
});
|
||||
return message.reply(['Rover Battery Status', ...fields].join('\n\n'));
|
||||
},
|
||||
async timeStatus(message) {
|
||||
const serverTimezone = config.timezone || config.server?.timezone || process.env.TZ || 'America/New_York';
|
||||
const zones = [
|
||||
['UTC', 'UTC'], ['US Pacific', 'America/Los_Angeles'], ['US Mountain', 'America/Denver'],
|
||||
['US Central', 'America/Chicago'], ['US Eastern', 'America/New_York'], ['Europe London', 'Europe/London'],
|
||||
['Europe Berlin', 'Europe/Berlin'], ['Asia Kolkata', 'Asia/Kolkata'], ['Asia Shanghai', 'Asia/Shanghai'],
|
||||
['Asia Tokyo', 'Asia/Tokyo'], ['Australia Sydney', 'Australia/Sydney'], ['New Zealand Auckland', 'Pacific/Auckland'],
|
||||
];
|
||||
const now = new Date();
|
||||
const lines = zones.map(([label, zone]) => `${label} — ${formatTimeInZone(now, zone)}${zone.toLowerCase() === String(serverTimezone).toLowerCase() ? ' **(server local timezone)**' : ''}`);
|
||||
if (!zones.some(([, zone]) => zone.toLowerCase() === String(serverTimezone).toLowerCase())) {
|
||||
lines.push(`Server Local — ${formatTimeInZone(now, serverTimezone)} **(server local timezone)**`);
|
||||
}
|
||||
const siteLink = siteUrl ? `\n\n${siteUrl}` : '';
|
||||
return message.reply(`Time Status\n${lines.join('\n')}${siteLink}\n\nServer local timezone: ${serverTimezone}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWebTransportHandlers };
|
||||
Reference in New Issue
Block a user