replay beeps and boops and flashings
Container image / image (push) Failing after 1m3s

This commit is contained in:
legop3
2026-10-07 14:31:54 -04:00
parent ba2f66ebc1
commit 6bfbb8ad80
8 changed files with 98 additions and 108 deletions
@@ -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);
+43 -22
View File
@@ -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;
}
+1 -1
View File
@@ -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();
});