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/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>
|
<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>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CEQiOJg5.js"></script>
|
<script type="module" crossorigin src="/assets/index-BWsjlMbl.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DCUrAu3o.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-L6xBNcuq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -23,8 +23,34 @@ function registerAudioForwardHooks(deps) {
|
|||||||
buildWhipUrl,
|
buildWhipUrl,
|
||||||
videoSessions,
|
videoSessions,
|
||||||
startSilenceWriter,
|
startSilenceWriter,
|
||||||
|
isMuted,
|
||||||
|
verificationEvents,
|
||||||
} = deps;
|
} = 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 } = {}) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
if (action === 'removed') {
|
if (action === 'removed') {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const logger = require('../../globals/logger').child('audioForwardService');
|
|||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const turnService = require('../turnService');
|
const turnService = require('../turnService');
|
||||||
const { isVerified } = require('../verificationService');
|
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
|
||||||
const videoSessions = require('../videoSessions');
|
const videoSessions = require('../videoSessions');
|
||||||
const { createAudioForwardPolicy } = require('./policy');
|
const { createAudioForwardPolicy } = require('./policy');
|
||||||
const { createAudioForwardWorkerEngine } = require('./workerEngine');
|
const { createAudioForwardWorkerEngine } = require('./workerEngine');
|
||||||
@@ -62,6 +62,7 @@ function getAudioForwardState() {
|
|||||||
|
|
||||||
const audioForwardPolicy = createAudioForwardPolicy({
|
const audioForwardPolicy = createAudioForwardPolicy({
|
||||||
isVerified,
|
isVerified,
|
||||||
|
isMuted,
|
||||||
roverManager,
|
roverManager,
|
||||||
turnService,
|
turnService,
|
||||||
streamSuffix,
|
streamSuffix,
|
||||||
@@ -141,6 +142,8 @@ registerAudioForwardHooks({
|
|||||||
buildWhipUrl,
|
buildWhipUrl,
|
||||||
videoSessions,
|
videoSessions,
|
||||||
startSilenceWriter,
|
startSilenceWriter,
|
||||||
|
isMuted,
|
||||||
|
verificationEvents,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerChargeCompleteSound({
|
registerChargeCompleteSound({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
function createAudioForwardPolicy(deps) {
|
function createAudioForwardPolicy(deps) {
|
||||||
const {
|
const {
|
||||||
isVerified,
|
isVerified,
|
||||||
|
isMuted,
|
||||||
roverManager,
|
roverManager,
|
||||||
turnService,
|
turnService,
|
||||||
streamSuffix,
|
streamSuffix,
|
||||||
@@ -18,6 +19,9 @@ function createAudioForwardPolicy(deps) {
|
|||||||
|
|
||||||
function ensureAudioForwardPermission(socket, roverId) {
|
function ensureAudioForwardPermission(socket, roverId) {
|
||||||
ensureVipVerified(socket);
|
ensureVipVerified(socket);
|
||||||
|
if (isMuted(socket)) {
|
||||||
|
throw new Error('Muted');
|
||||||
|
}
|
||||||
if (!roverManager.isDriver(roverId, socket)) {
|
if (!roverManager.isDriver(roverId, socket)) {
|
||||||
throw new Error('Audio forwarding is only allowed on your own rover');
|
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.
|
// Scope: Owns message validation pipeline and typed outbound message construction.
|
||||||
const logger = require('../../globals/logger').child('chatService');
|
const logger = require('../../globals/logger').child('chatService');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
|
const { isDeterred, isMuted } = require('../verificationService');
|
||||||
const { withinRateLimit } = require('./state');
|
const { withinRateLimit } = require('./state');
|
||||||
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
|
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
|
||||||
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
||||||
@@ -17,6 +18,12 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
const normalized = normalizeUserText(text);
|
const normalized = normalizeUserText(text);
|
||||||
const clean = normalized.trim();
|
const clean = normalized.trim();
|
||||||
if (!clean) return cb({ error: 'Message required' });
|
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' });
|
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
|
||||||
// This service no longer enforces a character-count ceiling for chat text.
|
// This service no longer enforces a character-count ceiling for chat text.
|
||||||
// The chat layer only rejects empty, rate-limited, or moderated content so
|
// 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 });
|
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||||
|
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);
|
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (isPrivateClosedRoverId(message.roverId)) {
|
if (isPrivateClosedRoverId(message.roverId)) {
|
||||||
// Private-closed chat does not broadcast the text, so TTS is the only
|
// 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
|
// delivery path. Use the same Google speech default as normal chat when
|
||||||
// the sender did not provide explicit TTS settings.
|
// the sender did not provide explicit TTS settings.
|
||||||
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
|
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
|
||||||
|
if (!deterred) {
|
||||||
maybeSpeak(socket, message, forcedTts);
|
maybeSpeak(socket, message, forcedTts);
|
||||||
|
}
|
||||||
cb({ success: true, privateOnly: true });
|
cb({ success: true, privateOnly: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
maybeSendAccessNotice(message, sendSystemMessage);
|
maybeSendAccessNotice(message, sendSystemMessage);
|
||||||
|
if (!deterred) {
|
||||||
maybeSpeak(socket, message, ttsOptions);
|
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
|
// Chat delivery is complete once validation, broadcast, and local side
|
||||||
// effects above have succeeded. A command may wait on Home Assistant,
|
// effects above have succeeded. A command may wait on Home Assistant,
|
||||||
// hardware, replay preparation, or an external transport, so tying the
|
// 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.
|
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const { subscribe } = require('../eventBus');
|
const { subscribe } = require('../eventBus');
|
||||||
|
const { isDeterred, isMuted } = require('../verificationService');
|
||||||
const { typingBySocket } = require('./state');
|
const { typingBySocket } = require('./state');
|
||||||
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
|
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
|
||||||
const { broadcastTyping } = require('./broadcast');
|
const { broadcastTyping } = require('./broadcast');
|
||||||
@@ -13,11 +14,29 @@ function registerChatSocketHooks({ history, handleIncoming }) {
|
|||||||
socket.emit('chat:init', history);
|
socket.emit('chat:init', history);
|
||||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||||
socket.on('chat:typing', (payload = {}) => {
|
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 isTyping = Boolean(payload?.isTyping);
|
||||||
const wasTyping = typingBySocket.get(socket.id);
|
const wasTyping = typingBySocket.get(socket.id);
|
||||||
if (isTyping) {
|
if (isTyping) {
|
||||||
typingBySocket.set(socket.id, true);
|
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);
|
const roverId = resolveRoverId(socket?.id);
|
||||||
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ const {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
@@ -176,8 +179,11 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: null,
|
sendToChannel: null,
|
||||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const EventEmitter = require('events');
|
|||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
const { isDeterred } = require('../verificationService');
|
const { isDeterred, isMuted } = require('../verificationService');
|
||||||
const logger = require('../../globals/logger').child('commandService');
|
const logger = require('../../globals/logger').child('commandService');
|
||||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
@@ -292,6 +292,14 @@ io.on('connection', (socket) => {
|
|||||||
if (!isAdminSocket && isDeterred(socket)) {
|
if (!isAdminSocket && isDeterred(socket)) {
|
||||||
throw new Error('Not authorized');
|
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
|
// 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
|
// in the same explicit admin-only branch as reboot instead of relying on
|
||||||
// drive ownership checks, because having a turn should not grant system
|
// drive ownership checks, because having a turn should not grant system
|
||||||
|
|||||||
@@ -41,8 +41,11 @@ const {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
const {
|
const {
|
||||||
attachDmMessage: attachPrivateAccessDmMessage,
|
attachDmMessage: attachPrivateAccessDmMessage,
|
||||||
@@ -242,8 +245,11 @@ const commandDependencies = {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: channelIO.sendToChannel,
|
sendToChannel: channelIO.sendToChannel,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
removeUserSignal,
|
removeUserSignal,
|
||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
|
setMuted,
|
||||||
setFeatureState,
|
setFeatureState,
|
||||||
deleteFeatureState,
|
deleteFeatureState,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
@@ -103,6 +104,14 @@ io.on('connection', (socket) => {
|
|||||||
}).id),
|
}).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 }) => {
|
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
|
||||||
const normalized = normalizeFeaturePayload(namespace, value);
|
const normalized = normalizeFeaturePayload(namespace, value);
|
||||||
setFeatureState(userId, normalized.namespace, normalized.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 DB_PATH = resolveDataPath('identity.sqlite');
|
||||||
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||||
const STORE_VERSION = 1;
|
const STORE_VERSION = 2;
|
||||||
const identityEvents = new EventEmitter();
|
const identityEvents = new EventEmitter();
|
||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
@@ -166,7 +166,10 @@ function ensureSchema(conn) {
|
|||||||
deterrence_enabled integer not null default 0,
|
deterrence_enabled integer not null default 0,
|
||||||
deterrence_reason text,
|
deterrence_reason text,
|
||||||
deterrence_at integer,
|
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 (
|
create table if not exists verification_requests (
|
||||||
@@ -213,6 +216,24 @@ function ensureSchema(conn) {
|
|||||||
|
|
||||||
pragma user_version = ${STORE_VERSION};
|
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()) {
|
function createUser(conn = getDb(), ts = nowMs()) {
|
||||||
@@ -279,6 +300,20 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
|
|||||||
where user_id = ?
|
where user_id = ?
|
||||||
`).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId);
|
`).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);
|
const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId);
|
||||||
sourceFeatures.forEach((feature) => {
|
sourceFeatures.forEach((feature) => {
|
||||||
@@ -382,6 +417,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
|
|||||||
socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null;
|
socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null;
|
||||||
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
||||||
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
||||||
|
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
function identifySocket(socket, payload = {}) {
|
function identifySocket(socket, payload = {}) {
|
||||||
@@ -422,6 +458,7 @@ function identifySocket(socket, payload = {}) {
|
|||||||
fingerprintId: fingerprintId || null,
|
fingerprintId: fingerprintId || null,
|
||||||
isVerified: Boolean(user.verified?.enabled),
|
isVerified: Boolean(user.verified?.enabled),
|
||||||
isDeterred: Boolean(user.deterrence?.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,
|
reason: status.deterrence_reason || null,
|
||||||
at: status.deterrence_at || null,
|
at: status.deterrence_at || null,
|
||||||
by: status.deterrence_by || null,
|
by: status.deterrence_by || null,
|
||||||
|
muted: Boolean(status.muted_enabled),
|
||||||
|
mutedAt: status.muted_at || null,
|
||||||
|
mutedBy: status.muted_by || null,
|
||||||
},
|
},
|
||||||
features,
|
features,
|
||||||
};
|
};
|
||||||
@@ -673,6 +713,19 @@ function setDeterrence(userId, { enabled = true, reason = null, actor = null, at
|
|||||||
return getUserById(id);
|
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) {
|
function isVerified(socket) {
|
||||||
return Boolean(socket?.data?.isVerified);
|
return Boolean(socket?.data?.isVerified);
|
||||||
}
|
}
|
||||||
@@ -681,12 +734,13 @@ function isDeterred(socket) {
|
|||||||
return Boolean(socket?.data?.isDeterred);
|
return Boolean(socket?.data?.isDeterred);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listUsers({ verified = null, deterred = null } = {}) {
|
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
|
||||||
const conn = getDb();
|
const conn = getDb();
|
||||||
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
||||||
const where = [];
|
const where = [];
|
||||||
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
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 (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 ')}`;
|
if (where.length) sql += ` where ${where.join(' and ')}`;
|
||||||
sql += ' order by users.updated_at desc';
|
sql += ' order by users.updated_at desc';
|
||||||
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
||||||
@@ -705,6 +759,9 @@ function userToLegacyIdentityEntry(user) {
|
|||||||
updatedAt: user.updatedAt,
|
updatedAt: user.updatedAt,
|
||||||
approvedBy: user.verified?.by || null,
|
approvedBy: user.verified?.by || null,
|
||||||
reason: user.deterrence?.reason || 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);
|
return listUsers({ deterred: true }).map(userToLegacyIdentityEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listMutedUsers() {
|
||||||
|
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
||||||
const value = String(selector || '').trim();
|
const value = String(selector || '').trim();
|
||||||
if (!value) return { error: 'selector_required' };
|
if (!value) return { error: 'selector_required' };
|
||||||
@@ -969,10 +1030,12 @@ module.exports = {
|
|||||||
listFeatureStates,
|
listFeatureStates,
|
||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
|
setMuted,
|
||||||
isVerified,
|
isVerified,
|
||||||
isDeterred,
|
isDeterred,
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
createJsonStore,
|
createJsonStore,
|
||||||
|
|||||||
@@ -1,10 +1,45 @@
|
|||||||
// Operator Deter Command
|
// Operator Deter Command
|
||||||
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
||||||
// Scope: Supports list, ban, and unban subcommands.
|
// Scope: Supports list, ban, and unban subcommands.
|
||||||
const { mask, resolveIdentitySelector } = require('./resolvers');
|
const { mask, normalizeSearchText, resolveIdentitySelector } = require('./resolvers');
|
||||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
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.
|
// Moderation usage errors use the same core prefix shown by organized help.
|
||||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
|
|
||||||
@@ -15,23 +50,58 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
|||||||
}
|
}
|
||||||
const action = (tokens.shift() || 'list').toLowerCase();
|
const action = (tokens.shift() || 'list').toLowerCase();
|
||||||
if (action === 'list') {
|
if (action === 'list') {
|
||||||
const users = listDeterredUsers();
|
const deterredUsers = listDeterredUsers().map((entry) => ({ ...entry, deterred: true }));
|
||||||
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } });
|
const mutedUsers = listMutedUsers().map((entry) => ({ ...entry, muted: true }));
|
||||||
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
|
const usersById = new Map();
|
||||||
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
[...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') {
|
if (action === 'ban') {
|
||||||
const selector = tokens.join(' ').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
try {
|
try {
|
||||||
|
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 });
|
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||||
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
||||||
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
|
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
|
// Ban reasons were deliberately removed from the command grammar. The
|
||||||
// full remaining text is now always the selector, which lets lockdown
|
// full remaining text is now always the selector, which lets lockdown
|
||||||
// admins deter multi-word nicknames without quoting or delimiter rules.
|
// 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 });
|
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 } });
|
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} 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: 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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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,
|
title: job.title,
|
||||||
requester: job.requester,
|
requester: job.requester,
|
||||||
requestedBy: job.requestedBy || null,
|
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,
|
proxyUrl: null,
|
||||||
messageUrl: null,
|
messageUrl: null,
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ const {
|
|||||||
sanitizeNickname,
|
sanitizeNickname,
|
||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
|
setMuted,
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
@@ -37,6 +39,26 @@ const verificationEvents = new EventEmitter();
|
|||||||
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
||||||
const IDENTITY_SWEEP_INTERVAL_MS = 15 * 1000;
|
const IDENTITY_SWEEP_INTERVAL_MS = 15 * 1000;
|
||||||
const DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS = 250;
|
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 = {}) {
|
function emitChange(reason, payload = {}) {
|
||||||
verificationEvents.emit('change', { reason, ...payload });
|
verificationEvents.emit('change', { reason, ...payload });
|
||||||
@@ -46,6 +68,31 @@ function isAdminRole(role) {
|
|||||||
return role === 'admin' || role === 'lockdown';
|
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) {
|
function refreshSocketIdentityFlags(socket) {
|
||||||
const user = getUserForSocket(socket);
|
const user = getUserForSocket(socket);
|
||||||
if (!socket?.data || !user) {
|
if (!socket?.data || !user) {
|
||||||
@@ -55,13 +102,21 @@ function refreshSocketIdentityFlags(socket) {
|
|||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
const verifiedByRole = isAdminRole(role);
|
const verifiedByRole = isAdminRole(role);
|
||||||
const deterredByUser = Boolean(user.deterrence?.enabled);
|
const deterredByUser = Boolean(user.deterrence?.enabled);
|
||||||
|
const mutedByUser = Boolean(user.deterrence?.muted);
|
||||||
socket.data.isVerified = verifiedByRole || Boolean(user.verified?.enabled);
|
socket.data.isVerified = verifiedByRole || Boolean(user.verified?.enabled);
|
||||||
socket.data.verifiedRecordId = socket.data.isVerified ? user.id : null;
|
socket.data.verifiedRecordId = socket.data.isVerified ? user.id : null;
|
||||||
socket.data.isDeterred = isAdminRole(role) ? false : deterredByUser;
|
socket.data.isDeterred = isAdminRole(role) ? false : deterredByUser;
|
||||||
socket.data.deterredRecordId = socket.data.isDeterred ? user.id : null;
|
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 {
|
return {
|
||||||
isVerified: socket.data.isVerified,
|
isVerified: socket.data.isVerified,
|
||||||
isDeterred: socket.data.isDeterred,
|
isDeterred: socket.data.isDeterred,
|
||||||
|
isMuted: socket.data.isMuted,
|
||||||
matchedRecordId: user.id,
|
matchedRecordId: user.id,
|
||||||
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -212,6 +267,7 @@ function getVerificationStateForSocket(socket) {
|
|||||||
function getModerationStateForSocket(socket) {
|
function getModerationStateForSocket(socket) {
|
||||||
return {
|
return {
|
||||||
isDeterred: Boolean(socket?.data?.isDeterred),
|
isDeterred: Boolean(socket?.data?.isDeterred),
|
||||||
|
isMuted: Boolean(socket?.data?.isMuted),
|
||||||
recordId: socket?.data?.deterredRecordId || null,
|
recordId: socket?.data?.deterredRecordId || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -482,6 +538,41 @@ function undeterUser(selector, removedBy = null) {
|
|||||||
return userToLegacyIdentityEntry(user);
|
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) {
|
function reevaluateSocketVerification(socket) {
|
||||||
return refreshSocketIdentityFlags(socket);
|
return refreshSocketIdentityFlags(socket);
|
||||||
}
|
}
|
||||||
@@ -500,6 +591,7 @@ function getVerificationStatus(socket) {
|
|||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.data = socket.data || {};
|
socket.data = socket.data || {};
|
||||||
socket.data.connectedAt = Date.now();
|
socket.data.connectedAt = Date.now();
|
||||||
|
installDeterredSocketGuard(socket);
|
||||||
identifySocket(socket, {});
|
identifySocket(socket, {});
|
||||||
|
|
||||||
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
||||||
@@ -535,6 +627,32 @@ roleEvents.on('change', ({ socket }) => {
|
|||||||
identityEvents.on('change', ({ userId, reason } = {}) => {
|
identityEvents.on('change', ({ userId, reason } = {}) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
refreshSocketsForUser(userId);
|
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 });
|
emitChange('identity_change', { userId, reason });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -573,10 +691,14 @@ module.exports = {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
||||||
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
||||||
|
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
||||||
reevaluateSocketVerification,
|
reevaluateSocketVerification,
|
||||||
reevaluateSocketDeterrence,
|
reevaluateSocketDeterrence,
|
||||||
verificationEvents,
|
verificationEvents,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
listUsers,
|
listUsers,
|
||||||
removeSignal,
|
removeSignal,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
|
setMuted,
|
||||||
setVerified,
|
setVerified,
|
||||||
updateFeatureState,
|
updateFeatureState,
|
||||||
} from './identityDatabaseApi.js';
|
} from './identityDatabaseApi.js';
|
||||||
@@ -30,6 +31,7 @@ const FILTERS = [
|
|||||||
{ key: 'all', label: 'All' },
|
{ key: 'all', label: 'All' },
|
||||||
{ key: 'verified', label: 'Verified' },
|
{ key: 'verified', label: 'Verified' },
|
||||||
{ key: 'deterred', label: 'Deterred' },
|
{ key: 'deterred', label: 'Deterred' },
|
||||||
|
{ key: 'muted', label: 'Muted' },
|
||||||
{ key: 'unverified', label: 'Unverified' },
|
{ 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">
|
<span className="flex flex-col items-end gap-0.25">
|
||||||
<StatusPill active={user.verified?.enabled}>verified</StatusPill>
|
<StatusPill active={user.verified?.enabled}>verified</StatusPill>
|
||||||
<StatusPill active={user.deterrence?.enabled}>deterred</StatusPill>
|
<StatusPill active={user.deterrence?.enabled}>deterred</StatusPill>
|
||||||
|
<StatusPill active={user.deterrence?.muted}>muted</StatusPill>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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 || '');
|
const [reason, setReason] = useState(user?.deterrence?.reason || '');
|
||||||
|
|
||||||
useEffect(() => {
|
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)}>
|
<button type="button" className="button-dark text-xs" onClick={() => onDeterrence(Boolean(user?.deterrence?.enabled), reason)}>
|
||||||
Save Reason
|
Save Reason
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
@@ -358,6 +378,8 @@ export default function IdentityDatabasePanel() {
|
|||||||
runMutation((userId) => setVerified(socket, userId, enabled), 'Verification updated.');
|
runMutation((userId) => setVerified(socket, userId, enabled), 'Verification updated.');
|
||||||
const handleDeterrence = (enabled, reason) =>
|
const handleDeterrence = (enabled, reason) =>
|
||||||
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
|
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
|
||||||
|
const handleMuted = (enabled) =>
|
||||||
|
runMutation((userId) => setMuted(socket, userId, enabled), 'Mute updated.');
|
||||||
const handleSaveFeature = (namespace, value) =>
|
const handleSaveFeature = (namespace, value) =>
|
||||||
runMutation((userId) => updateFeatureState(socket, userId, namespace, value), 'Feature state saved.');
|
runMutation((userId) => updateFeatureState(socket, userId, namespace, value), 'Feature state saved.');
|
||||||
const handleDeleteFeature = (namespace) =>
|
const handleDeleteFeature = (namespace) =>
|
||||||
@@ -393,7 +415,7 @@ export default function IdentityDatabasePanel() {
|
|||||||
<SignalsCard user={selectedUser} onAddSignal={handleAddSignal} onRemoveSignal={handleRemoveSignal} />
|
<SignalsCard user={selectedUser} onAddSignal={handleAddSignal} onRemoveSignal={handleRemoveSignal} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="status">
|
<TabPanel id="status">
|
||||||
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} />
|
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="features">
|
<TabPanel id="features">
|
||||||
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
|
<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 });
|
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) {
|
export function updateFeatureState(socket, userId, namespace, value) {
|
||||||
return emitIdentityAdmin(socket, 'identityAdmin:updateFeatureState', { 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) {
|
export function userMatchesFilter(user, filter) {
|
||||||
if (filter === 'verified') return Boolean(user?.verified?.enabled);
|
if (filter === 'verified') return Boolean(user?.verified?.enabled);
|
||||||
if (filter === 'deterred') return Boolean(user?.deterrence?.enabled);
|
if (filter === 'deterred') return Boolean(user?.deterrence?.enabled);
|
||||||
|
if (filter === 'muted') return Boolean(user?.deterrence?.muted);
|
||||||
if (filter === 'unverified') return !user?.verified?.enabled;
|
if (filter === 'unverified') return !user?.verified?.enabled;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user