better discord commands, rs commands should work in chat

This commit is contained in:
legop3
2026-06-19 23:15:14 -04:00
parent bd857e6387
commit f0a7d72f94
10 changed files with 479 additions and 58 deletions
+17 -1
View File
@@ -8,9 +8,10 @@ const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
const { broadcastMessage, broadcastTyping } = require('./broadcast');
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
const { runChatTextCommand } = require('./textCommands');
function createHandlers({ sendSystemMessage }) {
function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
const role = getRole(socket);
void role;
const normalized = normalizeUserText(text);
@@ -24,6 +25,21 @@ function createHandlers({ sendSystemMessage }) {
// where an external API or rover-side feature has a real hard limit.
if (hasProfanity(clean)) return cb({ error: 'Message blocked' });
try {
const consumedAsCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage });
if (consumedAsCommand) {
cb({ success: true, command: true });
return;
}
} catch (err) {
// Command errors are returned through the existing chat acknowledgement
// contract. This keeps command execution server-side and avoids adding a
// new client pathway just to display failures.
logger.warn('Chat command failed', { socket: socket?.id, error: err.message });
cb({ error: err.message || 'Command failed' });
return;
}
const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, {
@@ -0,0 +1,221 @@
// Chat Text Commands
// Purpose: Lets normal site chat submit the same `rs`/`ts` server commands used by Discord.
// Scope: Adapts a socket chat message into the shared command router without adding any client-side command logic.
const io = require('../../globals/io');
const roverManager = require('../roverManager');
const { MODES, getMode, setMode } = require('../modeManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const { getActiveDrivers } = require('../turnService');
const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
deterUser,
undeterUser,
} = require('../verificationService');
const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService');
const { loadConfig } = require('../../helpers/configLoader');
const { createCommandHandlers } = require('../discordBotService/commands');
const {
buildReplayJobId,
buildReplayTitle,
createReplaySourceResolver,
} = require('../discordBotService/replayWorkflow');
const config = loadConfig();
const discordConfig = config.discord || {};
function isTextCommand(text) {
const clean = String(text || '').trim();
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
}
function sanitizeMentions(text) {
return String(text || '')
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
.replace(/@everyone/gi, '[everyone]')
.replace(/@here/gi, '[here]');
}
function embedToText(embed) {
const data = embed?.data || embed || {};
const lines = [];
if (data.title) lines.push(String(data.title));
if (data.description) lines.push(String(data.description));
(Array.isArray(data.fields) ? data.fields : []).forEach((field) => {
if (!field) return;
// Discord embeds have structured fields. Chat is plain text, so flattening
// name/value pairs keeps the command result readable without creating any
// new web-specific UI or payload contract.
lines.push(`${field.name || 'Field'}\n${field.value || ''}`.trim());
});
if (data.footer?.text) lines.push(String(data.footer.text));
return lines.filter(Boolean).join('\n\n');
}
function replyPayloadToText(payload) {
if (typeof payload === 'string') return payload;
if (!payload || typeof payload !== 'object') return '';
const parts = [];
if (payload.content) parts.push(String(payload.content));
(Array.isArray(payload.embeds) ? payload.embeds : []).forEach((embed) => {
const text = embedToText(embed);
if (text) parts.push(text);
});
return parts.join('\n\n').trim();
}
function buildRequesterLabel(socket) {
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
}
function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
return function createReplayHandler({
rovers,
getReplaySources: getAllReplaySources,
getDefaultDiscordSources,
validateSources: validateReplaySources,
}) {
const sourceResolver = createReplaySourceResolver({
rovers,
getReplaySources: () => getAllReplaySources(socket),
getDefaultDiscordSources: () => {
const assignment = assignmentService.describeAssignment(socket.id);
return replayApi.getDefaultWebSources(assignment, socket);
},
validateSources: (sources) => validateReplaySources(sources, socket),
});
return async function handleWebReplayCommand(message, query) {
if (getMode() === MODES.LOCKDOWN) {
await message.reply({ content: 'Replay denied: server is in lockdown.' });
return;
}
const channelId = discordConfig?.channels?.replay || null;
if (!channelId) {
await message.reply({ content: 'Replay denied: replay channel is not configured.' });
return;
}
const resolved = sourceResolver.resolve(query);
if (resolved?.error) {
await message.reply({ content: resolved.error });
return;
}
const requester = buildRequesterLabel(socket);
const jobId = buildReplayJobId('web-chat');
const attempt = replayApi.tryTriggerReplay({ by: { source: 'web-chat', requester } });
if (!attempt.ok) {
await message.reply({ content: `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.` });
return;
}
// Site chat cannot upload Discord attachments directly. Publishing the
// same replay.requested event used by the existing web replay button keeps
// the actual render/upload pipeline centralized while still letting chat
// commands use the normal server-side command route.
publishEvent({
source: 'chatCommand',
type: 'replay.requested',
payload: {
jobId,
channelId,
requester,
title: '',
includeSidebar: true,
sources: resolved.sources || [],
requestedBy: { socketId: socket.id },
},
});
const title = buildReplayTitle({ explicitTitle: '', sources: resolved.sources || [] });
sendSystemMessage(`Replay accepted: ${title}`, { nickname: 'Rover bot', bot: true });
};
};
}
function createChatCommandMessage({ socket, text, sendSystemMessage }) {
const nickname = buildRequesterLabel(socket);
return {
content: String(text || '').trim(),
author: {
bot: false,
id: socket.id,
username: nickname,
},
member: {
nickname,
},
reply: async (payload) => {
const response = sanitizeMentions(replyPayloadToText(payload));
if (!response) return null;
return sendSystemMessage(response, { nickname: 'Rover bot', bot: true });
},
};
}
async function runChatTextCommand({ text, socket, sendSystemMessage }) {
if (!isTextCommand(text)) return false;
// ReplayEngineV2 has startup side effects by design. Loading it lazily here
// keeps ordinary chatService initialization from changing the service boot
// order, while still letting `rs replay` use the existing replay pipeline.
const replayApi = require('../replayEngineV2');
const message = createChatCommandMessage({ socket, text, sendSystemMessage });
const commands = createCommandHandlers({
logger: null,
client: null,
io,
rovers: roverManager.rovers,
roverManager,
getMode,
MODES,
setMode,
lockRover: roverManager.lockRover,
getNickname,
getActiveDrivers,
buildReplayVideo: replayApi.buildReplayVideo,
getReplaySources: replayApi.getReplaySources,
getDefaultDiscordSources: () => replayApi.getDefaultWebSources(assignmentService.describeAssignment(socket.id), socket),
validateSources: replayApi.validateSources,
tryTriggerReplay: replayApi.tryTriggerReplay,
getGlobalObjective,
setGlobalObjective,
clearGlobalObjective,
getAdminReason,
setAdminReason,
clearAdminReason,
getGuildConfig: () => null,
setGuildConfig: () => null,
removeGuildConfig: () => null,
normalizeMode: (mode) => mode,
VALID_MODES: new Set(['global', 'private']),
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
deterUser,
undeterUser,
sanitizeMentions,
sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
discordConfig,
config,
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
});
// Let the shared router perform normal command permission checks. Returning
// true tells chatService that the text was consumed as a command and should
// not be broadcast as a regular user chat message.
await commands.handleCommand(message);
return true;
}
module.exports = {
isTextCommand,
runChatTextCommand,
};
@@ -1,13 +1,9 @@
// Discord Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands.
function createDeterCommand({ listDeterredUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) {
function mask(v) {
const key = String(v || '').trim();
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)}`;
}
const { mask, resolveIdentitySelector } = require('./resolvers');
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) {
return async function handleDeterCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
@@ -22,11 +18,18 @@ function createDeterCommand({ listDeterredUsers, deterUser, undeterUser, isLockd
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
}
if (action === 'ban') {
const selector = String(tokens.shift() || '').trim();
const reason = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs deter ban <cookieUserId|nickname|ip> [reason]`', allowedMentions: { parse: [], repliedUser: false } });
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs deter ban <cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
try {
const deterred = deterUser(selector, { reason, actor: message.author?.id || null });
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?.cookieUserId || selector;
const deterred = deterUser(stableSelector, { actor: message.author?.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 } });
@@ -36,13 +39,15 @@ function createDeterCommand({ listDeterredUsers, deterUser, undeterUser, isLockd
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs deter unban <id|cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
try {
const removed = undeterUser(selector, message.author?.id || null);
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.author?.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 `rs deter list`, `rs deter ban <selector> [reason]`, or `rs deter unban <selector>`.', allowedMentions: { parse: [], repliedUser: false } });
return message.reply({ content: 'Unknown deter command. Use `rs deter list`, `rs deter ban <selector>`, or `rs deter unban <selector>`.', allowedMentions: { parse: [], repliedUser: false } });
};
}
@@ -5,22 +5,22 @@ function formatHelp() {
return [
'**Rover Bot Commands**',
'`rs help` — show this help',
'`rs status [id]` — show rover status (all or one)',
'`rs replay [sources]` — send instant replay (room/rover)',
'`rs status [rover]` — show rover status; rover names can be fuzzy',
'`rs replay [sources]` — send instant replay; source names can be fuzzy',
'`rs bridge` — show chat bridge status for this server',
'`rs bridge here <global|private>` — set chat bridge to this channel',
'`rs bridge mode <global|private>` — change chat bridge mode',
'`rs bridge off` — disable chat bridge for this server',
'`rs lock <id>` — lock a rover',
'`rs unlock <id>` — unlock a rover',
'`rs lock <rover>` — lock a rover; rover names can be fuzzy',
'`rs unlock <rover>` — unlock a rover; rover names can be fuzzy',
'`rs mode <open|turns|admin|lockdown>` — change server mode',
'`rs reason [text|clear]` — show or set admin mode reason',
'`rs goal [text|clear]` — show or set global objective',
'`rs verify list` — list verified users (lockdown admins)',
'`rs verify remove <cookieUserId|nickname>` — remove verified user (lockdown admins)',
'`rs verify remove <cookieUserId|nickname>` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)',
'`rs deter list` — list deterred users (lockdown admins)',
'`rs deter ban <cookieUserId|nickname|ip> [reason]` — deter a user (lockdown admins)',
'`rs deter unban <id|cookieUserId|nickname|ip>` — remove deterrence (lockdown admins)',
'`rs deter ban <cookieUserId|nickname|ip>` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)',
'`rs deter unban <id|cookieUserId|nickname|ip>` — remove deterrence; nicknames can be fuzzy or multi-word (lockdown admins)',
'`ts` — show time status',
].join('\n');
}
@@ -22,7 +22,9 @@ function createCommandHandlers(deps) {
} = deps;
const handleStatusCommand = createStatusCommand(deps);
const handleReplayCommand = createReplayCommand(deps);
const handleReplayCommand = deps.createReplayTextCommand
? deps.createReplayTextCommand(deps)
: createReplayCommand(deps);
const handleLockCommand = createLockCommand(deps);
const handleModeCommand = createModeCommand(deps);
const handleReasonCommand = createReasonCommand(deps);
@@ -36,18 +38,24 @@ function createCommandHandlers(deps) {
if (message.author.bot) return;
const content = (message.content || '').trim();
const lower = content.toLowerCase();
if (lower === 'ts' || lower.startsWith('ts')) return handleTimeStatusCommand(message);
if (!lower.startsWith('rs')) return;
// 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 (lower === 'ts') return handleTimeStatusCommand(message);
if (!/^rs(?:\s|$)/i.test(content)) return;
const tokens = content.split(/\s+/);
tokens.shift();
const action = (tokens.shift() || '').toLowerCase();
const rest = tokens.join(' ').trim();
const isAdmin = isAdminUser(message.author.id);
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
const mode = getMode();
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter']);
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
@@ -59,7 +67,7 @@ function createCommandHandlers(deps) {
switch (action) {
case '':
case 'status':
return handleStatusCommand(message, tokens[0]);
return handleStatusCommand(message, rest);
case 'help':
return message.reply(formatHelp());
case 'replay':
@@ -67,9 +75,9 @@ function createCommandHandlers(deps) {
case 'bridge':
return handleBridgeCommand(message, tokens);
case 'lock':
return handleLockCommand(message, tokens[0], true);
return handleLockCommand(message, rest, true);
case 'unlock':
return handleLockCommand(message, tokens[0], false);
return handleLockCommand(message, rest, false);
case 'mode':
return handleModeCommand(message, tokens);
case 'goal':
@@ -1,15 +1,25 @@
// Discord Lock Command
// Purpose: Handles `rs lock` and `rs unlock` operations for rover availability control.
// Scope: Applies lock state updates for a single rover ID.
function createLockCommand({ lockRover, sanitizeMentions }) {
const { resolveRoverSelector } = require('./resolvers');
function createLockCommand({ lockRover, sanitizeMentions, rovers }) {
return async function handleLockCommand(message, roverId, locked) {
if (!roverId) {
await message.reply({ content: 'Specify a rover ID. Example: `rs lock alpha`', allowedMentions: { parse: [], repliedUser: false } });
return;
}
try {
lockRover(roverId, locked, { reason: 'discord' });
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`), allowedMentions: { parse: [], repliedUser: false } });
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.
lockRover(resolved.id, locked, { reason: 'discord' });
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 } });
}
@@ -0,0 +1,164 @@
// Discord 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 cookieUserId = normalizeText(record?.cookieUserId);
const nickname = normalizeText(record?.nickname);
const knownIps = Array.isArray(record?.knownIps) ? record.knownIps.map(normalizeText).filter(Boolean) : [];
return {
key: id || cookieUserId || nickname,
id,
cookieUserId,
nickname,
knownIps,
label: compactJoin([nickname || 'unknown', includeId && id ? id : '', cookieUserId ? mask(cookieUserId) : '']),
record,
searchId: normalizeSearchText(id),
searchCookie: normalizeSearchText(cookieUserId),
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.searchCookie === 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: '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,
};
@@ -3,23 +3,20 @@
// Scope: Builds and sends rover status embed for one rover or all visible rovers.
const { EmbedBuilder } = require('discord.js');
const { buildBatteryStatusEmbed } = require('../batteryEmbeds');
const { resolveRoverSelector } = require('./resolvers');
function createStatusCommand({ rovers, roverManager }) {
function findRoverRecord(id) {
if (!id) return null;
for (const record of rovers.values()) {
if (String(record.id) === String(id) || String(record.meta?.name) === String(id)) return record;
}
return null;
}
return async function handleStatusCommand(message, roverId) {
const single = roverId ? findRoverRecord(roverId) : null;
if (roverId && !single) {
const embed = new EmbedBuilder().setTitle('Rover Status').setDescription('Unknown rover.').setColor(0x2196f3).setTimestamp(new Date());
const resolved = roverId ? resolveRoverSelector(roverId, rovers) : null;
if (roverId && resolved?.error) {
const embed = new EmbedBuilder().setTitle('Rover Status').setDescription(resolved.error).setColor(0x2196f3).setTimestamp(new Date());
await message.reply({ embeds: [embed], allowedMentions: { parse: [], repliedUser: false } });
return;
}
const records = roverId ? [single] : Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
// Status accepts fuzzy display names, but the final status payload is still
// built from the canonical rover record so battery/private/lock fields stay
// identical to the all-rover status view.
const records = roverId ? [resolved.record] : Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
await message.reply({
embeds: [buildBatteryStatusEmbed({ color: 0x2196f3, records })],
allowedMentions: { parse: [], repliedUser: false },
@@ -1,14 +1,9 @@
// Discord Verify Command
// Purpose: Handles verified-user moderation commands for lockdown admins.
// Scope: Supports list and remove subcommands.
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions }) {
function mask(v) {
const key = String(v || '').trim();
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)}`;
}
const { mask, resolveIdentitySelector } = require('./resolvers');
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions }) {
return async function handleVerifyCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
@@ -25,7 +20,13 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs verify remove <cookieUserId|nickname>`', allowedMentions: { parse: [], repliedUser: false } });
try {
const removed = removeVerifiedUser(selector, message.author?.id || null);
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.cookieUserId, message.author?.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 } });
+8 -9
View File
@@ -1,17 +1,16 @@
5. barcode wiki links
6. implement multitabbing prevention using the identity system
7. make google tts the default everywhere but roverd
8. fix rover request spam queue cheat
9. fix up ALL discord admin commands
1. implement multitabbing prevention using the identity system
2. make google tts the default everywhere but roverd
3. fix rover request spam queue cheat
4. fix up ALL discord admin commands
1. make sure all permissions are correct
2. fuzzy search all the things
3. dont break on multi word nicknames
4. make all rs commands work form both the site chat and discord
1. make sure all the permissions are correct
10. make alert feed.jsx show more alerts at once
11. unify typing row and chat row, should be simple
12. fix google TTS speeds
13. fix this:
5. make alert feed.jsx show more alerts at once
6. unify typing row and chat row, should be simple
7. fix google TTS speeds
8. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^