This commit is contained in:
legop3
2026-07-15 00:35:39 -04:00
parent 96d06091ee
commit 3a8a2ebb13
34 changed files with 1271 additions and 372 deletions
@@ -0,0 +1,109 @@
// Replay Delivery Service
// Purpose: Builds each web-requested replay once and chooses Discord or automatic local hosting.
// Scope: Keeps replay generation functional even when the optional Discord feature is disabled or unhealthy.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('replayDelivery');
const { subscribe } = require('../eventBus');
const { buildReplayVideo } = require('../replayEngineV2');
const { hostReplay } = require('../replayMediaService');
const {
createReplayJob,
createJobStatusEmitter,
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
} = require('./workflow');
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions: (value) => String(value || '') });
let preferredDeliveryProvider = null;
function registerPreferredDeliveryProvider(provider) {
preferredDeliveryProvider = provider && typeof provider.deliver === 'function' ? provider : null;
return () => {
if (preferredDeliveryProvider === provider) preferredDeliveryProvider = null;
};
}
async function deliverReplay(payload = {}) {
const job = createReplayJob({
id: payload.jobId,
requester: payload.requester,
source: 'web',
title: payload.title,
sources: payload.sources,
includeSidebar: payload.includeSidebar,
requestedBy: payload.requestedBy,
});
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
let providerContext = null;
try {
let providerError = null;
if (preferredDeliveryProvider?.begin) {
try {
providerContext = await preferredDeliveryProvider.begin(job);
} catch (err) {
providerError = err;
logger.warn('Preferred replay delivery could not start; using hosted media', { jobId: job.id, error: err.message });
}
}
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (providerContext?.progressMessage?.edit) {
await providerContext.progressMessage.edit({ content: buildStatusMessage(job, 'building'), allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
}
const built = await buildReplayVideo({
sources: job.sources,
title: job.title,
requester: job.requester,
includeSidebar: job.includeSidebar,
});
let media = null;
if (preferredDeliveryProvider && !providerError) {
try {
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
media = await preferredDeliveryProvider.deliver({ job, context: providerContext, ...built });
} catch (err) {
providerError = err;
if (!providerError.progressMessage && providerContext?.progressMessage) providerError.progressMessage = providerContext.progressMessage;
logger.warn('Preferred replay delivery failed; using hosted media', { jobId: job.id, error: err.message });
}
}
if (!media) media = await hostReplay({ buffer: built.buffer, job });
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
if (providerError && preferredDeliveryProvider?.completeFallback) {
await preferredDeliveryProvider.completeFallback({ job, context: providerContext, media }).catch((err) => {
logger.warn('Unable to announce hosted replay fallback', { jobId: job.id, error: err.message });
});
}
if (providerContext?.stopTyping) providerContext.stopTyping();
// A Discord progress message may already exist when upload fails. Let the
// provider attach it to the error so fallback can finish that outward UI
// instead of leaving a permanent "uploading" message in the channel.
if (providerError?.progressMessage?.edit) {
await providerError.progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
}
return media;
} catch (err) {
if (providerContext?.stopTyping) providerContext.stopTyping();
const message = normalizeUserError(err);
jobStatus.emit(job, 'failed', { message });
if (providerContext?.progressMessage?.edit) {
await providerContext.progressMessage.edit({ content: message, allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
}
throw err;
}
}
subscribe('replay.requested', (event) => {
deliverReplay(event?.payload || {}).catch((err) => {
logger.warn('Replay delivery failed', { error: err.message });
});
});
module.exports = {
deliverReplay,
registerPreferredDeliveryProvider,
};
@@ -0,0 +1,388 @@
// Replay Delivery Workflow
// 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 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,
requestedBy: job.requestedBy || null,
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, requestedBy = null } = {}) {
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,
requestedBy: requestedBy && typeof requestedBy === 'object' ? { ...requestedBy } : null,
createdAt: nowTs(),
};
}
function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
function buildPayload(job, status, extra = {}) {
return {
jobId: job.id,
status,
title: job.title,
requester: job.requester,
requestedBy: job.requestedBy || null,
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,
};