diff --git a/pi/roverd/roverd b/pi/roverd/roverd index 6935b9be..dfc24cbf 100755 Binary files a/pi/roverd/roverd and b/pi/roverd/roverd differ diff --git a/server/src/services/assignmentService.js b/server/src/services/assignmentService.js index 5bea1582..7e26fca0 100644 --- a/server/src/services/assignmentService.js +++ b/server/src/services/assignmentService.js @@ -125,6 +125,10 @@ function reassignFromRover(roverId) { assignments.delete(socketId); continue; } + const access = roverManager.canRequestControl(roverId, socket, { allowUser: true }); + if (access.ok) { + continue; + } roverManager.releaseControl(rid, socket); assignments.delete(socketId); assignSocket(socket); diff --git a/server/src/services/discordBotService.js b/server/src/services/discordBotService.js index 9be0a03e..35171c5a 100644 --- a/server/src/services/discordBotService.js +++ b/server/src/services/discordBotService.js @@ -40,6 +40,14 @@ const { listVerifiedUsers, removeVerifiedUser, } = require('./verificationService'); +const { + DM_APPROVE_EMOJI: PRIVATE_ACCESS_APPROVE_EMOJI, + DM_DENY_EMOJI: PRIVATE_ACCESS_DENY_EMOJI, + attachDmMessage: attachPrivateAccessDmMessage, + getRequestByMessageId: getPrivateAccessRequestByMessageId, + approveRequest: approvePrivateAccessRequest, + denyRequest: denyPrivateAccessRequest, +} = require('./privateRoverAccessRequestService'); const config = loadConfig(); const discordConfig = config.discord || {}; const enabled = Boolean(discordConfig.token); @@ -1525,7 +1533,7 @@ async function sendPrivateRoverAccessRequestDms(event) { `IP: \`${requester.ip || 'unknown'}\``, `Created: ${createdAt}`, '', - 'Open the rover manually in the admin UI if approved.', + `React with ${PRIVATE_ACCESS_APPROVE_EMOJI} to approve or ${PRIVATE_ACCESS_DENY_EMOJI} to deny.`, ].join('\n'); await Promise.all( @@ -1534,7 +1542,14 @@ async function sendPrivateRoverAccessRequestDms(event) { const user = await client.users.fetch(String(adminId)); if (!user) return; const dm = await user.createDM(); - await dm.send({ content, allowedMentions: { parse: [] } }); + const message = await dm.send({ content, allowedMentions: { parse: [] } }); + try { + await message.react(PRIVATE_ACCESS_APPROVE_EMOJI); + await message.react(PRIVATE_ACCESS_DENY_EMOJI); + } catch (err) { + logger.warn('Failed to add private rover access reactions', { requestId, adminId, error: err.message }); + } + attachPrivateAccessDmMessage(requestId, message.id, adminId); } catch (err) { logger.warn('Failed to DM lockdown admin for private rover access request', { requestId, @@ -1589,6 +1604,52 @@ async function handleVerificationReaction(reaction, user) { } } +async function handlePrivateAccessReaction(reaction, user) { + if (!reaction || !user || user.bot) return; + const emoji = reaction.emoji?.name; + if (emoji !== PRIVATE_ACCESS_APPROVE_EMOJI && emoji !== PRIVATE_ACCESS_DENY_EMOJI) return; + if (!isLockdownAdminUser(user.id)) return; + + const maybePartial = reaction.message?.partial || reaction.partial; + if (maybePartial) { + try { + await reaction.fetch(); + } catch (err) { + logger.warn('Failed to fetch partial private access reaction', err.message); + return; + } + } + + const messageId = reaction.message?.id; + if (!messageId) return; + const linked = getPrivateAccessRequestByMessageId(messageId); + if (!linked?.request || linked.request.status !== 'pending') return; + + try { + if (emoji === PRIVATE_ACCESS_APPROVE_EMOJI) { + const { assignedSocketId } = approvePrivateAccessRequest(linked.request.id, user.id); + const assignmentNote = assignedSocketId + ? ` Granted and assigned to socket \`${assignedSocketId}\`.` + : ' Granted.'; + await reaction.message.reply({ + content: `Approved private access request \`${linked.request.id}\`.${assignmentNote}`, + allowedMentions: { parse: [] }, + }); + } else { + denyPrivateAccessRequest(linked.request.id, user.id); + await reaction.message.reply({ + content: `Denied private access request \`${linked.request.id}\`.`, + allowedMentions: { parse: [] }, + }); + } + } catch (err) { + logger.warn('Failed to resolve private rover access request from reaction', { + requestId: linked.request.id, + error: err.message, + }); + } +} + function handleChatBridgeOutbound(event) { const payload = event?.payload; if (!payload) return; @@ -1692,6 +1753,9 @@ client.on('messageReactionAdd', (reaction, user) => { handleVerificationReaction(reaction, user).catch((err) => { logger.warn('Error handling verification reaction', err.message); }); + handlePrivateAccessReaction(reaction, user).catch((err) => { + logger.warn('Error handling private access reaction', err.message); + }); }); client.once('ready', () => { diff --git a/server/src/services/privateRoverAccessRequestService.js b/server/src/services/privateRoverAccessRequestService.js index f4635371..c99abfbf 100644 --- a/server/src/services/privateRoverAccessRequestService.js +++ b/server/src/services/privateRoverAccessRequestService.js @@ -10,10 +10,14 @@ const { getSocketIp, normalizeIp } = require('../helpers/ipResolver'); const requestEvents = new EventEmitter(); const REQUEST_COOLDOWN_MS = 15 * 1000; +const DM_APPROVE_EMOJI = '✅'; +const DM_DENY_EMOJI = '❌'; const pendingRequests = new Map(); // requestId -> request const pendingByRequesterRover = new Map(); // `${requesterKey}:${roverId}` -> requestId const lastRequestAtByRequester = new Map(); // requesterKey -> ts +const dmMessages = new Map(); // messageId -> { requestId, adminDiscordId, createdAt } +const grants = new Map(); // `${requesterKey}:${roverId}` -> { requesterKey, roverId, grantedAt, grantedBy, requestId } function normalizeRoverId(value) { return String(value || '').trim(); @@ -25,6 +29,14 @@ function buildRequesterKey(socket) { return `socket:${socket?.id || 'unknown'}`; } +function normalizeRequesterKey(value) { + return String(value || '').trim().toLowerCase(); +} + +function buildGrantKey(requesterKey, roverId) { + return `${normalizeRequesterKey(requesterKey)}:${normalizeRoverId(roverId)}`; +} + function isClosedPrivateRoverRecord(record) { return Boolean(record?.private?.enabled && !record?.privateOpen); } @@ -60,9 +72,21 @@ function listPendingForRequester(socket) { } function getStateForSocket(socket) { + const requesterKey = buildRequesterKey(socket); + const grantedRovers = []; + for (const grant of grants.values()) { + if (grant.requesterKey !== requesterKey) continue; + grantedRovers.push({ + roverId: grant.roverId, + grantedAt: grant.grantedAt, + requestId: grant.requestId || null, + }); + } + grantedRovers.sort((a, b) => b.grantedAt - a.grantedAt); return { requestableRovers: listClosedPrivateRovers(), pendingRequests: listPendingForRequester(socket), + grantedRovers, }; } @@ -83,6 +107,188 @@ function clearPendingForRover(roverId, reason = 'resolved') { } } +function getGrantForRequester(requesterKey, roverId) { + if (!requesterKey || !roverId) return null; + return grants.get(buildGrantKey(requesterKey, roverId)) || null; +} + +function hasClosedPrivateAccessForSocket(socket, roverId) { + if (!socket || !roverId) return false; + const requesterKey = buildRequesterKey(socket); + return Boolean(getGrantForRequester(requesterKey, roverId)); +} + +function listGrantedRoversForRequester(requesterKey) { + const key = normalizeRequesterKey(requesterKey); + if (!key) return []; + const roverIds = []; + for (const grant of grants.values()) { + if (grant.requesterKey !== key) continue; + roverIds.push(grant.roverId); + } + return Array.from(new Set(roverIds)); +} + +function applySocketGrantCache(socket) { + if (!socket) return; + socket.data = socket.data || {}; + const requesterKey = buildRequesterKey(socket); + socket.data.privateClosedAccessRovers = listGrantedRoversForRequester(requesterKey); +} + +function findPendingRequestById(requestId) { + const id = String(requestId || '').trim(); + if (!id) return null; + const request = pendingRequests.get(id); + if (!request || request.status !== 'pending') return null; + return request; +} + +function attachDmMessage(requestId, messageId, adminDiscordId = null) { + const rid = String(requestId || '').trim(); + const mid = String(messageId || '').trim(); + if (!rid || !mid) return; + dmMessages.set(mid, { + requestId: rid, + adminDiscordId: adminDiscordId ? String(adminDiscordId) : null, + createdAt: Date.now(), + }); +} + +function getRequestByMessageId(messageId) { + const mid = String(messageId || '').trim(); + if (!mid) return null; + const map = dmMessages.get(mid); + if (!map) return null; + const request = pendingRequests.get(map.requestId) || null; + if (!request) return null; + return { request, map }; +} + +function getSocketByRequesterKey(requesterKey) { + if (!requesterKey) return null; + if (requesterKey.startsWith('socket:')) { + const socketId = requesterKey.slice('socket:'.length); + return io.sockets.sockets.get(socketId) || null; + } + return null; +} + +function tryAssignClosedPrivateRover(socket, roverId) { + if (!socket || !roverId) return false; + const record = roverManager.rovers.get(String(roverId)); + if (!record) return false; + const access = roverManager.canRequestControl(roverId, socket, { allowUser: true }); + if (!access.ok) { + throw new Error(access.reason || 'Control denied'); + } + const previousJoined = roverManager.getRoversForSocket(socket.id); + roverManager.requestControl(roverId, socket, { allowUser: true }); + previousJoined.forEach((rid) => { + if (rid !== roverId) { + roverManager.releaseControl(rid, socket); + } + }); + roverManager.managerEvents.emit('switch', { socketId: socket.id, roverId: String(roverId) }); + try { + const assignmentService = require('./assignmentService'); + assignmentService.moveAssignment(socket, String(roverId), { releasePrevious: false }); + } catch (err) { + logger.warn('Failed to move assignment after private access grant', { socketId: socket.id, error: err.message }); + } + socket.emit('controlGranted', { roverId: String(roverId) }); + return true; +} + +function approveRequest(requestId, actorDiscordId = null) { + const request = findPendingRequestById(requestId); + if (!request) { + throw new Error('Request not found or already resolved.'); + } + request.status = 'approved'; + request.resolvedAt = Date.now(); + request.resolvedBy = actorDiscordId ? String(actorDiscordId) : null; + pendingByRequesterRover.delete(`${request.requesterKey}:${request.roverId}`); + + const grantKey = buildGrantKey(request.requesterKey, request.roverId); + grants.set(grantKey, { + requesterKey: request.requesterKey, + roverId: request.roverId, + requestId: request.id, + grantedAt: Date.now(), + grantedBy: request.resolvedBy, + }); + + let assignedSocketId = null; + const socket = getSocketByRequesterKey(request.requesterKey); + if (socket) { + applySocketGrantCache(socket); + try { + if (tryAssignClosedPrivateRover(socket, request.roverId)) { + assignedSocketId = socket.id; + } + } catch (err) { + logger.warn('Private rover access approved but assignment failed', { + requestId: request.id, + roverId: request.roverId, + socketId: socket.id, + error: err.message, + }); + } + } + + publishEvent({ + source: 'privateRoverAccessRequest', + type: 'privateRoverAccess.resolved', + payload: { + requestId: request.id, + decision: 'approved', + roverId: request.roverId, + requesterKey: request.requesterKey, + resolvedBy: request.resolvedBy, + resolvedAt: request.resolvedAt, + assignedSocketId, + }, + }); + requestEvents.emit('change', { + reason: 'approved', + requestId: request.id, + roverId: request.roverId, + socketId: assignedSocketId, + }); + return { request, assignedSocketId }; +} + +function denyRequest(requestId, actorDiscordId = null) { + const request = findPendingRequestById(requestId); + if (!request) { + throw new Error('Request not found or already resolved.'); + } + request.status = 'denied'; + request.resolvedAt = Date.now(); + request.resolvedBy = actorDiscordId ? String(actorDiscordId) : null; + pendingByRequesterRover.delete(`${request.requesterKey}:${request.roverId}`); + + publishEvent({ + source: 'privateRoverAccessRequest', + type: 'privateRoverAccess.resolved', + payload: { + requestId: request.id, + decision: 'denied', + roverId: request.roverId, + requesterKey: request.requesterKey, + resolvedBy: request.resolvedBy, + resolvedAt: request.resolvedAt, + }, + }); + requestEvents.emit('change', { + reason: 'denied', + requestId: request.id, + roverId: request.roverId, + }); + return { request }; +} + function createRequest(socket, roverIdRaw) { if (!socket?.id) { throw new Error('Socket required'); @@ -167,6 +373,11 @@ roverManager.managerEvents.on('private', ({ roverId, open } = {}) => { if (!roverId) return; if (open) { clearPendingForRover(roverId, 'opened'); + for (const [key, grant] of grants.entries()) { + if (String(grant.roverId) === String(roverId)) { + grants.delete(key); + } + } } requestEvents.emit('change', { reason: 'private_state', @@ -179,6 +390,11 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => { if (!roverId) return; if (action === 'removed') { clearPendingForRover(roverId, 'rover_removed'); + for (const [key, grant] of grants.entries()) { + if (String(grant.roverId) === String(roverId)) { + grants.delete(key); + } + } } requestEvents.emit('change', { reason: 'rover', @@ -188,6 +404,8 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => { }); io.on('connection', (socket) => { + applySocketGrantCache(socket); + function handleRequest({ roverId } = {}, cb = () => {}) { try { const { request, isNew } = createRequest(socket, roverId); @@ -204,11 +422,21 @@ io.on('connection', (socket) => { socket.on('privateRover:requestAccess', handleRequest); socket.on('session:privateRover:requestAccess', handleRequest); + socket.on('session:identify', () => { + applySocketGrantCache(socket); + requestEvents.emit('change', { reason: 'identify', socketId: socket.id }); + }); }); module.exports = { + DM_APPROVE_EMOJI, + DM_DENY_EMOJI, requestEvents, getStateForSocket, createRequest, + hasClosedPrivateAccessForSocket, + attachDmMessage, + getRequestByMessageId, + approveRequest, + denyRequest, }; - diff --git a/server/src/services/roverManager.js b/server/src/services/roverManager.js index fe86bdd0..20529b07 100644 --- a/server/src/services/roverManager.js +++ b/server/src/services/roverManager.js @@ -150,11 +150,19 @@ function shouldApplyPrivateSensorSafety(record) { return !isLockdownAdmin(activeSocket); } +function socketHasClosedPrivateAccess(socket, roverId) { + const list = Array.isArray(socket?.data?.privateClosedAccessRovers) + ? socket.data.privateClosedAccessRovers + : []; + return list.some((id) => String(id) === String(roverId)); +} + function isRoverVisibleToSocket(record, socket) { if (!record) return false; if (!isPrivateRecord(record)) return true; if (isPrivateOpen(record)) return true; - return isLockdownAdmin(socket); + if (isLockdownAdmin(socket)) return true; + return socketHasClosedPrivateAccess(socket, record.id); } function getControlDenialReason(record, socket, options = {}) { @@ -183,6 +191,9 @@ function getControlDenialReason(record, socket, options = {}) { } if (!isPrivateOpen(record)) { if (!isLockdownAdmin(socket)) { + if (socketHasClosedPrivateAccess(socket, record.id)) { + return null; + } return 'Private rover is closed'; } return null;