mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
162 lines
6.5 KiB
JavaScript
162 lines
6.5 KiB
JavaScript
// 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.
|
|
const { AttachmentBuilder } = require('discord.js');
|
|
const io = require('../../../globals/io');
|
|
const { hostReplay } = require('../../replayMediaService');
|
|
const {
|
|
DEFAULT_ALLOWED_MENTIONS,
|
|
buildReplayJobId,
|
|
createReplayJob,
|
|
createJobStatusEmitter,
|
|
createReplaySourceResolver,
|
|
createReplayCaptionBuilder,
|
|
startDiscordTypingLoop,
|
|
buildReplayFilename,
|
|
firstAttachmentFromMessage,
|
|
buildDiscordReplayMediaPayload,
|
|
buildAcceptedMessage,
|
|
buildStatusMessage,
|
|
normalizeUserError,
|
|
} = require('../../replayDeliveryService/workflow');
|
|
|
|
function createReplayCommand({
|
|
logger,
|
|
getMode,
|
|
MODES,
|
|
tryTriggerReplay,
|
|
getReplaySources,
|
|
getDefaultDiscordSources,
|
|
validateSources,
|
|
buildReplayVideo,
|
|
sanitizeMentions,
|
|
getActiveDrivers,
|
|
getNickname,
|
|
rovers,
|
|
config,
|
|
}) {
|
|
const sourceResolver = createReplaySourceResolver({
|
|
rovers,
|
|
getReplaySources,
|
|
getDefaultDiscordSources,
|
|
validateSources,
|
|
});
|
|
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
|
|
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
|
|
|
|
async function replyDenied(message, content) {
|
|
await message.reply({ content: sanitizeMentions(content), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
}
|
|
|
|
return async function handleReplayCommand(message, query) {
|
|
if (getMode() === MODES.LOCKDOWN) {
|
|
await replyDenied(message, 'Replay denied: server is in lockdown.');
|
|
return;
|
|
}
|
|
|
|
const resolved = sourceResolver.resolve(query);
|
|
if (resolved?.error) {
|
|
await replyDenied(message, resolved.error);
|
|
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'),
|
|
requester,
|
|
source: 'discord',
|
|
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');
|
|
|
|
let builtReplay = null;
|
|
let deliveredMedia = null;
|
|
try {
|
|
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
|
if (progressMessage?.edit) {
|
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
}
|
|
|
|
builtReplay = await buildReplayVideo({
|
|
sources: job.sources,
|
|
title: job.title,
|
|
requester: job.requester,
|
|
includeSidebar: job.includeSidebar,
|
|
});
|
|
const { buffer, usedSources = job.sources, missingSources = [] } = builtReplay;
|
|
|
|
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
|
|
if (progressMessage?.edit) {
|
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
}
|
|
|
|
// Keep direct Discord commands consistent with web-triggered uploads and
|
|
// with the local hosting fallback used when Discord delivery is unavailable.
|
|
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
|
|
const body = replayCaption.build({ job, usedSources, missingSources });
|
|
const uploadMessage = await progressMessage.reply({
|
|
content: body,
|
|
files: [attachment],
|
|
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
|
});
|
|
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
|
|
|
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
|
|
deliveredMedia = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
|
|
if (!deliveredMedia) throw new Error('Discord upload did not include a replay attachment URL');
|
|
|
|
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media: deliveredMedia });
|
|
if (progressMessage?.edit) {
|
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
}
|
|
} catch (err) {
|
|
if (deliveredMedia) {
|
|
logger?.warn?.('Replay uploaded but Discord progress message could not be finalized', { jobId: job.id, error: err.message });
|
|
return;
|
|
}
|
|
// A completed video should never be discarded merely because the
|
|
// optional Discord upload failed. Host that exact buffer locally and
|
|
// publish the same ready event consumed by existing clients.
|
|
if (builtReplay?.buffer && !deliveredMedia) {
|
|
try {
|
|
const media = await hostReplay({ buffer: builtReplay.buffer, job });
|
|
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
|
if (progressMessage?.edit) {
|
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
}
|
|
const publicBaseUrl = String(config?.publicUrl || '').replace(/\/$/, '');
|
|
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
|
|
await progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
return;
|
|
} catch (fallbackError) {
|
|
logger?.warn?.('Local replay fallback failed', { jobId: job.id, error: fallbackError.message });
|
|
}
|
|
}
|
|
const userMessage = normalizeUserError(err);
|
|
jobStatus.emit(job, 'failed', { message: userMessage });
|
|
if (progressMessage?.edit) {
|
|
await progressMessage.edit({ content: sanitizeMentions(userMessage), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
} else {
|
|
await replyDenied(message, userMessage);
|
|
}
|
|
} finally {
|
|
stopTyping();
|
|
}
|
|
};
|
|
}
|
|
|
|
module.exports = { createReplayCommand };
|