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
@@ -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 } });