mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user