replay capture ?

This commit is contained in:
legop3
2026-01-03 20:53:38 -05:00
parent bbb4014f61
commit e0cadb493f
4 changed files with 210 additions and 1 deletions
+131 -1
View File
@@ -4,13 +4,29 @@ const {
Partials, Partials,
ActivityType, ActivityType,
EmbedBuilder, EmbedBuilder,
AttachmentBuilder,
} = require('discord.js'); } = require('discord.js');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const { promisify } = require('util');
const { execFile } = require('child_process');
const logger = require('../globals/logger').child('discordBot'); const logger = require('../globals/logger').child('discordBot');
const io = require('../globals/io');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { subscribe } = require('./eventBus'); const { subscribe } = require('./eventBus');
const { getRoster, lockRover, rovers } = require('./roverManager'); const { getRoster, lockRover, rovers } = require('./roverManager');
const { MODES, getMode, setMode } = require('./modeManager'); const { MODES, getMode, setMode } = require('./modeManager');
const { sendExternalMessage } = require('./chatService'); const { sendExternalMessage } = require('./chatService');
const { getRoomCameras } = require('./roomCameraService');
const {
getRoomCameraFrames,
getRoomCameraReplayDelayMs,
getRoomCameraReplayFrameCount,
} = require('./roomCameraSnapshotService');
const { getActiveDrivers } = require('./turnService');
const { getNickname } = require('./nicknameService');
const { tryTriggerReplay } = require('./replayService');
const config = loadConfig(); const config = loadConfig();
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
@@ -37,6 +53,7 @@ const client = new Client({
const channelCache = new Map(); const channelCache = new Map();
let skippedFirstModeAnnouncement = false; let skippedFirstModeAnnouncement = false;
const execFileAsync = promisify(execFile);
function sanitizeMentions(text) { function sanitizeMentions(text) {
if (!text) return ''; if (!text) return '';
@@ -182,6 +199,7 @@ function formatHelp() {
'**Rover Bot Commands**', '**Rover Bot Commands**',
'`rs help` — show this help', '`rs help` — show this help',
'`rs status [id]` — show rover status (all or one)', '`rs status [id]` — show rover status (all or one)',
'`rs replay` — send room camera instant replay',
'`rs lock <id>` — lock a rover', '`rs lock <id>` — lock a rover',
'`rs unlock <id>` — unlock a rover', '`rs unlock <id>` — unlock a rover',
'`rs mode <open|turns|admin|lockdown>` — change server mode', '`rs mode <open|turns|admin|lockdown>` — change server mode',
@@ -216,6 +234,115 @@ async function handleStatusCommand(message, roverId) {
}); });
} }
function buildDriverCaption() {
const activeDrivers = getActiveDrivers();
const roster = Array.from(rovers.values());
if (!roster.length) return 'Drivers: no rovers online.';
const entries = roster.map((record) => {
const driverId = activeDrivers[record.id];
if (!driverId) {
return `${record.meta?.name || record.id}: none`;
}
const socket = io.sockets.sockets.get(driverId);
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
return `${record.meta?.name || record.id}: ${nickname}`;
});
return `Drivers: ${entries.join(', ')}`;
}
async function buildReplayGif() {
const cameras = getRoomCameras();
if (!cameras.length) {
throw new Error('No room cameras configured');
}
const frames = [];
cameras.forEach((camera) => {
const history = getRoomCameraFrames(camera.id, getRoomCameraReplayFrameCount());
history.forEach((entry) => {
frames.push({ camera, buffer: entry.buffer });
});
});
if (!frames.length) {
throw new Error('No camera frames available yet');
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
try {
for (let i = 0; i < frames.length; i += 1) {
const filename = `frame-${String(i + 1).padStart(4, '0')}.jpg`;
await fsp.writeFile(path.join(tmpDir, filename), frames[i].buffer);
}
const outPath = path.join(tmpDir, 'replay.gif');
const delayMs = getRoomCameraReplayDelayMs();
const fps = (1000 / delayMs).toFixed(3);
await execFileAsync('ffmpeg', [
'-y',
'-hide_banner',
'-loglevel',
'error',
'-framerate',
fps,
'-i',
'frame-%04d.jpg',
'-vf',
'scale=640:-1:flags=lanczos',
'-loop',
'0',
outPath,
], { cwd: tmpDir });
const buffer = await fsp.readFile(outPath);
return buffer;
} finally {
try {
await fsp.rm(tmpDir, { recursive: true, force: true });
} catch (err) {
logger.warn('Failed to cleanup replay temp dir', err.message);
}
}
}
async function handleReplayCommand(message) {
if (getMode() === MODES.LOCKDOWN) {
await message.reply({
content: 'Replay is disabled while the server is in lockdown.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
const attempt = tryTriggerReplay({
by: message.author?.id || null,
source: 'discord',
});
if (!attempt.ok) {
const remaining = Math.ceil(attempt.remainingMs / 1000);
await message.reply({
content: `Replay cooldown active. Try again in ${remaining}s.`,
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
const requester =
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
try {
const buffer = await buildReplayGif();
const attachment = new AttachmentBuilder(buffer, { name: 'replay.gif' });
const caption = [
`Replay requested by ${requester}.`,
buildDriverCaption(),
].join(' ');
await message.reply({
content: sanitizeMentions(caption),
files: [attachment],
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
logger.warn('Replay capture failed', err.message);
await message.reply({
content: sanitizeMentions(`Replay failed: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
}
async function handleLockCommand(message, roverId, locked) { async function handleLockCommand(message, roverId, locked) {
if (!roverId) { if (!roverId) {
await message.reply({ await message.reply({
@@ -271,7 +398,7 @@ async function handleCommand(message) {
const action = (tokens.shift() || '').toLowerCase(); const action = (tokens.shift() || '').toLowerCase();
const isAdmin = isAdminUser(message.author.id); const isAdmin = isAdminUser(message.author.id);
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help') { if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay') {
return; // ignore non-admins for privileged commands return; // ignore non-admins for privileged commands
} }
@@ -285,6 +412,9 @@ async function handleCommand(message) {
case 'status': case 'status':
await handleStatusCommand(message, tokens[0]); await handleStatusCommand(message, tokens[0]);
break; break;
case 'replay':
await handleReplayCommand(message);
break;
case 'lock': case 'lock':
await handleLockCommand(message, tokens[0], true); await handleLockCommand(message, tokens[0], true);
break; break;
+42
View File
@@ -0,0 +1,42 @@
const EventEmitter = require('events');
const COOLDOWN_MS = 10 * 1000;
const events = new EventEmitter();
let lastTriggeredAt = null;
let lastTriggeredBy = null;
function getRemainingMs(now = Date.now()) {
if (!lastTriggeredAt) return 0;
const elapsed = now - lastTriggeredAt;
return Math.max(0, COOLDOWN_MS - elapsed);
}
function getState() {
const remainingMs = getRemainingMs();
return {
cooldownMs: COOLDOWN_MS,
lastTriggeredAt,
lastTriggeredBy,
remainingMs,
available: remainingMs === 0,
};
}
function tryTrigger(by = null) {
const remainingMs = getRemainingMs();
if (remainingMs > 0) {
return { ok: false, remainingMs, state: getState() };
}
lastTriggeredAt = Date.now();
lastTriggeredBy = by || null;
const state = getState();
events.emit('update', { state, by: lastTriggeredBy });
return { ok: true, state };
}
module.exports = {
tryTriggerReplay: tryTrigger,
getReplayState: getState,
replayEvents: events,
};
@@ -3,9 +3,11 @@ const logger = require('../globals/logger').child('roomCameraSnapshot');
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const POLL_INTERVAL_MS = 800; const POLL_INTERVAL_MS = 800;
const REPLAY_FRAME_COUNT = 15;
const FETCH_TIMEOUT_MS = 2000; const FETCH_TIMEOUT_MS = 2000;
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching} const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
const frameHistory = new Map(); // id -> [{buffer, ts}]
const events = new EventEmitter(); // frame, status const events = new EventEmitter(); // frame, status
let pollTimer = null; let pollTimer = null;
@@ -16,6 +18,15 @@ function markState(id, updates = {}) {
return next; return next;
} }
function recordFrame(id, buffer, ts) {
const history = frameHistory.get(id) || [];
history.push({ buffer, ts });
if (history.length > REPLAY_FRAME_COUNT) {
history.splice(0, history.length - REPLAY_FRAME_COUNT);
}
frameHistory.set(id, history);
}
async function fetchSnapshot(camera) { async function fetchSnapshot(camera) {
const { id, url } = camera; const { id, url } = camera;
const state = cameraState.get(id); const state = cameraState.get(id);
@@ -32,6 +43,7 @@ async function fetchSnapshot(camera) {
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const ts = Date.now(); const ts = Date.now();
markState(id, { frame: buffer, ts, error: null, failures: 0 }); markState(id, { frame: buffer, ts, error: null, failures: 0 });
recordFrame(id, buffer, ts);
events.emit('frame', { id, buffer, ts }); events.emit('frame', { id, buffer, ts });
} catch (err) { } catch (err) {
const failures = (state?.failures || 0) + 1; const failures = (state?.failures || 0) + 1;
@@ -50,6 +62,7 @@ function stopAll() {
pollTimer = null; pollTimer = null;
} }
cameraState.clear(); cameraState.clear();
frameHistory.clear();
} }
function startAll() { function startAll() {
@@ -71,6 +84,20 @@ function getState(id) {
}; };
} }
function getReplayFrames(id, count = REPLAY_FRAME_COUNT) {
const history = frameHistory.get(id) || [];
if (!history.length) return [];
return history.slice(Math.max(0, history.length - count));
}
function getReplayFrameDelayMs() {
return POLL_INTERVAL_MS;
}
function getReplayFrameCount() {
return REPLAY_FRAME_COUNT;
}
roomCameraEvents.on('update', () => { roomCameraEvents.on('update', () => {
logger.info('Room cameras changed; restarting snapshot pollers'); logger.info('Room cameras changed; restarting snapshot pollers');
startAll(); startAll();
@@ -81,4 +108,7 @@ startAll();
module.exports = { module.exports = {
roomCameraStreamEvents: events, roomCameraStreamEvents: events,
getRoomCameraState: getState, getRoomCameraState: getState,
getRoomCameraFrames: getReplayFrames,
getRoomCameraReplayDelayMs: getReplayFrameDelayMs,
getRoomCameraReplayFrameCount: getReplayFrameCount,
}; };
+7
View File
@@ -9,6 +9,7 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService')
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
const { getNickname, nicknameEvents } = require('./nicknameService'); const { getNickname, nicknameEvents } = require('./nicknameService');
const { getReplayState, replayEvents } = require('./replayService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const discordInvite = loadConfig().discord?.invite || null; const discordInvite = loadConfig().discord?.invite || null;
@@ -45,6 +46,7 @@ function buildSession(socket) {
turnQueues: getTurnQueues(), turnQueues: getTurnQueues(),
roomCameras: getRoomCameras(), roomCameras: getRoomCameras(),
homeAssistant: getHomeAssistantState(), homeAssistant: getHomeAssistantState(),
replay: getReplayState(),
users, users,
discord: { discord: {
invite: discordInvite, invite: discordInvite,
@@ -156,6 +158,11 @@ homeAssistantEvents.on('status', () => {
syncAll(); syncAll();
}); });
replayEvents.on('update', () => {
logger.info('Replay cooldown updated; syncing all clients');
syncAll();
});
nicknameEvents.on('change', ({ socketId }) => { nicknameEvents.on('change', ({ socketId }) => {
const socket = socketId ? io.sockets.sockets.get(socketId) : null; const socket = socketId ? io.sockets.sockets.get(socketId) : null;
if (socket) { if (socket) {