mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// Discord Replay Command
|
||||
// Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow.
|
||||
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
|
||||
// Scope: Resolves sources, enforces replay availability, reports job progress, uploads video, and broadcasts media URLs.
|
||||
const { AttachmentBuilder } = require('discord.js');
|
||||
const io = require('../../../globals/io');
|
||||
const { hostReplay } = require('../../replayMediaService');
|
||||
@@ -60,12 +60,6 @@ function createReplayCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' });
|
||||
if (!attempt.ok) {
|
||||
await replyDenied(message, `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||
const job = createReplayJob({
|
||||
id: buildReplayJobId('discord'),
|
||||
@@ -74,17 +68,23 @@ function createReplayCommand({
|
||||
sources: resolved.sources || [],
|
||||
includeSidebar: true,
|
||||
});
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
|
||||
const progressMessage = await message.reply({
|
||||
content: sanitizeMentions(buildAcceptedMessage(job)),
|
||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||
});
|
||||
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
||||
const attempt = tryTriggerReplay({ jobId: job.id, by: { source: 'discord', requester } });
|
||||
if (!attempt.ok) {
|
||||
await replyDenied(message, `Replay denied: ${attempt.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let progressMessage = null;
|
||||
let stopTyping = () => {};
|
||||
let builtReplay = null;
|
||||
let deliveredMedia = null;
|
||||
try {
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
progressMessage = await message.reply({
|
||||
content: sanitizeMentions(buildAcceptedMessage(job)),
|
||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||
});
|
||||
stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
|
||||
@@ -35,9 +35,9 @@ async function deliverReplay(payload = {}) {
|
||||
requestedBy: payload.requestedBy,
|
||||
});
|
||||
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
let providerContext = null;
|
||||
try {
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
let providerError = null;
|
||||
if (preferredDeliveryProvider?.begin) {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
|
||||
// Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
|
||||
const Fuse = require('fuse.js');
|
||||
const { updateReplayStatus } = require('../replayEngineV2/cooldown');
|
||||
|
||||
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
|
||||
const FUZZY_THRESHOLD = 0.42;
|
||||
@@ -180,6 +181,22 @@ function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
|
||||
|
||||
function emit(job, status, extra = {}) {
|
||||
const payload = buildPayload(job, status, extra);
|
||||
updateReplayStatus(payload);
|
||||
if (status === 'accepted') {
|
||||
// Sound once per participating rover; a disconnected rover must not abort video creation.
|
||||
const { issueCommand } = require('../commandService');
|
||||
const roverIds = new Set(job.sources.filter((entry) => entry.type === 'rover').map((entry) => entry.id));
|
||||
for (const roverId of roverIds) {
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
song: { notes: [{ note: 72, duration: 8 }, { note: 76, duration: 8 }, { note: 79, duration: 16 }] },
|
||||
});
|
||||
} catch (err) {
|
||||
logger?.warn?.('Unable to play replay tune', { roverId, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
io.emit('replay:status', payload);
|
||||
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
|
||||
if (status === 'failed') io.emit('replay:failed', payload);
|
||||
|
||||
@@ -4,40 +4,61 @@
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const COOLDOWN_MS = 10 * 1000;
|
||||
const MAX_CONCURRENT_JOBS = 3;
|
||||
const replayEvents = new EventEmitter();
|
||||
const activeJobs = new Map();
|
||||
let status = null;
|
||||
let lastTriggeredAt = null;
|
||||
let lastTriggeredBy = null;
|
||||
|
||||
function getRemainingMs(now = Date.now()) {
|
||||
if (!lastTriggeredAt) return 0;
|
||||
return Math.max(0, COOLDOWN_MS - (now - lastTriggeredAt));
|
||||
}
|
||||
|
||||
function getReplayState() {
|
||||
const remainingMs = getRemainingMs();
|
||||
const remainingMs = lastTriggeredAt === null ? 0 : Math.max(0, COOLDOWN_MS - (Date.now() - lastTriggeredAt));
|
||||
return {
|
||||
cooldownMs: COOLDOWN_MS,
|
||||
lastTriggeredAt,
|
||||
lastTriggeredBy,
|
||||
remainingMs,
|
||||
available: remainingMs === 0,
|
||||
atJobLimit: activeJobs.size >= MAX_CONCURRENT_JOBS,
|
||||
activeSourceKeys: [...new Set(Array.from(activeJobs.values()).flatMap((sources) =>
|
||||
sources.map((source) => `${source.type}:${source.id}`),
|
||||
))],
|
||||
status,
|
||||
available: remainingMs === 0 && activeJobs.size < MAX_CONCURRENT_JOBS,
|
||||
};
|
||||
}
|
||||
|
||||
function tryTriggerReplay(by = null) {
|
||||
const remainingMs = getRemainingMs();
|
||||
if (remainingMs > 0) {
|
||||
return { ok: false, remainingMs, state: getReplayState() };
|
||||
}
|
||||
lastTriggeredAt = Date.now();
|
||||
lastTriggeredBy = by || null;
|
||||
const state = getReplayState();
|
||||
replayEvents.emit('update', { state, by: lastTriggeredBy });
|
||||
return { ok: true, state };
|
||||
function emitUpdate() {
|
||||
replayEvents.emit('update', { state: getReplayState(), by: lastTriggeredBy });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tryTriggerReplay,
|
||||
getReplayState,
|
||||
replayEvents,
|
||||
};
|
||||
function tryTriggerReplay({ jobId, by = null } = {}) {
|
||||
const state = getReplayState();
|
||||
if (state.atJobLimit) {
|
||||
return { ok: false, error: 'Replay limit reached: 3 jobs are already being made', state };
|
||||
}
|
||||
if (state.remainingMs > 0) {
|
||||
return { ok: false, error: `Replay cooldown active. Try again in ${Math.ceil(state.remainingMs / 1000)}s.`, remainingMs: state.remainingMs, state };
|
||||
}
|
||||
if (!jobId) throw new Error('Replay job id required');
|
||||
activeJobs.set(jobId, []);
|
||||
status = { jobId, status: 'accepted' };
|
||||
lastTriggeredAt = Date.now();
|
||||
lastTriggeredBy = by;
|
||||
emitUpdate();
|
||||
return { ok: true, state: getReplayState() };
|
||||
}
|
||||
|
||||
function updateReplayStatus(payload) {
|
||||
if (!activeJobs.has(payload.jobId)) return;
|
||||
if (payload.jobId === status?.jobId) {
|
||||
status = { jobId: payload.jobId, status: payload.status, title: payload.title, message: payload.message };
|
||||
}
|
||||
if (payload.status === 'ready' || payload.status === 'failed') {
|
||||
activeJobs.delete(payload.jobId);
|
||||
} else {
|
||||
activeJobs.set(payload.jobId, payload.sources || []);
|
||||
}
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
module.exports = { tryTriggerReplay, getReplayState, replayEvents, updateReplayStatus };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Replay Socket Hooks
|
||||
// Purpose: Registers web socket replay-trigger handler that publishes replay requests.
|
||||
// Scope: Applies replay mode/cooldown/source validation for socket-triggered replay requests.
|
||||
// Scope: Applies replay mode/availability/source validation for socket-triggered replay requests.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('replaySocket');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
@@ -45,9 +45,9 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
||||
const title = normalizeReplayTitle(payload?.title);
|
||||
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
|
||||
const jobId = buildReplayJobId('web');
|
||||
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
||||
const attempt = tryTriggerReplay({ jobId, by: { source: 'web', requester } });
|
||||
if (!attempt.ok) {
|
||||
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
|
||||
cb({ error: attempt.error, remainingMs: attempt.remainingMs, state: attempt.state });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -459,7 +459,7 @@ balanceBoardEvents.on('change', () => {
|
||||
});
|
||||
|
||||
replayEvents.on('update', () => {
|
||||
logger.info('Replay cooldown updated; syncing all clients');
|
||||
logger.info('Replay state updated; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ReplaySourcesPanel({
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const replayStatus = useSessionSelector((state) => state.replayStatus);
|
||||
const replayStatus = replayState?.status;
|
||||
const socket = useSocket();
|
||||
const { triggerReplay } = useSessionActions();
|
||||
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
|
||||
@@ -161,48 +161,25 @@ export default function ReplaySourcesPanel({
|
||||
};
|
||||
}, [sources]);
|
||||
|
||||
const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs);
|
||||
const replayCooldownEndsAt = hasReplayCooldown
|
||||
? replayState.lastTriggeredAt + replayState.cooldownMs
|
||||
const cooldownNow = useSharedClock(1000, Boolean(replayState?.lastTriggeredAt));
|
||||
const remainingMs = replayState?.lastTriggeredAt
|
||||
? Math.max(0, replayState.lastTriggeredAt + replayState.cooldownMs - cooldownNow)
|
||||
: 0;
|
||||
const cooldownNow = useSharedClock(1000, hasReplayCooldown);
|
||||
const remainingMs = useMemo(() => {
|
||||
if (!hasReplayCooldown) return 0;
|
||||
/*
|
||||
The button only shows whole seconds, so a shared one-second clock gives the
|
||||
same useful information without each mounted replay panel owning a 250ms
|
||||
interval. The exact server cooldown still decides whether the action is
|
||||
accepted; this value is only the local disabled-state/display estimate.
|
||||
*/
|
||||
const next = replayCooldownEndsAt - cooldownNow;
|
||||
return Math.max(0, next);
|
||||
}, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]);
|
||||
|
||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||
const atJobLimit = replayState?.atJobLimit === true;
|
||||
const replaySourceKeys = replayState?.activeSourceKeys;
|
||||
const activeSourceKeys = useMemo(() => new Set(replaySourceKeys || []), [replaySourceKeys]);
|
||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || atJobLimit || !selected.length;
|
||||
const selectedSet = useMemo(() => {
|
||||
// Checkbox rendering asks the same membership question for every source.
|
||||
// A Set avoids repeated linear scans and, more importantly, gives memoized
|
||||
// child lists a stable value while the selected keys have not changed.
|
||||
return new Set(selected);
|
||||
}, [selected]);
|
||||
const replayStatusText = useMemo(() => {
|
||||
if (!replayStatus?.status) return null;
|
||||
const titleText = replayStatus.title ? `: ${replayStatus.title}` : '';
|
||||
switch (replayStatus.status) {
|
||||
case 'accepted':
|
||||
return `Replay accepted${titleText}`;
|
||||
case 'building':
|
||||
return `Replay building${titleText}`;
|
||||
case 'uploading':
|
||||
return `Replay uploading${titleText}`;
|
||||
case 'ready':
|
||||
return `Replay ready${titleText}`;
|
||||
case 'failed':
|
||||
return replayStatus.message || `Replay failed${titleText}`;
|
||||
default:
|
||||
return replayStatus.message || `Replay ${replayStatus.status}${titleText}`;
|
||||
}
|
||||
}, [replayStatus]);
|
||||
const replayStatusText = replayStatus?.status
|
||||
? replayStatus.status === 'failed' && replayStatus.message
|
||||
? replayStatus.message
|
||||
: `Replay ${replayStatus.status}${replayStatus.title ? `: ${replayStatus.title}` : ''}`
|
||||
: null;
|
||||
const toggleKey = useCallback((key) => {
|
||||
setSelected((prev) => {
|
||||
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
|
||||
@@ -250,13 +227,13 @@ export default function ReplaySourcesPanel({
|
||||
current desktop sidebar. They only collapse when the card becomes
|
||||
narrower than the supported new-drive desktop column. */}
|
||||
<div className={`grid grid-cols-2 gap-0.5 @max-[15rem]:grid-cols-1 ${listWrapClass}`}>
|
||||
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} onToggle={toggleKey} />
|
||||
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} onToggle={toggleKey} />
|
||||
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
|
||||
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||
{replayStatusText ? (
|
||||
<div className={`text-xs ${replayStatus?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||
<div className={`text-xs ${replayStatus.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||
{replayStatusText}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -279,11 +256,6 @@ export default function ReplaySourcesPanel({
|
||||
saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Enter is the keyboard equivalent of clicking Replay. Ignore
|
||||
// composition events so confirming an IME candidate cannot
|
||||
// accidentally submit a replay before the title is complete.
|
||||
// handleReplay remains the single authority for cooldown,
|
||||
// lockdown, busy, and empty-source checks.
|
||||
if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return;
|
||||
event.preventDefault();
|
||||
handleReplay();
|
||||
@@ -314,7 +286,7 @@ export default function ReplaySourcesPanel({
|
||||
onClick={handleReplay}
|
||||
disabled={replayDisabled}
|
||||
>
|
||||
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
|
||||
{atJobLimit ? 'Replay (limit reached)' : remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,14 +295,14 @@ export default function ReplaySourcesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const GroupList = React.memo(function GroupList({ title, items, selectedSet, onToggle }) {
|
||||
const GroupList = React.memo(function GroupList({ title, items, selectedSet, activeSourceKeys, onToggle }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="panel-muted text-xs">{title}</div>
|
||||
<div className="space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
|
||||
<label key={item.key} className={`surface flex items-center gap-0.5 text-xs ${activeSourceKeys.has(item.key) ? 'animate-pulse' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(item.key)}
|
||||
|
||||
@@ -16,7 +16,6 @@ const INITIAL_STATE = {
|
||||
overseerMemory: null,
|
||||
alerts: [],
|
||||
latestReplay: null,
|
||||
replayStatus: null,
|
||||
duplicateIdentityBlock: null,
|
||||
roverRemovalNotice: null,
|
||||
};
|
||||
@@ -199,23 +198,6 @@ export function SessionProvider({ children }) {
|
||||
],
|
||||
}));
|
||||
}
|
||||
function handleReplayStatus(payload = {}) {
|
||||
if (!payload?.status) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
/*
|
||||
Replay creation is serialized by the server's cooldown and workflow,
|
||||
so the browser needs one current progress value rather than a map of
|
||||
jobs. The server remains authoritative for job identity and execution;
|
||||
this scalar exists only to present accepted/building/uploading/ready/
|
||||
failed progress in the web UI.
|
||||
*/
|
||||
replayStatus: {
|
||||
...payload,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
function handleReplayReady(payload = {}) {
|
||||
if (!payload?.jobId || !payload?.url) return;
|
||||
setState((prev) => ({
|
||||
@@ -316,7 +298,6 @@ export function SessionProvider({ children }) {
|
||||
socket.on('overseer:state', handleOverseerState);
|
||||
socket.on('overseer:memory', handleOverseerMemory);
|
||||
socket.on('alert:new', handleAlertNew);
|
||||
socket.on('replay:status', handleReplayStatus);
|
||||
socket.on('replay:ready', handleReplayReady);
|
||||
socket.on('replay:failed', handleReplayFailed);
|
||||
socket.on('session:duplicateIdentity', handleDuplicateIdentity);
|
||||
@@ -331,7 +312,6 @@ export function SessionProvider({ children }) {
|
||||
socket.off('overseer:state', handleOverseerState);
|
||||
socket.off('overseer:memory', handleOverseerMemory);
|
||||
socket.off('alert:new', handleAlertNew);
|
||||
socket.off('replay:status', handleReplayStatus);
|
||||
socket.off('replay:ready', handleReplayReady);
|
||||
socket.off('replay:failed', handleReplayFailed);
|
||||
socket.off('session:duplicateIdentity', handleDuplicateIdentity);
|
||||
|
||||
Reference in New Issue
Block a user