mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -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 };
|
||||
Reference in New Issue
Block a user