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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CEQiOJg5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DCUrAu3o.css">
<script type="module" crossorigin src="/assets/index-BWsjlMbl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-L6xBNcuq.css">
</head>
<body>
<div id="root"></div>
@@ -23,8 +23,34 @@ function registerAudioForwardHooks(deps) {
buildWhipUrl,
videoSessions,
startSilenceWriter,
isMuted,
verificationEvents,
} = deps;
verificationEvents.on('change', ({ socketId } = {}) => {
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);
if (!socket || !isMuted(socket)) return;
/*
Permission checks stop new muted audio, but an upload or microphone can
already be live when moderation changes. Stop only streams owned by this
socket so muting does not disturb another driver's audio or unrelated
server-generated sounds.
*/
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
if (ownerSocketId === socketId) {
stopWhipForRover(roverId, 'owner_muted');
}
}
workers.forEach((worker, roverId) => {
if (worker?.contentKind === 'upload' && worker.activeOwnerSocketId === socketId) {
logger.info('Stopping uploaded audio because its owner was muted', { roverId, socketId });
startSilenceWriter(roverId);
}
});
});
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return;
if (action === 'removed') {
@@ -8,7 +8,7 @@ const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
const { isVerified } = require('../verificationService');
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
const videoSessions = require('../videoSessions');
const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine');
@@ -62,6 +62,7 @@ function getAudioForwardState() {
const audioForwardPolicy = createAudioForwardPolicy({
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
@@ -141,6 +142,8 @@ registerAudioForwardHooks({
buildWhipUrl,
videoSessions,
startSilenceWriter,
isMuted,
verificationEvents,
});
registerChargeCompleteSound({
@@ -4,6 +4,7 @@
function createAudioForwardPolicy(deps) {
const {
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
@@ -18,6 +19,9 @@ function createAudioForwardPolicy(deps) {
function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket);
if (isMuted(socket)) {
throw new Error('Muted');
}
if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover');
}
@@ -0,0 +1,27 @@
// Audio Forward Policy Tests
// Purpose: Verifies that mute blocks user-owned forwarding without changing ordinary driver authorization.
// Scope: Exercises the pure permission policy with small injected role, rover, and turn doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createAudioForwardPolicy } = require('./policy');
function createPolicy({ verified = true, muted = false, driver = true, canDrive = true } = {}) {
return createAudioForwardPolicy({
isVerified: () => verified,
isMuted: () => muted,
roverManager: { isDriver: () => driver },
turnService: { canDrive: () => canDrive },
streamSuffix: '-fwd',
mediaConfig: {},
});
}
test('rejects audio forwarding for a muted verified driver', () => {
const policy = createPolicy({ muted: true });
assert.throws(() => policy.ensureAudioForwardPermission({}, 'rover'), /Muted/);
});
test('preserves normal audio forwarding for an unmuted verified driver', () => {
const policy = createPolicy();
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
});
+28 -4
View File
@@ -3,6 +3,7 @@
// Scope: Owns message validation pipeline and typed outbound message construction.
const logger = require('../../globals/logger').child('chatService');
const { getRole } = require('../roleService');
const { isDeterred, isMuted } = require('../verificationService');
const { withinRateLimit } = require('./state');
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
@@ -17,6 +18,12 @@ function createHandlers({ sendSystemMessage }) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return cb({ error: 'Message required' });
/*
Mute is narrower than deterrence: the socket may continue driving and
using ordinary features, but its message must stop before broadcast,
command parsing, TTS, or any other chat-derived side effect occurs.
*/
if (isMuted(socket)) return cb({ error: 'Muted' });
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
// This service no longer enforces a character-count ceiling for chat text.
// The chat layer only rejects empty, rate-limited, or moderated content so
@@ -37,23 +44,40 @@ function createHandlers({ sendSystemMessage }) {
});
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
const deterred = isDeterred(socket);
/*
Deterred users retain text chat, but chat must not become an indirect
hardware-control path. Suppress the rover typing note and TTS while still
constructing and broadcasting the same visible message as everyone else.
*/
if (!deterred) {
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
}
if (isPrivateClosedRoverId(message.roverId)) {
// Private-closed chat does not broadcast the text, so TTS is the only
// delivery path. Use the same Google speech default as normal chat when
// the sender did not provide explicit TTS settings.
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
maybeSpeak(socket, message, forcedTts);
if (!deterred) {
maybeSpeak(socket, message, forcedTts);
}
cb({ success: true, privateOnly: true });
return;
}
broadcastMessage(message);
maybeSendAccessNotice(message, sendSystemMessage);
maybeSpeak(socket, message, ttsOptions);
if (!deterred) {
maybeSpeak(socket, message, ttsOptions);
}
const command = isTextCommand(clean);
/*
Command-shaped text from a deterred user remains ordinary visible chat.
Reporting command=false prevents the client from implying that the server
accepted an action, and the command router is never invoked.
*/
const command = !deterred && isTextCommand(clean);
// Chat delivery is complete once validation, broadcast, and local side
// effects above have succeeded. A command may wait on Home Assistant,
// hardware, replay preparation, or an external transport, so tying the
+20 -1
View File
@@ -3,6 +3,7 @@
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
const io = require('../../globals/io');
const { subscribe } = require('../eventBus');
const { isDeterred, isMuted } = require('../verificationService');
const { typingBySocket } = require('./state');
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
const { broadcastTyping } = require('./broadcast');
@@ -13,11 +14,29 @@ function registerChatSocketHooks({ history, handleIncoming }) {
socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => {
/*
A muted typing packet must not leak presence or produce rover notes.
Clearing any prior state also removes a typing indicator that began
immediately before an administrator applied the mute.
*/
if (isMuted(socket)) {
const wasTyping = typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id);
if (wasTyping && !isPrivateClosedRoverId(roverId)) {
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }));
}
return;
}
const isTyping = Boolean(payload?.isTyping);
const wasTyping = typingBySocket.get(socket.id);
if (isTyping) {
typingBySocket.set(socket.id, true);
if (!wasTyping) {
/*
The typing indicator is part of chat and remains available to a
deterred user. The rover note is a physical side effect, however, so
text-only deterrence suppresses that note without changing presence.
*/
if (!wasTyping && !isDeterred(socket)) {
const roverId = resolveRoverId(socket?.id);
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
}
@@ -17,8 +17,11 @@ const {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
} = require('../verificationService');
const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService');
@@ -176,8 +179,11 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
sanitizeMentions,
sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
+9 -1
View File
@@ -6,7 +6,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io');
const roverManager = require('../roverManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const { isDeterred } = require('../verificationService');
const { isDeterred, isMuted } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService');
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
const homeAssistantService = require('../homeAssistantService');
@@ -292,6 +292,14 @@ io.on('connection', (socket) => {
if (!isAdminSocket && isDeterred(socket)) {
throw new Error('Not authorized');
}
/*
Both structured song commands and raw Open Interface song payloads
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
and any future browser beeper without affecting unrelated driving.
*/
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
throw new Error('Muted');
}
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
// in the same explicit admin-only branch as reboot instead of relying on
// drive ownership checks, because having a turn should not grant system
@@ -41,8 +41,11 @@ const {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
} = require('../verificationService');
const {
attachDmMessage: attachPrivateAccessDmMessage,
@@ -242,8 +245,11 @@ const commandDependencies = {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
sanitizeMentions,
sendToChannel: channelIO.sendToChannel,
isAdminUser,
@@ -11,6 +11,7 @@ const {
removeUserSignal,
setVerified,
setDeterrence,
setMuted,
setFeatureState,
deleteFeatureState,
} = require('../identityService');
@@ -103,6 +104,14 @@ io.on('connection', (socket) => {
}).id),
}));
ackHandler(socket, 'identityAdmin:setMuted', ({ userId, enabled }) => ({
user: getUserForAdmin(setMuted(userId, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
const normalized = normalizeFeaturePayload(namespace, value);
setFeatureState(userId, normalized.namespace, normalized.value);
+66 -3
View File
@@ -17,7 +17,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 1;
const STORE_VERSION = 2;
const identityEvents = new EventEmitter();
let db = null;
@@ -166,7 +166,10 @@ function ensureSchema(conn) {
deterrence_enabled integer not null default 0,
deterrence_reason text,
deterrence_at integer,
deterrence_by text
deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text
);
create table if not exists verification_requests (
@@ -213,6 +216,24 @@ function ensureSchema(conn) {
pragma user_version = ${STORE_VERSION};
`);
/*
SQLite's `create table if not exists` leaves an existing table untouched.
Add the mute columns explicitly for installations created before store
version 2, while the column-name check keeps every later startup idempotent.
*/
const statusColumns = new Set(
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
);
if (!statusColumns.has('muted_enabled')) {
conn.exec('alter table user_status add column muted_enabled integer not null default 0');
}
if (!statusColumns.has('muted_at')) {
conn.exec('alter table user_status add column muted_at integer');
}
if (!statusColumns.has('muted_by')) {
conn.exec('alter table user_status add column muted_by text');
}
}
function createUser(conn = getDb(), ts = nowMs()) {
@@ -279,6 +300,20 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
where user_id = ?
`).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId);
}
if (sourceStatus?.muted_enabled) {
/*
Identity merging must preserve the stricter moderation state. Otherwise
joining two signals could silently clear a mute merely because the
unmuted record happened to become the merge target.
*/
conn.prepare(`
update user_status
set muted_enabled = 1,
muted_at = coalesce(muted_at, ?),
muted_by = coalesce(muted_by, ?)
where user_id = ?
`).run(sourceStatus.muted_at || ts, sourceStatus.muted_by || null, targetUserId);
}
const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId);
sourceFeatures.forEach((feature) => {
@@ -382,6 +417,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null;
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
socket.data.isMuted = Boolean(user.deterrence?.muted);
}
function identifySocket(socket, payload = {}) {
@@ -422,6 +458,7 @@ function identifySocket(socket, payload = {}) {
fingerprintId: fingerprintId || null,
isVerified: Boolean(user.verified?.enabled),
isDeterred: Boolean(user.deterrence?.enabled),
isMuted: Boolean(user.deterrence?.muted),
};
}
@@ -468,6 +505,9 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
reason: status.deterrence_reason || null,
at: status.deterrence_at || null,
by: status.deterrence_by || null,
muted: Boolean(status.muted_enabled),
mutedAt: status.muted_at || null,
mutedBy: status.muted_by || null,
},
features,
};
@@ -673,6 +713,19 @@ function setDeterrence(userId, { enabled = true, reason = null, actor = null, at
return getUserById(id);
}
function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
const id = String(userId || '').trim();
if (!id) throw new Error('userId required');
ensureUserStatus(getDb(), id);
getDb().prepare(`
update user_status
set muted_enabled = ?, muted_at = ?, muted_by = ?
where user_id = ?
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
identityEvents.emit('change', { reason: enabled ? 'muted' : 'unmuted', userId: id });
return getUserById(id);
}
function isVerified(socket) {
return Boolean(socket?.data?.isVerified);
}
@@ -681,12 +734,13 @@ function isDeterred(socket) {
return Boolean(socket?.data?.isDeterred);
}
function listUsers({ verified = null, deterred = null } = {}) {
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
const conn = getDb();
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
const where = [];
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
if (where.length) sql += ` where ${where.join(' and ')}`;
sql += ' order by users.updated_at desc';
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
@@ -705,6 +759,9 @@ function userToLegacyIdentityEntry(user) {
updatedAt: user.updatedAt,
approvedBy: user.verified?.by || null,
reason: user.deterrence?.reason || null,
muted: Boolean(user.deterrence?.muted),
mutedAt: user.deterrence?.mutedAt || null,
mutedBy: user.deterrence?.mutedBy || null,
};
}
@@ -716,6 +773,10 @@ function listDeterredUsers() {
return listUsers({ deterred: true }).map(userToLegacyIdentityEntry);
}
function listMutedUsers() {
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
}
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
const value = String(selector || '').trim();
if (!value) return { error: 'selector_required' };
@@ -969,10 +1030,12 @@ module.exports = {
listFeatureStates,
setVerified,
setDeterrence,
setMuted,
isVerified,
isDeterred,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
createJsonStore,
@@ -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>/);
});
@@ -33,7 +33,19 @@ function buildCommandRegistry(prefix, timeCommand) {
},
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
deter: {
category: 'admin',
summary: 'Manage identity deterrence and mute status.',
usage: [
`${prefix} deter list`,
`${prefix} deter ban <identity>`,
`${prefix} deter unban <identity>`,
`${prefix} deter mute <identity>`,
`${prefix} deter unmute <identity>`,
],
access: 'Lockdown admin',
permission: 'lockdown-admin',
},
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
@@ -95,7 +95,11 @@ async function hostReplay({ buffer, job }) {
title: job.title,
requester: job.requester,
requestedBy: job.requestedBy || null,
url: `/media/replays/${filename}`,
// The readable filename intentionally contains spaces, but the media URL is
// also pasted into Discord messages where an unescaped space terminates the
// detected link. Encode only the path segment; Express decodes the route
// parameter back to the exact on-disk filename before the file is served.
url: `/media/replays/${encodeURIComponent(filename)}`,
proxyUrl: null,
messageUrl: null,
filename,
@@ -26,8 +26,10 @@ const {
sanitizeNickname,
setVerified,
setDeterrence,
setMuted,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
} = require('../identityService');
@@ -37,6 +39,26 @@ const verificationEvents = new EventEmitter();
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
const IDENTITY_SWEEP_INTERVAL_MS = 15 * 1000;
const DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS = 250;
const DETERRED_DISCONNECT_DELAY_MS = 250;
/*
Deterrence is intentionally enforced at the socket boundary instead of being
repeated in every feature service. A deterred browser still needs the small
identity/authentication surface that lets it reconnect, retain a chat name,
and let a real administrator log in. Everything else is limited to ordinary
text chat and its non-mutating typing indicator.
Keeping this list exact is important: newly added socket capabilities are
denied by default, so a future feature cannot accidentally become an escape
hatch merely because its service forgot a deterrence check.
*/
const DETERRED_ALLOWED_SOCKET_EVENTS = new Set([
'auth:login',
'session:identify',
'nickname:set',
'chat:send',
'chat:typing',
]);
function emitChange(reason, payload = {}) {
verificationEvents.emit('change', { reason, ...payload });
@@ -46,6 +68,31 @@ function isAdminRole(role) {
return role === 'admin' || role === 'lockdown';
}
function installDeterredSocketGuard(socket) {
socket.use(([eventName, ...eventArgs], next) => {
if (!socket?.data?.isDeterred || DETERRED_ALLOWED_SOCKET_EVENTS.has(eventName)) {
next();
return;
}
/*
Socket.IO does not automatically acknowledge a packet that middleware
declines. Reply through the packet's acknowledgement callback when one
exists so browser promises settle normally instead of hanging forever.
We deliberately do not call next() after the reply because doing so would
deliver the denied packet to its feature handler.
*/
const acknowledgement = eventArgs[eventArgs.length - 1];
if (typeof acknowledgement === 'function') {
acknowledgement({ error: 'Not authorized' });
}
logger.info('Blocked socket event from deterred user', {
socketId: socket.id,
eventName,
});
});
}
function refreshSocketIdentityFlags(socket) {
const user = getUserForSocket(socket);
if (!socket?.data || !user) {
@@ -55,13 +102,21 @@ function refreshSocketIdentityFlags(socket) {
const role = getRole(socket);
const verifiedByRole = isAdminRole(role);
const deterredByUser = Boolean(user.deterrence?.enabled);
const mutedByUser = Boolean(user.deterrence?.muted);
socket.data.isVerified = verifiedByRole || Boolean(user.verified?.enabled);
socket.data.verifiedRecordId = socket.data.isVerified ? user.id : null;
socket.data.isDeterred = isAdminRole(role) ? false : deterredByUser;
socket.data.deterredRecordId = socket.data.isDeterred ? user.id : null;
/*
Mute follows the same administrator exemption as full deterrence. This
prevents a stored moderation flag from disabling an authenticated admin's
operational chat/audio tools while preserving the flag for normal roles.
*/
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
return {
isVerified: socket.data.isVerified,
isDeterred: socket.data.isDeterred,
isMuted: socket.data.isMuted,
matchedRecordId: user.id,
reason: socket.data.isVerified ? 'matched' : 'no_match',
userId: user.id,
@@ -212,6 +267,7 @@ function getVerificationStateForSocket(socket) {
function getModerationStateForSocket(socket) {
return {
isDeterred: Boolean(socket?.data?.isDeterred),
isMuted: Boolean(socket?.data?.isMuted),
recordId: socket?.data?.deterredRecordId || null,
};
}
@@ -482,6 +538,41 @@ function undeterUser(selector, removedBy = null) {
return userToLegacyIdentityEntry(user);
}
function setUserMute(selector, enabled, actor = null) {
const resolved = resolveUserBySelector(selector, { includeVerified: true, includeDeterred: true });
if (resolved.error || !resolved.user) {
throw new Error(resolved.error === 'ambiguous_nickname' ? 'Nickname matches multiple users.' : 'User not found.');
}
const user = setMuted(resolved.user.id, {
enabled,
actor: actor ? String(actor) : null,
at: Date.now(),
});
refreshSocketsForUser(user.id);
publishEvent({
source: 'moderation',
type: enabled ? 'moderation.muted' : 'moderation.unmuted',
payload: {
userId: user.id,
cookieUserId: user.cookieUserIds[0] || null,
nickname: user.nickname,
actor: actor ? String(actor) : null,
ts: Date.now(),
},
});
emitChange(enabled ? 'mute_update' : 'mute_remove', { userId: user.id });
return userToLegacyIdentityEntry(user);
}
function muteUser(selector, actor = null) {
return setUserMute(selector, true, actor);
}
function unmuteUser(selector, actor = null) {
return setUserMute(selector, false, actor);
}
function reevaluateSocketVerification(socket) {
return refreshSocketIdentityFlags(socket);
}
@@ -500,6 +591,7 @@ function getVerificationStatus(socket) {
io.on('connection', (socket) => {
socket.data = socket.data || {};
socket.data.connectedAt = Date.now();
installDeterredSocketGuard(socket);
identifySocket(socket, {});
socket.on('session:identify', (payload = {}, cb = () => {}) => {
@@ -535,6 +627,32 @@ roleEvents.on('change', ({ socket }) => {
identityEvents.on('change', ({ userId, reason } = {}) => {
if (!userId) return;
refreshSocketsForUser(userId);
if (reason === 'deterred') {
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) !== userId || !socket.data?.isDeterred) return;
/*
A user can be deterred while already driving or consuming media. One
normal disconnect lets the established rover, PTZ, video, and snapshot
services perform their own cleanup without coupling moderation to each
subsystem. The browser may reconnect immediately; identification then
restores the deterred flag and the guard above leaves chat available.
*/
setTimeout(() => {
if (!socket.disconnected && socket.data?.isDeterred) {
/*
Close the underlying transport rather than issuing Socket.IO's
explicit server-disconnect packet. A server-disconnect disables
automatic reconnection in the browser, while a transport close
runs the same disconnect cleanup and then lets the normal client
reconnect path restore its chat-only session.
*/
socket.conn.close();
}
}, DETERRED_DISCONNECT_DELAY_MS);
});
}
emitChange('identity_change', { userId, reason });
});
@@ -573,10 +691,14 @@ module.exports = {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
isVerified: (socket) => Boolean(socket?.data?.isVerified),
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
isMuted: (socket) => Boolean(socket?.data?.isMuted),
reevaluateSocketVerification,
reevaluateSocketDeterrence,
verificationEvents,
+24 -2
View File
@@ -12,6 +12,7 @@ import {
listUsers,
removeSignal,
setDeterrence,
setMuted,
setVerified,
updateFeatureState,
} from './identityDatabaseApi.js';
@@ -30,6 +31,7 @@ const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'verified', label: 'Verified' },
{ key: 'deterred', label: 'Deterred' },
{ key: 'muted', label: 'Muted' },
{ key: 'unverified', label: 'Unverified' },
];
@@ -85,6 +87,7 @@ function UserListCard({ users, selectedUserId, query, filter, loading, onQuery,
<span className="flex flex-col items-end gap-0.25">
<StatusPill active={user.verified?.enabled}>verified</StatusPill>
<StatusPill active={user.deterrence?.enabled}>deterred</StatusPill>
<StatusPill active={user.deterrence?.muted}>muted</StatusPill>
</span>
</button>
)) : (
@@ -164,7 +167,7 @@ function SignalsCard({ user, onAddSignal, onRemoveSignal }) {
);
}
function StatusCard({ user, onVerified, onDeterrence }) {
function StatusCard({ user, onVerified, onDeterrence, onMuted }) {
const [reason, setReason] = useState(user?.deterrence?.reason || '');
useEffect(() => {
@@ -206,6 +209,23 @@ function StatusCard({ user, onVerified, onDeterrence }) {
<button type="button" className="button-dark text-xs" onClick={() => onDeterrence(Boolean(user?.deterrence?.enabled), reason)}>
Save Reason
</button>
<div className="border-t border-slate-700/70 pt-0.5">
<label className="flex items-center gap-0.5 text-slate-100">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-amber-500"
checked={Boolean(user?.deterrence?.muted)}
onChange={(event) => onMuted(event.target.checked)}
/>
<span>Muted</span>
</label>
{/*
Mute is intentionally presented inside deterrence status because
it is a narrower moderation action, while its own timestamp makes
it clear that toggling mute does not toggle full deterrence.
*/}
<p className="text-xs text-slate-500">Updated {formatDateTime(user?.deterrence?.mutedAt)}</p>
</div>
</div>
</CardFrame>
);
@@ -358,6 +378,8 @@ export default function IdentityDatabasePanel() {
runMutation((userId) => setVerified(socket, userId, enabled), 'Verification updated.');
const handleDeterrence = (enabled, reason) =>
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
const handleMuted = (enabled) =>
runMutation((userId) => setMuted(socket, userId, enabled), 'Mute updated.');
const handleSaveFeature = (namespace, value) =>
runMutation((userId) => updateFeatureState(socket, userId, namespace, value), 'Feature state saved.');
const handleDeleteFeature = (namespace) =>
@@ -393,7 +415,7 @@ export default function IdentityDatabasePanel() {
<SignalsCard user={selectedUser} onAddSignal={handleAddSignal} onRemoveSignal={handleRemoveSignal} />
</TabPanel>
<TabPanel id="status">
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} />
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
</TabPanel>
<TabPanel id="features">
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
@@ -37,6 +37,10 @@ export function setDeterrence(socket, userId, enabled, reason) {
return emitIdentityAdmin(socket, 'identityAdmin:setDeterrence', { userId, enabled, reason });
}
export function setMuted(socket, userId, enabled) {
return emitIdentityAdmin(socket, 'identityAdmin:setMuted', { userId, enabled });
}
export function updateFeatureState(socket, userId, namespace, value) {
return emitIdentityAdmin(socket, 'identityAdmin:updateFeatureState', { userId, namespace, value });
}
@@ -55,6 +55,7 @@ export function userMatchesQuery(user, query) {
export function userMatchesFilter(user, filter) {
if (filter === 'verified') return Boolean(user?.verified?.enabled);
if (filter === 'deterred') return Boolean(user?.deterrence?.enabled);
if (filter === 'muted') return Boolean(user?.deterrence?.muted);
if (filter === 'unverified') return !user?.verified?.enabled;
return true;
}