mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
driver removal information finaly!
This commit is contained in:
@@ -13,6 +13,37 @@ const assignments = new Map(); // socketId -> roverId
|
||||
const waiting = new Set(); // socketIds waiting for placement
|
||||
const assignmentEvents = new EventEmitter();
|
||||
|
||||
function normalizeRemovalMessage(message, fallback) {
|
||||
/*
|
||||
Removal notices are shown directly in the driving UI, so the server trims
|
||||
caller-provided text before emitting it. Keeping this normalization close to
|
||||
the release helper makes every forced-removal path use the same readable
|
||||
fallback instead of forcing each caller to duplicate defensive string checks.
|
||||
*/
|
||||
const clean = String(message || '').trim();
|
||||
return clean || fallback;
|
||||
}
|
||||
|
||||
function emitRemovalNotice(socket, notice = {}) {
|
||||
/*
|
||||
The browser may lose its rover assignment in the same server tick that the
|
||||
reason is generated. Sending a dedicated event before releasing control lets
|
||||
the client preserve the explanation even after normal session sync says the
|
||||
user no longer has an assigned rover.
|
||||
*/
|
||||
if (!socket) return;
|
||||
const roverId = String(notice.roverId || '').trim() || null;
|
||||
const message = normalizeRemovalMessage(notice.message, 'You were removed from the rover.');
|
||||
socket.emit('session:roverRemovalNotice', {
|
||||
roverId,
|
||||
title: normalizeRemovalMessage(notice.title, 'Removed from rover'),
|
||||
message,
|
||||
reasonCode: String(notice.reasonCode || 'removed').trim() || 'removed',
|
||||
actor: notice.actor || null,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socketRefs.set(socket.id, socket);
|
||||
socket.on('disconnect', () => {
|
||||
@@ -176,6 +207,18 @@ function forceRelease(roverId, socketId) {
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
|
||||
function forceReleaseWithNotice(roverId, socketId, notice = {}) {
|
||||
/*
|
||||
This is the one public path for moderation-style removals. It deliberately
|
||||
emits the explanation before forceRelease mutates assignment state, because
|
||||
session sync listeners can update the UI immediately after the release and
|
||||
the UI needs the reason to already be in local state.
|
||||
*/
|
||||
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
|
||||
emitRemovalNotice(socket, { ...notice, roverId });
|
||||
forceRelease(roverId, socketId);
|
||||
}
|
||||
|
||||
function pickRover(socket, options = {}) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
@@ -266,6 +309,7 @@ module.exports = {
|
||||
assignmentEvents,
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
forceReleaseWithNotice,
|
||||
rerollAssignments,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ function formatHelp() {
|
||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
||||
'`rs bridge off` — disable chat bridge for this server',
|
||||
'`rs lights <status|lock|unlock>` — show or change room light lock state',
|
||||
'`rs kick <user> [reason]` — remove a user from their current rover; use `user | reason` for multi-word names',
|
||||
'`rs lock <rover>` — lock a rover; rover names can be fuzzy',
|
||||
'`rs unlock <rover>` — unlock a rover; rover names can be fuzzy',
|
||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||
|
||||
@@ -13,6 +13,7 @@ const { createDeterCommand } = require('./deter');
|
||||
const { createBridgeCommand } = require('./bridge');
|
||||
const { createTimeStatusCommand } = require('./timeStatus');
|
||||
const { createLightsCommand } = require('./lights');
|
||||
const { createKickCommand } = require('./kick');
|
||||
|
||||
function createCommandHandlers(deps) {
|
||||
const {
|
||||
@@ -35,6 +36,7 @@ function createCommandHandlers(deps) {
|
||||
const handleBridgeCommand = createBridgeCommand(deps);
|
||||
const handleTimeStatusCommand = createTimeStatusCommand(deps);
|
||||
const handleLightsCommand = createLightsCommand(deps);
|
||||
const handleKickCommand = createKickCommand(deps);
|
||||
|
||||
async function handleCommand(message) {
|
||||
if (message.author.bot) return;
|
||||
@@ -58,7 +60,7 @@ function createCommandHandlers(deps) {
|
||||
// lockdown mode narrows them from normal admins to lockdown admins. Room
|
||||
// light locking belongs here because it can force the physical room lights
|
||||
// on and disables ordinary Home Assistant room controls for everyone else.
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights']);
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick']);
|
||||
|
||||
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
|
||||
await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
@@ -82,6 +84,8 @@ function createCommandHandlers(deps) {
|
||||
return handleBridgeCommand(message, tokens);
|
||||
case 'lights':
|
||||
return handleLightsCommand(message, tokens);
|
||||
case 'kick':
|
||||
return handleKickCommand(message, rest);
|
||||
case 'lock':
|
||||
return handleLockCommand(message, rest, true);
|
||||
case 'unlock':
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Discord Kick Command
|
||||
// Purpose: Removes a connected user from their current rover without applying any persistent moderation state.
|
||||
// Scope: Resolves an online driver, sends them a UI-visible reason, and releases their current rover assignment.
|
||||
const Fuse = require('fuse.js');
|
||||
|
||||
const DEFAULT_KICK_REASON = 'Removed from rover by admin.';
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function splitSelectorAndReason(rawText) {
|
||||
const text = normalizeText(rawText);
|
||||
if (!text) return { selector: '', reason: '' };
|
||||
const pipeIndex = text.indexOf('|');
|
||||
if (pipeIndex >= 0) {
|
||||
/*
|
||||
A pipe delimiter is the escape hatch for multi-word nicknames. Without a
|
||||
delimiter the command intentionally treats the first token as the selector
|
||||
so quick admin commands stay short: `rs kick bob being reckless`.
|
||||
*/
|
||||
return {
|
||||
selector: normalizeText(text.slice(0, pipeIndex)),
|
||||
reason: normalizeText(text.slice(pipeIndex + 1)),
|
||||
};
|
||||
}
|
||||
const parts = text.split(/\s+/);
|
||||
return {
|
||||
selector: normalizeText(parts.shift()),
|
||||
reason: normalizeText(parts.join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
function buildKickCandidates({ io, roverManager, assignmentService, getNickname }) {
|
||||
return Array.from(io.sockets.sockets.values())
|
||||
.map((socket) => {
|
||||
const socketId = normalizeText(socket?.id);
|
||||
const assignedRoverId = assignmentService?.getAssignedRover?.(socketId) || null;
|
||||
const primaryRoverId = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
const roverId = assignedRoverId || primaryRoverId || null;
|
||||
if (!socketId || !roverId) return null;
|
||||
const nickname = normalizeText(getNickname(socket));
|
||||
const username = normalizeText(socket?.data?.user?.username);
|
||||
return {
|
||||
socket,
|
||||
socketId,
|
||||
roverId,
|
||||
nickname,
|
||||
username,
|
||||
label: nickname || username || socketId.slice(0, 6),
|
||||
searchSocketId: normalizeSearchText(socketId),
|
||||
searchShortSocketId: normalizeSearchText(socketId.slice(0, 6)),
|
||||
searchNickname: normalizeSearchText(nickname),
|
||||
searchUsername: normalizeSearchText(username),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveKickTarget(selector, candidates) {
|
||||
const query = normalizeSearchText(selector);
|
||||
if (!query) return { error: 'Specify a user to kick. Example: `rs kick nickname reason`' };
|
||||
const exact = candidates.filter((entry) => (
|
||||
entry.searchSocketId === query ||
|
||||
entry.searchShortSocketId === query ||
|
||||
entry.searchNickname === query ||
|
||||
entry.searchUsername === query
|
||||
));
|
||||
if (exact.length === 1) return { target: exact[0] };
|
||||
if (exact.length > 1) {
|
||||
return { error: `User matched multiple drivers: ${exact.map((entry) => entry.label).join(', ')}.` };
|
||||
}
|
||||
const fuse = new Fuse(candidates, {
|
||||
includeScore: true,
|
||||
threshold: 0.38,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'nickname', weight: 0.7 },
|
||||
{ name: 'username', weight: 0.2 },
|
||||
{ name: 'socketId', weight: 0.1 },
|
||||
],
|
||||
});
|
||||
const results = fuse.search(selector);
|
||||
if (!results.length) return { error: 'User not found among current rover drivers.' };
|
||||
const first = results[0];
|
||||
const second = results[1];
|
||||
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < 0.08) {
|
||||
return {
|
||||
error: `User matched multiple drivers: ${results.slice(0, 5).map((entry) => entry.item.label).join(', ')}.`,
|
||||
};
|
||||
}
|
||||
return { target: first.item };
|
||||
}
|
||||
|
||||
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions }) {
|
||||
return async function handleKickCommand(message, rawText) {
|
||||
const { selector, reason } = splitSelectorAndReason(rawText);
|
||||
const assignmentService = require('../../assignmentService');
|
||||
const candidates = buildKickCandidates({
|
||||
io,
|
||||
roverManager,
|
||||
assignmentService,
|
||||
getNickname,
|
||||
});
|
||||
const resolved = resolveKickTarget(selector, candidates);
|
||||
if (resolved.error) {
|
||||
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
const target = resolved.target;
|
||||
const removalReason = reason || DEFAULT_KICK_REASON;
|
||||
/*
|
||||
The command deliberately calls the notice-aware release helper instead of
|
||||
roverManager.releaseControl. That keeps admin kicks aligned with automated
|
||||
removals and gives the driver a stable explanation in the video panel.
|
||||
*/
|
||||
assignmentService.forceReleaseWithNotice(target.roverId, target.socketId, {
|
||||
title: 'Removed by admin',
|
||||
message: removalReason,
|
||||
reasonCode: 'admin-kick',
|
||||
actor: message.author?.id || null,
|
||||
});
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createKickCommand,
|
||||
};
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
managerEvents,
|
||||
backoffTimers,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
privateButtonStates,
|
||||
privateNoUsersSince,
|
||||
privateSafetyTimers,
|
||||
@@ -154,6 +155,7 @@ const sensorPipeline = createSensorPipeline({
|
||||
rovers,
|
||||
managerEvents,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
backoffTimers,
|
||||
privateButtonStates,
|
||||
privateSafetyTimers,
|
||||
|
||||
@@ -8,6 +8,7 @@ function createSensorPipeline(deps) {
|
||||
rovers,
|
||||
managerEvents,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
backoffTimers,
|
||||
privateButtonStates,
|
||||
privateSafetyTimers,
|
||||
@@ -38,6 +39,9 @@ function createSensorPipeline(deps) {
|
||||
shouldApplyPrivateSensorSafety,
|
||||
} = deps;
|
||||
|
||||
const DOCK_PROTECTION_MAX_STRIKES = 3;
|
||||
const DOCK_PROTECTION_STRIKE_RESET_MS = 5 * 60 * 1000;
|
||||
|
||||
function getPrivateSafetyState(roverId) {
|
||||
if (!privateSafetyStates.has(roverId)) {
|
||||
privateSafetyStates.set(roverId, {
|
||||
@@ -416,6 +420,68 @@ function createSensorPipeline(deps) {
|
||||
);
|
||||
}
|
||||
|
||||
function getDockProtectionStrikeState(socketId) {
|
||||
/*
|
||||
The strike record is keyed by driver socket because the moderation action
|
||||
removes a person from control, not a rover from service. The last rover is
|
||||
still tracked so "three in a row" means repeated bump-off-dock incidents
|
||||
by the same browser session without a different rover resetting context.
|
||||
*/
|
||||
const key = String(socketId || '').trim();
|
||||
if (!key) return null;
|
||||
if (!dockProtectionStrikeStates.has(key)) {
|
||||
dockProtectionStrikeStates.set(key, {
|
||||
count: 0,
|
||||
lastRoverId: null,
|
||||
updatedAt: 0,
|
||||
});
|
||||
}
|
||||
return dockProtectionStrikeStates.get(key);
|
||||
}
|
||||
|
||||
function recordDockProtectionStrike(suspect) {
|
||||
const socketId = String(suspect?.socketId || '').trim();
|
||||
const roverId = String(suspect?.roverId || '').trim();
|
||||
const state = getDockProtectionStrikeState(socketId);
|
||||
if (!state || !roverId) return 0;
|
||||
const now = Date.now();
|
||||
const previousIsFresh = state.updatedAt && now - state.updatedAt <= DOCK_PROTECTION_STRIKE_RESET_MS;
|
||||
/*
|
||||
Consecutive protection hits should punish repeated behavior, not stale
|
||||
memory from some unrelated rover interaction. Switching suspect rover
|
||||
resets the count because the dock-protection heuristic has a different
|
||||
physical context and should earn its own three-strike sequence.
|
||||
*/
|
||||
state.count = previousIsFresh && state.lastRoverId === roverId ? state.count + 1 : 1;
|
||||
state.lastRoverId = roverId;
|
||||
state.updatedAt = now;
|
||||
return state.count;
|
||||
}
|
||||
|
||||
function clearDockProtectionStrikes(socketId) {
|
||||
const key = String(socketId || '').trim();
|
||||
if (key) dockProtectionStrikeStates.delete(key);
|
||||
}
|
||||
|
||||
function removeDriverForDockProtection(suspect, strikes) {
|
||||
const socketId = String(suspect?.socketId || '').trim();
|
||||
const roverId = String(suspect?.roverId || '').trim();
|
||||
if (!socketId || !roverId) return false;
|
||||
const assignmentService = require('../assignmentService');
|
||||
/*
|
||||
Release through assignmentService so rover membership, turn queues, and
|
||||
the browser-facing removal notice all move together. Directly editing
|
||||
roverManager sets here would skip queue cleanup and produce stale UI.
|
||||
*/
|
||||
assignmentService.forceReleaseWithNotice(roverId, socketId, {
|
||||
title: 'Removed for dock protection',
|
||||
message: `You were removed from ${roverId} after triggering bump-off-dock protection ${strikes} times in a row.`,
|
||||
reasonCode: 'dock-protection',
|
||||
});
|
||||
clearDockProtectionStrikes(socketId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleIdleUndock(undockedRecord) {
|
||||
if (!undockedRecord || undockedRecord.drivers.size > 0) return;
|
||||
const now = Date.now();
|
||||
@@ -435,10 +501,11 @@ function createSensorPipeline(deps) {
|
||||
const suspectRecord = rovers.get(suspect.roverId);
|
||||
if (!suspectRecord) return;
|
||||
const bumpRecent = suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
|
||||
const strikes = recordDockProtectionStrike(suspect);
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Dock protection',
|
||||
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}.`,
|
||||
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}${strikes ? ` (${strikes}/${DOCK_PROTECTION_MAX_STRIKES})` : ''}.`,
|
||||
});
|
||||
try {
|
||||
issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||
@@ -449,6 +516,13 @@ function createSensorPipeline(deps) {
|
||||
setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS);
|
||||
if (bumpRecent) nudgeRover(suspect.roverId, 'backward');
|
||||
else nudgeRover(suspect.roverId, 'forward');
|
||||
if (strikes >= DOCK_PROTECTION_MAX_STRIKES && removeDriverForDockProtection(suspect, strikes)) {
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Driver removed',
|
||||
message: `${suspect.socketId} removed from ${suspect.roverId} after ${strikes} dock-protection triggers.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleSensorFrame(roverId, frame) {
|
||||
|
||||
@@ -9,6 +9,7 @@ const spectatorSockets = new Set();
|
||||
const managerEvents = new EventEmitter();
|
||||
const backoffTimers = new Map();
|
||||
const dockGuardStates = new Map();
|
||||
const dockProtectionStrikeStates = new Map();
|
||||
const privateButtonStates = new Map();
|
||||
const privateNoUsersSince = new Map();
|
||||
const privateSafetyTimers = new Map();
|
||||
@@ -21,6 +22,7 @@ module.exports = {
|
||||
managerEvents,
|
||||
backoffTimers,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
privateButtonStates,
|
||||
privateNoUsersSince,
|
||||
privateSafetyTimers,
|
||||
|
||||
@@ -11,9 +11,18 @@ function stopRover(roverId) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeDriverCompletely(roverId, socketId) {
|
||||
function removeDriverCompletely(roverId, socketId, notice = null) {
|
||||
try {
|
||||
const assignmentService = require('../assignmentService');
|
||||
/*
|
||||
Turn-service removals should explain themselves to the affected browser
|
||||
when a caller provides notice metadata. Plain forceRelease remains the
|
||||
fallback for old internal cleanup paths that only need to mutate state.
|
||||
*/
|
||||
if (notice && typeof assignmentService.forceReleaseWithNotice === 'function') {
|
||||
assignmentService.forceReleaseWithNotice(roverId, socketId, notice);
|
||||
return;
|
||||
}
|
||||
assignmentService.forceRelease(roverId, socketId);
|
||||
} catch (err) {
|
||||
// best effort; log elsewhere if needed
|
||||
|
||||
@@ -290,12 +290,17 @@ function handleIdleTimeout(roverId, expectedDriver) {
|
||||
const skips = incrementSkip(roverId, expectedDriver);
|
||||
stopRover(roverId);
|
||||
if (skips >= MAX_IDLE_SKIPS) {
|
||||
const removalMessage = `You were removed from ${roverId} after ${skips} idle skips because no driving input was detected during your turns.`;
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Driver removed',
|
||||
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
|
||||
});
|
||||
removeDriverCompletely(roverId, expectedDriver);
|
||||
removeDriverCompletely(roverId, expectedDriver, {
|
||||
title: 'Removed for inactivity',
|
||||
message: removalMessage,
|
||||
reasonCode: 'idle-removal',
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendAlert({
|
||||
|
||||
Reference in New Issue
Block a user