mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
better bettter replayses
This commit is contained in:
@@ -24,13 +24,16 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
|
||||
|
||||
async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }, sanitizeContent = true) {
|
||||
const channel = await fetchChannel(id);
|
||||
if (!channel) return;
|
||||
if (!channel) return null;
|
||||
try {
|
||||
const messageContent = sanitizeContent ? sanitizeMentions(content) : content;
|
||||
await channel.send({ content: messageContent, allowedMentions, ...options });
|
||||
// Replay uploads need the returned message so the server can extract Discord's attachment URL
|
||||
// and broadcast it to the Web UI instead of streaming video from the home server.
|
||||
return await channel.send({ content: messageContent, allowedMentions, ...options });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to send Discord message', { id, error: err.message });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typingCacheKey(guildId, typingId) {
|
||||
|
||||
@@ -1,112 +1,130 @@
|
||||
// Discord Replay Command
|
||||
// Purpose: Handles replay capture requests from Discord.
|
||||
// Scope: Resolves source selectors, enforces cooldowns, and replies with replay video attachment.
|
||||
// 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 {
|
||||
DEFAULT_ALLOWED_MENTIONS,
|
||||
buildReplayJobId,
|
||||
createReplayJob,
|
||||
createJobStatusEmitter,
|
||||
createReplaySourceResolver,
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
buildStatusMessage,
|
||||
normalizeUserError,
|
||||
} = require('../replayWorkflow');
|
||||
|
||||
function createReplayCommand({ getMode, MODES, tryTriggerReplay, getReplaySources, getDefaultDiscordSources, validateSources, buildReplayVideo, sanitizeMentions, getActiveDrivers, getNickname, rovers }) {
|
||||
function normalizeReplayQuery(input) { return String(input || '').trim().toLowerCase(); }
|
||||
function sanitizeReplayTitleForFilename(title) {
|
||||
const cleaned = String(title || '').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 96);
|
||||
return cleaned || 'replay';
|
||||
}
|
||||
function buildDefaultReplayTitle(requester, sources = []) {
|
||||
const requesterLabel = String(requester || 'Someone').trim() || 'Someone';
|
||||
const roverSource = (Array.isArray(sources) ? sources : []).find((entry) => entry?.type === 'rover');
|
||||
const roverLabel = roverSource?.label || roverSource?.id || 'a rover';
|
||||
return `${requesterLabel} driving ${roverLabel}`;
|
||||
}
|
||||
function createReplayCommand({
|
||||
logger,
|
||||
getMode,
|
||||
MODES,
|
||||
tryTriggerReplay,
|
||||
getReplaySources,
|
||||
getDefaultDiscordSources,
|
||||
validateSources,
|
||||
buildReplayVideo,
|
||||
sanitizeMentions,
|
||||
getActiveDrivers,
|
||||
getNickname,
|
||||
rovers,
|
||||
}) {
|
||||
const sourceResolver = createReplaySourceResolver({
|
||||
rovers,
|
||||
getReplaySources,
|
||||
getDefaultDiscordSources,
|
||||
validateSources,
|
||||
});
|
||||
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
|
||||
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
|
||||
|
||||
function buildReplayDriverLines(requester, sources = []) {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roverSources = (Array.isArray(sources) ? sources : []).filter((entry) => entry?.type === 'rover');
|
||||
const requestedRoverIds = new Set(roverSources.map((entry) => String(entry.id)));
|
||||
const lines = [];
|
||||
requestedRoverIds.forEach((roverId) => {
|
||||
const socketId = activeDrivers?.[roverId];
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || socketId;
|
||||
const record = rovers.get(roverId);
|
||||
const roverName = record?.meta?.name || record?.id || roverId;
|
||||
const isAuthor = String(nickname).toLowerCase() === String(requester || '').toLowerCase();
|
||||
lines.push(`${nickname} driving ${roverName}${isAuthor ? ' **author**' : ''}`);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildDriverCaption() {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roster = Array.from(rovers.values());
|
||||
if (!roster.length) return 'Drivers: no rovers online.';
|
||||
const entries = roster.map((record) => {
|
||||
const driverId = activeDrivers[record.id];
|
||||
if (!driverId) return `${record.meta?.name || record.id}: none`;
|
||||
const socket = io.sockets.sockets.get(driverId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
|
||||
return `${record.meta?.name || record.id}: ${nickname}`;
|
||||
});
|
||||
return `Drivers: ${entries.join(', ')}`;
|
||||
}
|
||||
|
||||
function buildReplayCaption({ requester, usedSources = [], missingSources = [], title }) {
|
||||
const lines = [];
|
||||
if (title) {
|
||||
lines.push(`**${title}**`);
|
||||
lines.push('');
|
||||
}
|
||||
const driverLines = buildReplayDriverLines(requester, usedSources);
|
||||
if (driverLines.length) lines.push(...driverLines);
|
||||
if (missingSources.length) {
|
||||
if (driverLines.length) lines.push('');
|
||||
lines.push(`Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}`);
|
||||
}
|
||||
if (!lines.length) lines.push(buildDriverCaption());
|
||||
return lines.join('\n');
|
||||
}
|
||||
function resolveReplaySources(query) {
|
||||
const cleaned = normalizeReplayQuery(query);
|
||||
if (!cleaned || cleaned === 'all' || cleaned === '*') return { sources: getDefaultDiscordSources() };
|
||||
const tokens = cleaned.split(',').map((token) => token.trim()).filter(Boolean);
|
||||
const all = getReplaySources();
|
||||
const matches = [];
|
||||
tokens.forEach((token) => {
|
||||
const [prefix, rest] = token.includes(':') ? token.split(':', 2) : [null, token];
|
||||
const candidate = all.find((entry) => (String(entry.id).toLowerCase() === rest || String(entry.label || '').toLowerCase() === rest) && (!prefix || entry.type === prefix));
|
||||
if (candidate) matches.push({ type: candidate.type, id: candidate.id, label: candidate.label });
|
||||
});
|
||||
const sources = validateSources(matches);
|
||||
return sources.length ? { sources } : { error: 'No matching sources found', matches: [] };
|
||||
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 message.reply({ content: 'Replay is disabled while the server is in lockdown.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
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 message.reply({ content: `Replay cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
const resolved = resolveReplaySources(query);
|
||||
if (resolved?.error) {
|
||||
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
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 title = buildDefaultReplayTitle(requester, resolved.sources || []);
|
||||
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');
|
||||
|
||||
try {
|
||||
const { buffer, usedSources = resolved.sources || [], missingSources = [] } = await buildReplayVideo({
|
||||
sources: resolved.sources || [],
|
||||
title,
|
||||
requester,
|
||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
|
||||
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
|
||||
sources: job.sources,
|
||||
title: job.title,
|
||||
requester: job.requester,
|
||||
includeSidebar: job.includeSidebar,
|
||||
});
|
||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(title)}.mp4` });
|
||||
const body = buildReplayCaption({ requester, usedSources, missingSources, title });
|
||||
await message.reply({ content: sanitizeMentions(body), files: [attachment], allowedMentions: { parse: [], repliedUser: false } });
|
||||
|
||||
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);
|
||||
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
|
||||
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
|
||||
|
||||
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
} catch (err) {
|
||||
await message.reply({ content: sanitizeMentions(`Replay failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,10 +182,12 @@ const commands = createCommandHandlers({
|
||||
isAdminUser,
|
||||
isLockdownAdminUser,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
fetchChannel: channelIO.fetchChannel,
|
||||
clearTypingMessage: channelIO.clearTypingMessage,
|
||||
sendTypingMessage: channelIO.sendTypingMessage,
|
||||
schedulePresenceRotation: presence.schedulePresenceRotation,
|
||||
buildReplayVideo,
|
||||
sanitizeMentions,
|
||||
});
|
||||
|
||||
const integrationHandlers = integrations.register();
|
||||
|
||||
@@ -4,11 +4,26 @@
|
||||
const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
|
||||
const io = require('../../../globals/io');
|
||||
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
|
||||
const {
|
||||
DEFAULT_ALLOWED_MENTIONS,
|
||||
createReplayJob,
|
||||
createJobStatusEmitter,
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
buildStatusMessage,
|
||||
normalizeUserError,
|
||||
} = require('../replayWorkflow');
|
||||
|
||||
function createBusEventHandler(deps) {
|
||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel, buildReplayVideo, getActiveDrivers, getNickname } = deps;
|
||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel, fetchChannel, buildReplayVideo, getActiveDrivers, getNickname, sanitizeMentions } = deps;
|
||||
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
||||
let skippedFirstModeAnnouncement = false;
|
||||
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
|
||||
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
|
||||
|
||||
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
||||
@@ -40,83 +55,48 @@ function createBusEventHandler(deps) {
|
||||
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId);
|
||||
}
|
||||
|
||||
function sanitizeReplayTitleForFilename(title) {
|
||||
const cleaned = String(title || '').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 96);
|
||||
return cleaned || 'replay';
|
||||
}
|
||||
|
||||
function buildDefaultReplayTitle(requester, sources = []) {
|
||||
const requesterLabel = String(requester || 'Someone').trim() || 'Someone';
|
||||
const roverSource = (Array.isArray(sources) ? sources : []).find((entry) => entry?.type === 'rover');
|
||||
const roverLabel = roverSource?.label || roverSource?.id || 'a rover';
|
||||
return `${requesterLabel} driving ${roverLabel}`;
|
||||
}
|
||||
|
||||
function buildReplayDriverLines(requester, sources = []) {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roverSources = (Array.isArray(sources) ? sources : []).filter((entry) => entry?.type === 'rover');
|
||||
const requestedRoverIds = new Set(roverSources.map((entry) => String(entry.id)));
|
||||
const lines = [];
|
||||
requestedRoverIds.forEach((roverId) => {
|
||||
const socketId = activeDrivers?.[roverId];
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || socketId;
|
||||
const record = rovers.get(roverId);
|
||||
const roverName = record?.meta?.name || record?.id || roverId;
|
||||
const isAuthor = String(nickname).toLowerCase() === String(requester || '').toLowerCase();
|
||||
lines.push(`${nickname} driving ${roverName}${isAuthor ? ' **author**' : ''}`);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildDriverCaption() {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roster = Array.from(rovers.values());
|
||||
if (!roster.length) return 'Drivers: no rovers online.';
|
||||
const entries = roster.map((record) => {
|
||||
const driverId = activeDrivers[record.id];
|
||||
if (!driverId) return `${record.meta?.name || record.id}: none`;
|
||||
const socket = io.sockets.sockets.get(driverId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
|
||||
return `${record.meta?.name || record.id}: ${nickname}`;
|
||||
});
|
||||
return `Drivers: ${entries.join(', ')}`;
|
||||
}
|
||||
|
||||
function buildReplayCaption({ requester, usedSources = [], missingSources = [], title }) {
|
||||
const lines = [];
|
||||
if (title) {
|
||||
lines.push(`**${title}**`);
|
||||
lines.push('');
|
||||
}
|
||||
const driverLines = buildReplayDriverLines(requester, usedSources);
|
||||
if (driverLines.length) lines.push(...driverLines);
|
||||
if (missingSources.length) {
|
||||
if (driverLines.length) lines.push('');
|
||||
lines.push(`Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}`);
|
||||
}
|
||||
if (!lines.length) lines.push(buildDriverCaption());
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true) {
|
||||
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true, jobId = null) {
|
||||
if (!channelId) throw new Error('Replay channel not configured');
|
||||
const resolvedTitle = String(explicitTitle || '').trim() || buildDefaultReplayTitle(requester, sources);
|
||||
const { buffer, usedSources = sources, missingSources = [] } = await buildReplayVideo({
|
||||
sources,
|
||||
title: resolvedTitle,
|
||||
const job = createReplayJob({
|
||||
id: jobId,
|
||||
requester,
|
||||
source: 'web',
|
||||
title: explicitTitle,
|
||||
sources,
|
||||
includeSidebar,
|
||||
});
|
||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(resolvedTitle)}.mp4` });
|
||||
const body = buildReplayCaption({
|
||||
requester,
|
||||
usedSources,
|
||||
missingSources,
|
||||
title: resolvedTitle,
|
||||
});
|
||||
await sendToChannel(channelId, body, { files: [attachment] }, { parse: [] });
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
const progressMessage = await sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
|
||||
const channel = await fetchChannel(channelId);
|
||||
const stopTyping = startDiscordTypingLoop(channel, logger, 'web replay delivery');
|
||||
try {
|
||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'building'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
|
||||
sources: job.sources,
|
||||
title: job.title,
|
||||
requester: job.requester,
|
||||
includeSidebar: job.includeSidebar,
|
||||
});
|
||||
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
|
||||
if (progressMessage?.edit) await progressMessage.edit({ content: 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 sendToChannel(channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
|
||||
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
|
||||
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
|
||||
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
||||
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
} catch (err) {
|
||||
const message = normalizeUserError(err);
|
||||
jobStatus.emit(job, 'failed', { message });
|
||||
if (progressMessage?.edit) await progressMessage.edit({ content: sanitizeMentions(message), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
throw err;
|
||||
} finally {
|
||||
stopTyping();
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplayRequested(event) {
|
||||
@@ -127,6 +107,7 @@ function createBusEventHandler(deps) {
|
||||
payload?.sources || [],
|
||||
payload?.title || '',
|
||||
payload?.includeSidebar !== false,
|
||||
payload?.jobId || null,
|
||||
).catch((err) => {
|
||||
logger.warn('Replay send failed', { error: err.message });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// Discord Replay Workflow
|
||||
// Purpose: Provides the shared replay job, Discord upload, fuzzy source lookup, and user-facing status helpers.
|
||||
// Scope: Keeps Discord-command and web-triggered replay delivery on the same status pipeline.
|
||||
const Fuse = require('fuse.js');
|
||||
|
||||
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
|
||||
const FUZZY_THRESHOLD = 0.42;
|
||||
const MAX_SUGGESTIONS = 4;
|
||||
|
||||
function nowTs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return normalizeText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function compactJoin(parts, separator = ', ') {
|
||||
return (Array.isArray(parts) ? parts : []).map(normalizeText).filter(Boolean).join(separator);
|
||||
}
|
||||
|
||||
function buildReplayJobId(prefix = 'replay') {
|
||||
// Replay rendering continues after the initial command/socket acknowledgement.
|
||||
// The id is deliberately short but unique enough for correlating UI status, Discord messages, and logs.
|
||||
const safePrefix = normalizeText(prefix).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24) || 'replay';
|
||||
return `${safePrefix}-${nowTs()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function sourceKey(source) {
|
||||
return `${source?.type}:${source?.id}`;
|
||||
}
|
||||
|
||||
function sourceName(source) {
|
||||
return normalizeText(source?.label) || normalizeText(source?.id) || 'unknown source';
|
||||
}
|
||||
|
||||
function describeSource(source) {
|
||||
const label = sourceName(source);
|
||||
if (source?.type === 'room') return `${label} room camera`;
|
||||
return label;
|
||||
}
|
||||
|
||||
function sanitizeReplayTitleForFilename(title) {
|
||||
// Discord attachment names should be readable and filesystem-safe.
|
||||
// The title may originate from chat/web input, so strip separators and cap length before upload.
|
||||
const cleaned = String(title || '')
|
||||
.replace(/[\\/:*?"<>|]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 96);
|
||||
return cleaned || 'replay';
|
||||
}
|
||||
|
||||
function buildReplayTitle({ explicitTitle = '', sources = [] } = {}) {
|
||||
const trimmed = normalizeText(explicitTitle).slice(0, 120);
|
||||
if (trimmed) return trimmed;
|
||||
|
||||
const list = Array.isArray(sources) ? sources : [];
|
||||
if (!list.length) return 'Replay';
|
||||
|
||||
const roomCount = list.filter((entry) => entry?.type === 'room').length;
|
||||
const roverCount = list.filter((entry) => entry?.type === 'rover').length;
|
||||
if (roomCount && !roverCount) return roomCount === 1 ? `Replay: ${sourceName(list[0])}` : 'Replay: Room Cameras';
|
||||
if (roverCount && !roomCount && list.length === 1) return `Replay: ${sourceName(list[0])}`;
|
||||
|
||||
// Multi-source titles identify the replay target without claiming the requester was driving.
|
||||
// Actual driver context is put in the caption where it can be based on live server state.
|
||||
const shown = list.slice(0, 2).map(sourceName);
|
||||
const remaining = list.length - shown.length;
|
||||
return `Replay: ${compactJoin(shown)}${remaining > 0 ? ` + ${remaining} more` : ''}`;
|
||||
}
|
||||
|
||||
function buildSourceSummary(sources = []) {
|
||||
const list = Array.isArray(sources) ? sources : [];
|
||||
if (!list.length) return 'no sources';
|
||||
return compactJoin(list.map(describeSource));
|
||||
}
|
||||
|
||||
function normalizeReplaySources(sources = []) {
|
||||
return (Array.isArray(sources) ? sources : [])
|
||||
.filter((source) => source?.type && source?.id != null)
|
||||
.map((source) => ({
|
||||
type: source.type,
|
||||
id: String(source.id),
|
||||
label: source.label || String(source.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeUserError(err) {
|
||||
const message = normalizeText(err?.message || err);
|
||||
if (!message) return 'Replay failed.';
|
||||
if (/No replay segments available/i.test(message)) return 'Replay failed: no recent video coverage was available for the selected source.';
|
||||
if (/No replay sources selected/i.test(message)) return 'Replay failed: no replay sources were selected.';
|
||||
if (/upload did not return/i.test(message)) return 'Replay failed: Discord did not confirm the video upload.';
|
||||
if (/attachment URL/i.test(message)) return 'Replay failed: Discord uploaded the message without a playable video URL.';
|
||||
if (/Replay channel not configured/i.test(message)) return 'Replay failed: the Discord replay channel is not configured.';
|
||||
return `Replay failed: ${message}`;
|
||||
}
|
||||
|
||||
function firstAttachmentFromMessage(message) {
|
||||
if (!message?.attachments) return null;
|
||||
if (typeof message.attachments.first === 'function') return message.attachments.first() || null;
|
||||
if (typeof message.attachments.values === 'function') return message.attachments.values().next().value || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDiscordReplayMediaPayload({ message, attachment, job }) {
|
||||
if (!attachment?.url) return null;
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: 'ready',
|
||||
title: job.title,
|
||||
requester: job.requester,
|
||||
url: attachment.url,
|
||||
proxyUrl: attachment.proxyURL || attachment.proxyUrl || null,
|
||||
messageUrl: message?.url || null,
|
||||
filename: attachment.name || attachment.filename || 'replay.mp4',
|
||||
size: Number.isFinite(attachment.size) ? attachment.size : null,
|
||||
contentType: attachment.contentType || null,
|
||||
discord: {
|
||||
channelId: message?.channelId || null,
|
||||
messageId: message?.id || null,
|
||||
attachmentId: attachment.id || null,
|
||||
},
|
||||
sources: normalizeReplaySources(job.sources),
|
||||
ts: nowTs(),
|
||||
};
|
||||
}
|
||||
|
||||
function createReplayJob({ id = null, requester = 'Discord', source = 'discord', title = '', sources = [], includeSidebar = true } = {}) {
|
||||
const normalizedSources = normalizeReplaySources(sources);
|
||||
const resolvedTitle = buildReplayTitle({ explicitTitle: title, sources: normalizedSources });
|
||||
return {
|
||||
id: id || buildReplayJobId(source),
|
||||
source,
|
||||
requester: normalizeText(requester) || 'Discord',
|
||||
title: resolvedTitle,
|
||||
sources: normalizedSources,
|
||||
includeSidebar: includeSidebar !== false,
|
||||
createdAt: nowTs(),
|
||||
};
|
||||
}
|
||||
|
||||
function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
|
||||
function buildPayload(job, status, extra = {}) {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status,
|
||||
title: job.title,
|
||||
requester: job.requester,
|
||||
sources: normalizeReplaySources(job.sources),
|
||||
message: extra.message ? sanitizeMentions(extra.message) : undefined,
|
||||
media: extra.media || undefined,
|
||||
ts: nowTs(),
|
||||
};
|
||||
}
|
||||
|
||||
function emit(job, status, extra = {}) {
|
||||
const payload = buildPayload(job, status, extra);
|
||||
io.emit('replay:status', payload);
|
||||
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
|
||||
if (status === 'failed') io.emit('replay:failed', payload);
|
||||
if (logger?.debug) logger.debug('Replay job status', { jobId: job.id, status });
|
||||
return payload;
|
||||
}
|
||||
|
||||
return { emit };
|
||||
}
|
||||
|
||||
function createReplaySourceResolver({ rovers, getReplaySources, getDefaultDiscordSources, validateSources }) {
|
||||
function buildAllowedCandidates() {
|
||||
return getReplaySources().map((source) => ({
|
||||
type: source.type,
|
||||
id: String(source.id),
|
||||
label: source.label || source.id,
|
||||
access: 'allowed',
|
||||
reason: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDeniedRoverCandidates(allowedKeys) {
|
||||
const denied = [];
|
||||
for (const record of rovers.values()) {
|
||||
const candidate = {
|
||||
type: 'rover',
|
||||
id: String(record.id),
|
||||
label: record.meta?.name || record.name || record.id,
|
||||
access: 'denied',
|
||||
reason: record.private?.enabled && !record.privateOpen
|
||||
? 'that rover is private right now'
|
||||
: 'that rover is not replayable from Discord right now',
|
||||
};
|
||||
if (!allowedKeys.has(sourceKey(candidate))) denied.push(candidate);
|
||||
}
|
||||
return denied;
|
||||
}
|
||||
|
||||
function buildCandidates() {
|
||||
const allowed = buildAllowedCandidates();
|
||||
const allowedKeys = new Set(allowed.map(sourceKey));
|
||||
return [...allowed, ...buildDeniedRoverCandidates(allowedKeys)].map((source) => ({
|
||||
...source,
|
||||
qualifiedId: `${source.type}:${source.id}`,
|
||||
searchLabel: normalizeSearchText(source.label),
|
||||
searchId: normalizeSearchText(source.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function createFuse(candidates) {
|
||||
return new Fuse(candidates, {
|
||||
includeScore: true,
|
||||
threshold: FUZZY_THRESHOLD,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'label', weight: 0.52 },
|
||||
{ name: 'id', weight: 0.26 },
|
||||
{ name: 'qualifiedId', weight: 0.18 },
|
||||
{ name: 'type', weight: 0.04 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function suggestions(candidates) {
|
||||
const allowed = candidates.filter((entry) => entry.access === 'allowed').slice(0, MAX_SUGGESTIONS);
|
||||
if (!allowed.length) return '';
|
||||
return ` Did you mean: ${compactJoin(allowed.map(describeSource))}?`;
|
||||
}
|
||||
|
||||
function findForToken(token, candidates, fuse) {
|
||||
const cleaned = normalizeSearchText(token);
|
||||
const [prefix, rest] = cleaned.includes(':') ? cleaned.split(':', 2) : [null, cleaned];
|
||||
const exact = candidates.find((entry) => (
|
||||
(!prefix || entry.type === prefix) &&
|
||||
(entry.searchId === rest || entry.searchLabel === rest || normalizeSearchText(entry.qualifiedId) === cleaned)
|
||||
));
|
||||
if (exact) return exact;
|
||||
return fuse.search(cleaned).map((entry) => entry.item).find((entry) => !prefix || entry.type === prefix) || null;
|
||||
}
|
||||
|
||||
function resolve(query) {
|
||||
const cleaned = normalizeSearchText(query);
|
||||
if (!cleaned || cleaned === 'all' || cleaned === '*') {
|
||||
const sources = getDefaultDiscordSources();
|
||||
return sources.length
|
||||
? { sources: normalizeReplaySources(sources), defaulted: true }
|
||||
: { error: 'Replay denied: no default Discord replay sources are available.' };
|
||||
}
|
||||
|
||||
const candidates = buildCandidates();
|
||||
const fuse = createFuse(candidates);
|
||||
const selected = [];
|
||||
const denied = [];
|
||||
const unmatched = [];
|
||||
normalizeText(query).split(',').map((token) => token.trim()).filter(Boolean).forEach((token) => {
|
||||
const match = findForToken(token, candidates, fuse);
|
||||
if (!match) {
|
||||
unmatched.push(token);
|
||||
} else if (match.access !== 'allowed') {
|
||||
denied.push(match);
|
||||
} else {
|
||||
selected.push({ type: match.type, id: match.id, label: match.label });
|
||||
}
|
||||
});
|
||||
|
||||
if (denied.length) {
|
||||
const deniedText = compactJoin(denied.map((entry) => `${describeSource(entry)} (${entry.reason})`));
|
||||
return { error: `Replay denied: ${deniedText}.` };
|
||||
}
|
||||
if (unmatched.length) return { error: `No replay source matched "${unmatched[0]}".${suggestions(candidates)}` };
|
||||
|
||||
const sources = validateSources(selected);
|
||||
return sources.length
|
||||
? { sources: normalizeReplaySources(sources), defaulted: false }
|
||||
: { error: `No replay source matched "${normalizeText(query)}".${suggestions(candidates)}` };
|
||||
}
|
||||
|
||||
return { resolve };
|
||||
}
|
||||
|
||||
function createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions }) {
|
||||
function buildReplayDriverLines(requester, sources = []) {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roverSources = normalizeReplaySources(sources).filter((entry) => entry.type === 'rover');
|
||||
const requestedRoverIds = new Set(roverSources.map((entry) => String(entry.id)));
|
||||
const lines = [];
|
||||
requestedRoverIds.forEach((roverId) => {
|
||||
const socketId = activeDrivers?.[roverId];
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || socketId;
|
||||
const record = rovers.get(roverId);
|
||||
const roverName = record?.meta?.name || record?.id || roverId;
|
||||
const isAuthor = normalizeSearchText(nickname) === normalizeSearchText(requester);
|
||||
lines.push(`${nickname} was driving ${roverName}${isAuthor ? ' when they requested this' : ''}`);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildDriverCaption() {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roster = Array.from(rovers.values());
|
||||
if (!roster.length) return 'Drivers: no rovers online.';
|
||||
const entries = roster.map((record) => {
|
||||
const driverId = activeDrivers[record.id];
|
||||
if (!driverId) return `${record.meta?.name || record.id}: none`;
|
||||
const socket = io.sockets.sockets.get(driverId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
|
||||
return `${record.meta?.name || record.id}: ${nickname}`;
|
||||
});
|
||||
return `Drivers: ${entries.join(', ')}`;
|
||||
}
|
||||
|
||||
function formatMissingSource(source) {
|
||||
const label = source?.label || `${source?.type}:${source?.id}`;
|
||||
return source?.reason ? `${label} (${source.reason})` : label;
|
||||
}
|
||||
|
||||
function build({ job, usedSources = [], missingSources = [] }) {
|
||||
const lines = [`**${job.title}**`, '', `Requested by ${job.requester}.`];
|
||||
const driverLines = buildReplayDriverLines(job.requester, usedSources);
|
||||
if (driverLines.length) {
|
||||
lines.push('', ...driverLines);
|
||||
} else {
|
||||
lines.push(buildDriverCaption());
|
||||
}
|
||||
if (missingSources.length) {
|
||||
lines.push('', `Missing: ${missingSources.map(formatMissingSource).join(', ')}`);
|
||||
}
|
||||
return sanitizeMentions(lines.join('\n'));
|
||||
}
|
||||
|
||||
return { build };
|
||||
}
|
||||
|
||||
function startDiscordTypingLoop(target, logger, label = 'replay') {
|
||||
let stopped = false;
|
||||
let intervalId = null;
|
||||
async function sendTyping() {
|
||||
if (stopped || typeof target?.sendTyping !== 'function') return;
|
||||
try {
|
||||
await target.sendTyping();
|
||||
} catch (err) {
|
||||
if (logger?.warn) logger.warn('Failed to send Discord typing indicator', { label, error: err.message });
|
||||
}
|
||||
}
|
||||
// Discord typing indicators expire quickly, so refresh while ffmpeg and upload work is active.
|
||||
sendTyping();
|
||||
intervalId = setInterval(sendTyping, 6000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}
|
||||
|
||||
function buildAcceptedMessage(job) {
|
||||
return `Replay accepted. Building **${job.title}** from ${buildSourceSummary(job.sources)}.`;
|
||||
}
|
||||
|
||||
function buildStatusMessage(job, status) {
|
||||
if (status === 'building') return `Replay building: **${job.title}**`;
|
||||
if (status === 'uploading') return `Replay uploading to Discord: **${job.title}**`;
|
||||
if (status === 'ready') return `Replay ready: **${job.title}**`;
|
||||
return `Replay ${status}: **${job.title}**`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_ALLOWED_MENTIONS,
|
||||
buildReplayJobId,
|
||||
createReplayJob,
|
||||
createJobStatusEmitter,
|
||||
createReplaySourceResolver,
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
buildStatusMessage,
|
||||
normalizeUserError,
|
||||
buildReplayTitle,
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const { publishEvent } = require('../eventBus');
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { buildReplayJobId, buildReplayTitle } = require('../discordBotService/replayWorkflow');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
@@ -52,6 +53,7 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
||||
const requester = buildRequesterLabel(socket);
|
||||
const title = normalizeReplayTitle(payload?.title);
|
||||
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
|
||||
const jobId = buildReplayJobId('web');
|
||||
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
||||
if (!attempt.ok) {
|
||||
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
|
||||
@@ -62,6 +64,7 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
||||
source: 'replaySocket',
|
||||
type: 'replay.requested',
|
||||
payload: {
|
||||
jobId,
|
||||
channelId,
|
||||
requester,
|
||||
title,
|
||||
@@ -70,8 +73,14 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
||||
requestedBy: { socketId: socket.id },
|
||||
},
|
||||
});
|
||||
logger.info('Replay requested via web', { socketId: socket.id });
|
||||
cb({ success: true, state: attempt.state });
|
||||
logger.info('Replay requested via web', { socketId: socket.id, jobId });
|
||||
cb({
|
||||
success: true,
|
||||
jobId,
|
||||
status: 'accepted',
|
||||
title: buildReplayTitle({ explicitTitle: title, sources }),
|
||||
state: attempt.state,
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('replay:trigger', handleReplayTrigger);
|
||||
|
||||
Reference in New Issue
Block a user