mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
praccess
This commit is contained in:
@@ -47,7 +47,7 @@
|
|||||||
- [ ] discord bot service
|
- [ ] discord bot service
|
||||||
- [ ] home assistant service
|
- [ ] home assistant service
|
||||||
- [ ] llm commentary service
|
- [ ] llm commentary service
|
||||||
- [ ] private rover access request service
|
- [x] private rover access request service
|
||||||
- [ ] replay services (already partly split; reformat consistently)
|
- [ ] replay services (already partly split; reformat consistently)
|
||||||
- [ ] room camera services (already partly split; reformat consistently)
|
- [ ] room camera services (already partly split; reformat consistently)
|
||||||
- [ ] rover manager service (in progress: constants/state extracted)
|
- [ ] rover manager service (in progress: constants/state extracted)
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
|
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
|
||||||
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
|
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
|
||||||
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
|
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `privateRoverAccessRequestService` decomposition by extracting in-memory maps/events/constants to `privateRoverAccessRequestService/state.js`, shared keying/lookup helpers to `privateRoverAccessRequestService/helpers.js`, request/grant business logic to `privateRoverAccessRequestService/core.js`, and rover/socket event wiring to `privateRoverAccessRequestService/hooks.js`; `privateRoverAccessRequestService/index.js` is now a thin composition layer.
|
||||||
|
|
||||||
## WebUI frontend
|
## WebUI frontend
|
||||||
### BIGGEST OFFENDERS
|
### BIGGEST OFFENDERS
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// Private Rover Access Core
|
||||||
|
// Purpose: Implements request creation, grant/approval lifecycle, and requester state projection.
|
||||||
|
// Scope: Owns service business rules while keeping transport/event wiring outside this module.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
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 { normalizeCookieUserId } = require('../identityService');
|
||||||
|
const {
|
||||||
|
REQUEST_COOLDOWN_MS,
|
||||||
|
requestEvents,
|
||||||
|
pendingRequests,
|
||||||
|
pendingByRequesterRover,
|
||||||
|
lastRequestAtByRequester,
|
||||||
|
dmMessages,
|
||||||
|
grants,
|
||||||
|
} = require('./state');
|
||||||
|
const {
|
||||||
|
normalizeRoverId,
|
||||||
|
buildRequesterKey,
|
||||||
|
normalizeRequesterKey,
|
||||||
|
buildGrantKey,
|
||||||
|
listClosedPrivateRovers,
|
||||||
|
getSocketByRequesterKey,
|
||||||
|
} = require('./helpers');
|
||||||
|
|
||||||
|
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 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 getGrantForRequester(requesterKey, roverId) {
|
||||||
|
if (!requesterKey || !roverId) return null;
|
||||||
|
return grants.get(buildGrantKey(requesterKey, roverId)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClosedPrivateAccessForSocket(socket, roverId) {
|
||||||
|
if (!socket || !roverId) return false;
|
||||||
|
return Boolean(getGrantForRequester(buildRequesterKey(socket), roverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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');
|
||||||
|
|
||||||
|
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: normalizeCookieUserId(socket?.data?.cookieUserId) || 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getStateForSocket,
|
||||||
|
createRequest,
|
||||||
|
hasClosedPrivateAccessForSocket,
|
||||||
|
attachDmMessage,
|
||||||
|
getRequestByMessageId,
|
||||||
|
approveRequest,
|
||||||
|
denyRequest,
|
||||||
|
applySocketGrantCache,
|
||||||
|
clearPendingForRover,
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Private Rover Access Helpers
|
||||||
|
// Purpose: Provides normalization, keying, lookup, and rover-list helpers used by request/grant workflows.
|
||||||
|
// Scope: Keeps pure or low-side-effect utility behavior centralized and reusable across modules.
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { normalizeCookieUserId } = require('../identityService');
|
||||||
|
|
||||||
|
function normalizeRoverId(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequesterKey(socket) {
|
||||||
|
const cookieUserId = normalizeCookieUserId(socket?.data?.cookieUserId);
|
||||||
|
if (cookieUserId) return `cookie:${cookieUserId}`;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listClosedPrivateRovers() {
|
||||||
|
return 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSocketByRequesterKey(requesterKey) {
|
||||||
|
if (!requesterKey) return null;
|
||||||
|
if (requesterKey.startsWith('socket:')) {
|
||||||
|
const socketId = requesterKey.slice('socket:'.length);
|
||||||
|
return io.sockets.sockets.get(socketId) || null;
|
||||||
|
}
|
||||||
|
if (requesterKey.startsWith('cookie:')) {
|
||||||
|
const cookieUserId = requesterKey.slice('cookie:'.length);
|
||||||
|
for (const socket of io.sockets.sockets.values()) {
|
||||||
|
const key = normalizeCookieUserId(socket?.data?.cookieUserId);
|
||||||
|
if (key && key === cookieUserId) {
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeRoverId,
|
||||||
|
buildRequesterKey,
|
||||||
|
normalizeRequesterKey,
|
||||||
|
buildGrantKey,
|
||||||
|
listClosedPrivateRovers,
|
||||||
|
getSocketByRequesterKey,
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Private Rover Access Hooks
|
||||||
|
// Purpose: Registers rover-manager and socket event handlers for private-access request updates.
|
||||||
|
// Scope: Binds framework events to core flow operations without owning business-rule decisions.
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { requestEvents, grants } = require('./state');
|
||||||
|
|
||||||
|
function registerPrivateRoverAccessHooks(deps) {
|
||||||
|
const { applySocketGrantCache, createRequest, clearPendingForRover } = deps;
|
||||||
|
|
||||||
|
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', roverId: String(roverId), open: Boolean(open) });
|
||||||
|
});
|
||||||
|
|
||||||
|
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', roverId: String(roverId), action: action || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
applySocketGrantCache(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);
|
||||||
|
socket.on('session:identify', () => {
|
||||||
|
applySocketGrantCache(socket);
|
||||||
|
requestEvents.emit('change', { reason: 'identify', socketId: socket.id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerPrivateRoverAccessHooks,
|
||||||
|
};
|
||||||
@@ -1,443 +1,24 @@
|
|||||||
// private Rover Access Request Service
|
// Private Rover Access Request Service
|
||||||
// Purpose: Defines the private Rover Access Request Service module and the helpers/state used by this service unit.
|
// Purpose: Composes private-rover access request state, core workflows, and event hooks behind one API.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Exposes request/grant operations and event stream while delegating behavior to focused modules.
|
||||||
const crypto = require('crypto');
|
const { DM_APPROVE_EMOJI, DM_DENY_EMOJI, requestEvents } = require('./state');
|
||||||
const EventEmitter = require('events');
|
const {
|
||||||
const io = require('../../globals/io');
|
getStateForSocket,
|
||||||
const logger = require('../../globals/logger').child('privateRoverAccessRequest');
|
createRequest,
|
||||||
const { publishEvent } = require('../eventBus');
|
hasClosedPrivateAccessForSocket,
|
||||||
const roverManager = require('../roverManager');
|
attachDmMessage,
|
||||||
const { getNickname } = require('../nicknameService');
|
getRequestByMessageId,
|
||||||
const { getRole, isLockdownAdmin } = require('../roleService');
|
approveRequest,
|
||||||
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
denyRequest,
|
||||||
|
applySocketGrantCache,
|
||||||
|
clearPendingForRover,
|
||||||
|
} = require('./core');
|
||||||
|
const { registerPrivateRoverAccessHooks } = require('./hooks');
|
||||||
|
|
||||||
const requestEvents = new EventEmitter();
|
registerPrivateRoverAccessHooks({
|
||||||
const REQUEST_COOLDOWN_MS = 15 * 1000;
|
applySocketGrantCache,
|
||||||
const DM_APPROVE_EMOJI = '✅';
|
createRequest,
|
||||||
const DM_DENY_EMOJI = '❌';
|
clearPendingForRover,
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequesterKey(socket) {
|
|
||||||
const cookieUserId = String(socket?.data?.cookieUserId || '').trim().toLowerCase();
|
|
||||||
if (cookieUserId) return `cookie:${cookieUserId}`;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
if (requesterKey.startsWith('cookie:')) {
|
|
||||||
const cookieUserId = requesterKey.slice('cookie:'.length);
|
|
||||||
for (const socket of io.sockets.sockets.values()) {
|
|
||||||
const key = String(socket?.data?.cookieUserId || '').trim().toLowerCase();
|
|
||||||
if (key && key === cookieUserId) {
|
|
||||||
return socket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
for (const [key, grant] of grants.entries()) {
|
|
||||||
if (String(grant.roverId) === String(roverId)) {
|
|
||||||
grants.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
for (const [key, grant] of grants.entries()) {
|
|
||||||
if (String(grant.roverId) === String(roverId)) {
|
|
||||||
grants.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestEvents.emit('change', {
|
|
||||||
reason: 'rover',
|
|
||||||
roverId: String(roverId),
|
|
||||||
action: action || null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
applySocketGrantCache(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);
|
|
||||||
socket.on('session:identify', () => {
|
|
||||||
applySocketGrantCache(socket);
|
|
||||||
requestEvents.emit('change', { reason: 'identify', socketId: socket.id });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Private Rover Access State
|
||||||
|
// Purpose: Owns in-memory request, DM-link, cooldown, and grant maps for private-rover access flows.
|
||||||
|
// Scope: Exports mutable state containers and constants shared across service modules.
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const REQUEST_COOLDOWN_MS = 15 * 1000;
|
||||||
|
const DM_APPROVE_EMOJI = '✅';
|
||||||
|
const DM_DENY_EMOJI = '❌';
|
||||||
|
|
||||||
|
const requestEvents = new EventEmitter();
|
||||||
|
const pendingRequests = new Map();
|
||||||
|
const pendingByRequesterRover = new Map();
|
||||||
|
const lastRequestAtByRequester = new Map();
|
||||||
|
const dmMessages = new Map();
|
||||||
|
const grants = new Map();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
REQUEST_COOLDOWN_MS,
|
||||||
|
DM_APPROVE_EMOJI,
|
||||||
|
DM_DENY_EMOJI,
|
||||||
|
requestEvents,
|
||||||
|
pendingRequests,
|
||||||
|
pendingByRequesterRover,
|
||||||
|
lastRequestAtByRequester,
|
||||||
|
dmMessages,
|
||||||
|
grants,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user