This commit is contained in:
legop3
2026-01-03 21:58:08 -05:00
parent 6d55073bfb
commit eb4e1621c0
10 changed files with 130 additions and 28 deletions
+62 -13
View File
@@ -18,7 +18,7 @@ const { subscribe } = require('./eventBus');
const { getRoster, lockRover, rovers } = require('./roverManager');
const { MODES, getMode, setMode } = require('./modeManager');
const { sendExternalMessage } = require('./chatService');
const { getRoomCameras } = require('./roomCameraService');
const { getRoomCameras, getRoomCamera } = require('./roomCameraService');
const {
getRoomCameraFrames,
getRoomCameraReplayDelayMs,
@@ -199,7 +199,7 @@ function formatHelp() {
'**Rover Bot Commands**',
'`rs help` — show this help',
'`rs status [id]` — show rover status (all or one)',
'`rs replay` — send room camera instant replay',
'`rs replay [camera]` — send room camera instant replay',
'`rs lock <id>` — lock a rover',
'`rs unlock <id>` — unlock a rover',
'`rs mode <open|turns|admin|lockdown>` — change server mode',
@@ -250,8 +250,39 @@ function buildDriverCaption() {
return `Drivers: ${entries.join(', ')}`;
}
async function buildReplayVideo() {
function normalizeCameraQuery(input) {
return String(input || '').trim().toLowerCase();
}
function resolveReplayCamera(query) {
const cleaned = normalizeCameraQuery(query);
if (!cleaned || cleaned === 'all' || cleaned === '*') return { camera: null };
const cameras = getRoomCameras();
const direct = cameras.find(
(camera) =>
String(camera.id).toLowerCase() === cleaned ||
String(camera.name || '').toLowerCase() === cleaned,
);
if (direct) return { camera: direct };
const starts = cameras.filter(
(camera) =>
String(camera.id).toLowerCase().startsWith(cleaned) ||
String(camera.name || '').toLowerCase().startsWith(cleaned),
);
if (starts.length === 1) return { camera: starts[0] };
if (starts.length > 1) return { error: 'Ambiguous camera name', matches: starts };
const includes = cameras.filter(
(camera) =>
String(camera.id).toLowerCase().includes(cleaned) ||
String(camera.name || '').toLowerCase().includes(cleaned),
);
if (includes.length === 1) return { camera: includes[0] };
if (includes.length > 1) return { error: 'Ambiguous camera name', matches: includes };
return { error: 'Camera not found', matches: [] };
}
async function buildReplayVideo({ cameraId = null } = {}) {
const cameras = cameraId ? [getRoomCamera(cameraId)].filter(Boolean) : getRoomCameras();
if (!cameras.length) {
throw new Error('No room cameras configured');
}
@@ -300,25 +331,30 @@ async function buildReplayVideo() {
}
}
function buildReplayCaption(requester) {
function buildReplayCaption(requester, camera) {
const requesterLabel = requester || 'unknown';
const cameraLabel = camera ? `Camera: ${camera.name || camera.id}.` : null;
return [
`Replay requested by ${requesterLabel}.`,
cameraLabel,
buildDriverCaption(),
].join(' ');
]
.filter(Boolean)
.join(' ');
}
async function sendReplayToChannel(channelId, requester) {
async function sendReplayToChannel(channelId, requester, cameraId = null) {
if (!channelId) {
throw new Error('Replay channel not configured');
}
const buffer = await buildReplayVideo();
const buffer = await buildReplayVideo({ cameraId });
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
const caption = buildReplayCaption(requester);
const camera = cameraId ? getRoomCamera(cameraId) : null;
const caption = buildReplayCaption(requester, camera);
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
}
async function handleReplayCommand(message) {
async function handleReplayCommand(message, query) {
if (getMode() === MODES.LOCKDOWN) {
await message.reply({
content: 'Replay is disabled while the server is in lockdown.',
@@ -338,12 +374,25 @@ async function handleReplayCommand(message) {
});
return;
}
const resolved = resolveReplayCamera(query);
if (resolved?.error) {
const matches = resolved.matches || [];
const list = matches.length
? `Matches: ${matches.map((cam) => cam.name || cam.id).join(', ')}`
: 'No matching cameras found.';
await message.reply({
content: sanitizeMentions(`${resolved.error}. ${list}`),
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
const cameraId = resolved.camera?.id || null;
const requester =
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
try {
const buffer = await buildReplayVideo();
const buffer = await buildReplayVideo({ cameraId });
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
const caption = buildReplayCaption(requester);
const caption = buildReplayCaption(requester, resolved.camera || null);
await message.reply({
content: sanitizeMentions(caption),
files: [attachment],
@@ -428,7 +477,7 @@ async function handleCommand(message) {
await handleStatusCommand(message, tokens[0]);
break;
case 'replay':
await handleReplayCommand(message);
await handleReplayCommand(message, tokens.join(' '));
break;
case 'lock':
await handleLockCommand(message, tokens[0], true);
@@ -717,7 +766,7 @@ function handleBusEvent(event) {
updatePresence();
break;
case 'replay.requested':
sendReplayToChannel(payload?.channelId, payload?.requester).catch((err) => {
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.cameraId || null).catch((err) => {
logger.warn('Replay send failed', err.message);
});
break;
@@ -5,6 +5,7 @@ const { publishEvent } = require('./eventBus');
const { tryTriggerReplay } = require('./replayService');
const { getNickname } = require('./nicknameService');
const { loadConfig } = require('../helpers/configLoader');
const { getRoomCamera } = require('./roomCameraService');
const config = loadConfig();
const discordConfig = config.discord || {};
@@ -24,6 +25,11 @@ io.on('connection', (socket) => {
cb({ error: 'Replay channel not configured', state: null });
return;
}
const requestedCameraId = payload?.cameraId ? String(payload.cameraId) : null;
if (requestedCameraId && !getRoomCamera(requestedCameraId)) {
cb({ error: 'Unknown camera', state: null });
return;
}
const requester = buildRequesterLabel(socket);
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
if (!attempt.ok) {
@@ -36,6 +42,7 @@ io.on('connection', (socket) => {
payload: {
channelId,
requester,
cameraId: requestedCameraId,
requestedBy: { socketId: socket.id },
},
});
@@ -2,8 +2,8 @@ const EventEmitter = require('events');
const logger = require('../globals/logger').child('roomCameraSnapshot');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const POLL_INTERVAL_MS = 800;
const REPLAY_FRAME_COUNT = 15;
const POLL_INTERVAL_MS = 100;
const REPLAY_FRAME_COUNT = 50;
const FETCH_TIMEOUT_MS = 2000;
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
@@ -7,6 +7,7 @@ const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnap
const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 800;
function passesMode(socket) {
const mode = getMode();
@@ -27,6 +28,7 @@ function canViewRoomCamera(socket) {
const cameraSubscribers = new Map(); // id -> Set(socketId)
const socketSubscriptions = new Map(); // socketId -> Set(id)
const subscribeBuckets = new Map(); // socketId -> { start, count }
const lastSentBySocket = new Map(); // socketId -> Map(cameraId -> ts)
function addSubscription(socket, cameraId) {
if (!cameraSubscribers.has(cameraId)) {
@@ -88,6 +90,17 @@ roomCameraStreamEvents.on('frame', ({ id, buffer, ts }) => {
bucket.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
let lastMap = lastSentBySocket.get(socketId);
if (!lastMap) {
lastMap = new Map();
lastSentBySocket.set(socketId, lastMap);
}
const lastSent = lastMap.get(id) || 0;
const now = ts || Date.now();
if (now - lastSent < STREAM_INTERVAL_MS) {
return;
}
lastMap.set(id, now);
sendFrame(socket, id, { ts }, buffer);
});
});
@@ -149,5 +162,6 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => {
removeAllSubscriptions(socket.id);
subscribeBuckets.delete(socket.id);
lastSentBySocket.delete(socket.id);
});
});