Files
MultiRoombaRover/server/src/services/discordBotService/commands/replay.js
T
2026-07-15 00:35:39 -04:00

160 lines
6.4 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,
sanitizeReplayTitleForFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
} = require('../../replayDeliveryService/workflow');
function createReplayCommand({
logger,
getMode,
MODES,
tryTriggerReplay,
getReplaySources,
getDefaultDiscordSources,
validateSources,
buildReplayVideo,
sanitizeMentions,
getActiveDrivers,
getNickname,
rovers,
discordConfig,
}) {
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 });
}
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
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 siteUrl = String(discordConfig?.siteUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${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 };