mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
better banning, new deter mute, replay discord fixes.
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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'));
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user