better banning, new deter mute, replay discord fixes.

This commit is contained in:
legop3
2026-07-21 22:50:12 -04:00
parent fb9565faf9
commit b26daed93f
25 changed files with 628 additions and 46 deletions
@@ -1,10 +1,45 @@
// Operator Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers');
const { mask, normalizeSearchText, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, sanitizeMentions, config }) {
function findOnlineNicknameMatches(io, getNickname, selector) {
const normalizedSelector = normalizeSearchText(selector);
if (!normalizedSelector) return [];
const matchesByUserId = new Map();
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return [];
sockets.forEach((socket) => {
const nickname = getNickname(socket);
const userId = String(socket?.data?.userId || '').trim();
if (!userId || normalizeSearchText(nickname) !== normalizedSelector) return;
/*
One person may have multiple connected tabs or surfaces. Collapse those
sockets to the canonical user id so duplicate tabs do not manufacture an
ambiguous moderation target when they all represent the same identity.
*/
if (!matchesByUserId.has(userId)) {
matchesByUserId.set(userId, { userId, nickname });
}
});
return Array.from(matchesByUserId.values());
}
function uniqueIdentityRecords(records = []) {
const byIdentity = new Map();
records.forEach((record) => {
const key = record?.userId || record?.id || record?.cookieUserId || record?.fingerprintId;
if (key && !byIdentity.has(key)) byIdentity.set(key, record);
});
return Array.from(byIdentity.values());
}
function createDeterCommand({ io, getNickname, listDeterredUsers, listMutedUsers, listVerifiedUsers, deterUser, undeterUser, muteUser, unmuteUser, sanitizeMentions, config }) {
// Moderation usage errors use the same core prefix shown by organized help.
const { prefix: commandPrefix } = getCommandConfig(config);
@@ -15,23 +50,58 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
}
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 } });
const deterredUsers = listDeterredUsers().map((entry) => ({ ...entry, deterred: true }));
const mutedUsers = listMutedUsers().map((entry) => ({ ...entry, muted: true }));
const usersById = new Map();
[...deterredUsers, ...mutedUsers].forEach((entry) => {
const userId = entry.userId || entry.id;
if (!userId) return;
const existing = usersById.get(userId) || {};
usersById.set(userId, {
...existing,
...entry,
deterred: Boolean(existing.deterred || entry.deterred),
muted: Boolean(existing.muted || entry.muted),
});
});
const users = Array.from(usersById.values());
if (!users.length) return message.reply({ content: 'No deterred or muted users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => {
const flags = [entry.deterred ? 'deterred' : '', entry.muted ? 'muted' : ''].filter(Boolean).join(', ');
return `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${flags} | ${mask(entry.cookieUserId)}`;
});
return message.reply({ content: ['Moderated 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 } });
const onlineMatches = findOnlineNicknameMatches(io, getNickname, selector);
if (onlineMatches.length > 1) {
/*
Identical live nicknames are genuinely ambiguous, so do not guess
for a destructive command. Unlike the old generic error, this
response exposes stable selectors that the administrator can copy
directly into a follow-up command.
*/
const choices = onlineMatches.map((match) => `${match.nickname} (${match.userId})`).join(', ');
return message.reply({
content: sanitizeMentions(`More than one online user is named ${selector}: ${choices}. Retry with \`${commandPrefix} deter ban <userId>\`.`),
allowedMentions: { parse: [], repliedUser: false },
});
}
let stableSelector = onlineMatches[0]?.userId || null;
if (!stableSelector) {
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 } });
}
stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
}
// 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) {
@@ -50,8 +120,47 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
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 } });
if (action === 'mute' || action === 'unmute') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ${action} <userId|cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
try {
const onlineMatches = findOnlineNicknameMatches(io, getNickname, selector);
if (onlineMatches.length > 1) {
const choices = onlineMatches.map((match) => `${match.nickname} (${match.userId})`).join(', ');
return message.reply({
content: sanitizeMentions(`More than one online user is named ${selector}: ${choices}. Retry with \`${commandPrefix} deter ${action} <userId>\`.`),
allowedMentions: { parse: [], repliedUser: false },
});
}
let stableSelector = onlineMatches[0]?.userId || null;
if (!stableSelector) {
const storedCandidates = uniqueIdentityRecords([...listVerifiedUsers(), ...listMutedUsers()]);
const storedMatch = resolveIdentitySelector(selector, storedCandidates, { includeId: true });
if (storedMatch.error && !/not found/i.test(storedMatch.error)) {
return message.reply({ content: sanitizeMentions(storedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
}
/*
Unverified users may not appear in the convenience candidate list.
Passing the original exact selector through lets verificationService
resolve any canonical identity without making fuzzy guesses here.
*/
stableSelector = storedMatch.record?.userId || storedMatch.record?.id || storedMatch.record?.cookieUserId || selector;
}
const updated = action === 'mute'
? muteUser(stableSelector, message.actor?.id || null)
: unmuteUser(stableSelector, message.actor?.id || null);
return message.reply({
content: sanitizeMentions(`${action === 'mute' ? 'Muted' : 'Unmuted'} ${updated.nickname || 'unknown'} (${mask(updated.cookieUserId)}).`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to ${action} user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, \`${commandPrefix} deter unban <selector>\`, \`${commandPrefix} deter mute <selector>\`, or \`${commandPrefix} deter unmute <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
};
}
module.exports = { createDeterCommand };
module.exports = { createDeterCommand, findOnlineNicknameMatches, uniqueIdentityRecords };
@@ -0,0 +1,113 @@
// Operator Deter Command Tests
// Purpose: Verifies that live nickname identity takes precedence over ambiguous stored aliases.
// Scope: Exercises only command target resolution with in-memory socket and identity doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createDeterCommand } = require('./deter');
function createSocket(id, userId, nickname) {
return { id, data: { userId, nickname } };
}
function createHarness(sockets = [], verifiedUsers = []) {
const deterCalls = [];
const muteCalls = [];
const replies = [];
const handler = createDeterCommand({
io: { sockets: { sockets: new Map(sockets.map((socket) => [socket.id, socket])) } },
getNickname: (socket) => socket?.data?.nickname || '',
listDeterredUsers: () => [],
listMutedUsers: () => [],
listVerifiedUsers: () => verifiedUsers,
deterUser: (selector) => {
deterCalls.push(selector);
return { created: true, nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
undeterUser: () => null,
muteUser: (selector) => {
muteCalls.push({ action: 'mute', selector });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
unmuteUser: (selector) => {
muteCalls.push({ action: 'unmute', selector });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
const message = {
actor: { id: 'admin', isLockdownAdmin: true },
reply: async (payload) => {
replies.push(payload);
return payload;
},
};
return { handler, message, deterCalls, muteCalls, replies };
}
test('prefers the one online exact nickname over ambiguous stored records', async () => {
const verifiedUsers = [
{ userId: 'old-user', nickname: 'Croissant', cookieUserId: 'old-cookie' },
{ userId: 'live-user', nickname: 'Croissant', cookieUserId: 'live-cookie' },
];
const { handler, message, deterCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
], verifiedUsers);
await handler(message, ['ban', 'croissant']);
assert.deepEqual(deterCalls, ['live-user']);
});
test('collapses multiple sockets belonging to the same online identity', async () => {
const { handler, message, deterCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
createSocket('socket-2', 'live-user', 'croissant'),
]);
await handler(message, ['ban', 'Croissant']);
assert.deepEqual(deterCalls, ['live-user']);
});
test('returns usable user ids when different online identities share a nickname', async () => {
const { handler, message, deterCalls, replies } = createHarness([
createSocket('socket-1', 'user-one', 'Croissant'),
createSocket('socket-2', 'user-two', 'croissant'),
]);
await handler(message, ['ban', 'croissant']);
assert.deepEqual(deterCalls, []);
assert.match(replies[0].content, /user-one/);
assert.match(replies[0].content, /user-two/);
assert.match(replies[0].content, /rs deter ban <userId>/);
});
test('mute uses the same exact online nickname preference as ban', async () => {
const verifiedUsers = [
{ userId: 'old-user', nickname: 'Croissant', cookieUserId: 'old-cookie' },
{ userId: 'live-user', nickname: 'Croissant', cookieUserId: 'live-cookie' },
];
const { handler, message, muteCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
], verifiedUsers);
await handler(message, ['mute', 'croissant']);
assert.deepEqual(muteCalls, [{ action: 'mute', selector: 'live-user' }]);
});
test('unmute returns usable ids for genuinely duplicated online nicknames', async () => {
const { handler, message, muteCalls, replies } = createHarness([
createSocket('socket-1', 'user-one', 'Croissant'),
createSocket('socket-2', 'user-two', 'croissant'),
]);
await handler(message, ['unmute', 'Croissant']);
assert.deepEqual(muteCalls, []);
assert.match(replies[0].content, /user-one/);
assert.match(replies[0].content, /user-two/);
assert.match(replies[0].content, /rs deter unmute <userId>/);
});