roverquesting

This commit is contained in:
legop3
2026-03-27 16:08:04 -04:00
parent 998933f4c2
commit c220eb4022
14 changed files with 508 additions and 131 deletions
+1
View File
@@ -17,6 +17,7 @@ require('./src/services/roverConnectionService');
require('./src/services/assignmentService');
require('./src/services/nicknameService');
require('./src/services/verificationService');
require('./src/services/privateRoverAccessRequestService');
require('./src/services/chatService');
require('./src/services/llmCommentaryService');
require('./src/services/communityGoalService');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-Dmezqb_N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css">
<script type="module" crossorigin src="/assets/index-dMtKgicC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BPze86Ff.css">
</head>
<body>
<div id="root"></div>
+44 -1
View File
@@ -40,7 +40,6 @@ const {
listVerifiedUsers,
removeVerifiedUser,
} = require('./verificationService');
const config = loadConfig();
const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.token);
@@ -1504,6 +1503,49 @@ async function sendVerificationRequestDms(event) {
);
}
async function sendPrivateRoverAccessRequestDms(event) {
const payload = event?.payload || {};
const requestId = payload.id;
if (!requestId) return;
const adminIdsToNotify = Array.from(lockdownAdminIds);
if (!adminIdsToNotify.length) {
logger.warn('No lockdown admins configured for private rover access request DM', { requestId });
return;
}
const requester = payload.requester || {};
const createdAt = payload.createdAt ? new Date(payload.createdAt).toLocaleString() : 'unknown';
const content = [
'**Private Rover Access Request**',
`Request ID: \`${requestId}\``,
`Rover: ${sanitizeMentions(payload.roverName || payload.roverId || 'unknown')} (\`${payload.roverId || 'unknown'}\`)`,
`Requester: ${sanitizeMentions(requester.nickname || requester.socketId || 'unknown')}`,
`Role: \`${requester.role || 'unknown'}\``,
`Verified: \`${requester.isVerified ? 'yes' : 'no'}\``,
`Identity key: \`${requester.cookieUserId || 'unknown'}\``,
`IP: \`${requester.ip || 'unknown'}\``,
`Created: ${createdAt}`,
'',
'Open the rover manually in the admin UI if approved.',
].join('\n');
await Promise.all(
adminIdsToNotify.map(async (adminId) => {
try {
const user = await client.users.fetch(String(adminId));
if (!user) return;
const dm = await user.createDM();
await dm.send({ content, allowedMentions: { parse: [] } });
} catch (err) {
logger.warn('Failed to DM lockdown admin for private rover access request', {
requestId,
adminId,
error: err.message,
});
}
}),
);
}
async function handleVerificationReaction(reaction, user) {
if (!reaction || !user || user.bot) return;
const emoji = reaction.emoji?.name;
@@ -1659,6 +1701,7 @@ client.once('ready', () => {
subscribe('*', handleBusEvent);
subscribe('verification.requested', sendVerificationRequestDms);
subscribe('privateRoverAccess.requested', sendPrivateRoverAccessRequestDms);
subscribe('chat:message', handleChatBridgeOutbound);
subscribe('chat:typing', handleChatTypingOutbound);
@@ -0,0 +1,214 @@
const crypto = require('crypto');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('privateRoverAccessRequest');
const { publishEvent } = require('./eventBus');
const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService');
const { getRole, isLockdownAdmin } = require('./roleService');
const { getSocketIp, normalizeIp } = require('../helpers/ipResolver');
const requestEvents = new EventEmitter();
const REQUEST_COOLDOWN_MS = 15 * 1000;
const pendingRequests = new Map(); // requestId -> request
const pendingByRequesterRover = new Map(); // `${requesterKey}:${roverId}` -> requestId
const lastRequestAtByRequester = new Map(); // requesterKey -> ts
function normalizeRoverId(value) {
return String(value || '').trim();
}
function buildRequesterKey(socket) {
const cookieUserId = String(socket?.data?.cookieUserId || '').trim().toLowerCase();
if (cookieUserId) return `cookie:${cookieUserId}`;
return `socket:${socket?.id || 'unknown'}`;
}
function isClosedPrivateRoverRecord(record) {
return Boolean(record?.private?.enabled && !record?.privateOpen);
}
function listClosedPrivateRovers() {
const records = Array.from(roverManager.rovers.values())
.filter((record) => isClosedPrivateRoverRecord(record))
.map((record) => ({
id: String(record.id),
name: record.meta?.name || record.id,
color: record.meta?.color || null,
}))
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
return records;
}
function listPendingForRequester(socket) {
const requesterKey = buildRequesterKey(socket);
const pending = [];
for (const request of pendingRequests.values()) {
if (request.requesterKey !== requesterKey) continue;
if (request.status !== 'pending') continue;
pending.push({
id: request.id,
roverId: request.roverId,
roverName: request.roverName,
createdAt: request.createdAt,
status: request.status,
});
}
pending.sort((a, b) => b.createdAt - a.createdAt);
return pending;
}
function getStateForSocket(socket) {
return {
requestableRovers: listClosedPrivateRovers(),
pendingRequests: listPendingForRequester(socket),
};
}
function clearPendingRequest(request, reason = 'resolved') {
if (!request || request.status !== 'pending') return;
request.status = reason;
request.resolvedAt = Date.now();
pendingByRequesterRover.delete(`${request.requesterKey}:${request.roverId}`);
requestEvents.emit('change', { reason, requestId: request.id, roverId: request.roverId });
}
function clearPendingForRover(roverId, reason = 'resolved') {
const target = normalizeRoverId(roverId);
for (const request of pendingRequests.values()) {
if (request.status !== 'pending') continue;
if (String(request.roverId) !== target) continue;
clearPendingRequest(request, reason);
}
}
function createRequest(socket, roverIdRaw) {
if (!socket?.id) {
throw new Error('Socket required');
}
const roverId = normalizeRoverId(roverIdRaw);
if (!roverId) {
throw new Error('roverId required');
}
const record = roverManager.rovers.get(roverId);
if (!record) {
throw new Error('Unknown rover');
}
if (!record?.private?.enabled) {
throw new Error('Rover is not private');
}
if (record.privateOpen) {
throw new Error('Private rover is already open');
}
if (isLockdownAdmin(socket)) {
throw new Error('Lockdown admins can open private rovers directly');
}
const requesterKey = buildRequesterKey(socket);
const dedupeKey = `${requesterKey}:${roverId}`;
const existingId = pendingByRequesterRover.get(dedupeKey);
if (existingId) {
const existing = pendingRequests.get(existingId);
if (existing && existing.status === 'pending') {
return { request: existing, isNew: false };
}
}
const now = Date.now();
const lastAt = Number(lastRequestAtByRequester.get(requesterKey) || 0);
if (lastAt && now - lastAt < REQUEST_COOLDOWN_MS) {
const remaining = Math.ceil((REQUEST_COOLDOWN_MS - (now - lastAt)) / 1000);
throw new Error(`Please wait ${remaining}s before sending another request`);
}
const request = {
id: `prr_${crypto.randomBytes(8).toString('hex')}`,
status: 'pending',
createdAt: now,
resolvedAt: null,
requesterKey,
roverId,
roverName: record.meta?.name || record.id,
requester: {
socketId: socket.id,
nickname: getNickname(socket) || null,
role: getRole(socket),
isVerified: Boolean(socket?.data?.isVerified),
cookieUserId: String(socket?.data?.cookieUserId || '').trim().toLowerCase() || null,
ip: normalizeIp(getSocketIp(socket)) || null,
},
};
pendingRequests.set(request.id, request);
pendingByRequesterRover.set(dedupeKey, request.id);
lastRequestAtByRequester.set(requesterKey, now);
publishEvent({
source: 'privateRoverAccessRequest',
type: 'privateRoverAccess.requested',
payload: request,
});
requestEvents.emit('change', {
reason: 'created',
requestId: request.id,
roverId: request.roverId,
socketId: socket.id,
});
logger.info('Private rover access requested', {
requestId: request.id,
roverId: request.roverId,
socketId: socket.id,
});
return { request, isNew: true };
}
roverManager.managerEvents.on('private', ({ roverId, open } = {}) => {
if (!roverId) return;
if (open) {
clearPendingForRover(roverId, 'opened');
}
requestEvents.emit('change', {
reason: 'private_state',
roverId: String(roverId),
open: Boolean(open),
});
});
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return;
if (action === 'removed') {
clearPendingForRover(roverId, 'rover_removed');
}
requestEvents.emit('change', {
reason: 'rover',
roverId: String(roverId),
action: action || null,
});
});
io.on('connection', (socket) => {
function handleRequest({ roverId } = {}, cb = () => {}) {
try {
const { request, isNew } = createRequest(socket, roverId);
cb({
success: true,
requestId: request.id,
status: request.status,
existing: !isNew,
});
} catch (err) {
cb({ error: err.message });
}
}
socket.on('privateRover:requestAccess', handleRequest);
socket.on('session:privateRover:requestAccess', handleRequest);
});
module.exports = {
requestEvents,
getStateForSocket,
createRequest,
};
+12 -2
View File
@@ -135,11 +135,21 @@ function getPrivateSafety(record) {
}
function shouldApplyPrivateSafety(record, socket) {
if (!isPrivateRecord(record) || !isPrivateOpen(record)) return false;
if (!isPrivateRecord(record)) return false;
if (isLockdownAdmin(socket)) return false;
return true;
}
function shouldApplyPrivateSensorSafety(record) {
if (!isPrivateRecord(record)) return false;
const activeDrivers = turnService.getActiveDrivers();
const activeDriverId = activeDrivers?.[record.id];
if (!activeDriverId) return false;
const activeSocket = io.sockets.sockets.get(activeDriverId);
if (!activeSocket) return true;
return !isLockdownAdmin(activeSocket);
}
function isRoverVisibleToSocket(record, socket) {
if (!record) return false;
if (!isPrivateRecord(record)) return true;
@@ -639,7 +649,7 @@ function evaluatePrivateSafety(record, sensors) {
const currentBump = bump;
const currentCliff = cliff;
if (!isPrivateRecord(record) || !isPrivateOpen(record)) {
if (!shouldApplyPrivateSensorSafety(record)) {
state.blockedUntil = 0;
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
+10
View File
@@ -14,6 +14,10 @@ const {
getIdentitySummary,
verificationEvents,
} = require('./verificationService');
const {
getStateForSocket: getPrivateRoverAccessStateForSocket,
requestEvents: privateRoverAccessRequestEvents,
} = require('./privateRoverAccessRequestService');
const { getReplayState, replayEvents } = require('./replayService');
const { getReplaySources } = require('./replaySourceService');
const { getHealthSnapshot } = require('./healthService');
@@ -128,6 +132,7 @@ function buildSession(socket) {
},
identity: getIdentitySummary(socket),
verification: getVerificationStateForSocket(socket),
privateRoverAccess: getPrivateRoverAccessStateForSocket(socket),
isVerified: Boolean(socket?.data?.isVerified),
audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(),
@@ -214,6 +219,11 @@ managerEvents.on('privateSafety', ({ roverId }) => {
syncAll();
});
privateRoverAccessRequestEvents.on('change', (event = {}) => {
logger.info('Private rover access request state changed', event.reason || 'unknown');
syncAll();
});
managerEvents.on('driver', ({ socketId }) => {
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);