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
+23 -20
View File
@@ -10,6 +10,9 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const { isFeatureEnabled } = require('../../helpers/features');
const {
listVerifiedUsers,
removeVerifiedUser,
@@ -20,20 +23,21 @@ const {
const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService');
const { loadConfig } = require('../../helpers/configLoader');
const { createCommandHandlers } = require('../discordBotService/commands');
const { createCommandHandlers } = require('../operatorCommandService');
const { parseCommandText } = require('../operatorCommandService/config');
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
const { commandReplyToText } = require('./commandResultFormatter');
const {
buildReplayJobId,
buildReplayTitle,
createReplaySourceResolver,
} = require('../discordBotService/replayWorkflow');
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
const discordConfig = config.discord || {};
function isTextCommand(text) {
const clean = String(text || '').trim();
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
return parseCommandText(text, config).matched;
}
function sanitizeMentions(text) {
@@ -70,12 +74,6 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
return;
}
const channelId = discordConfig?.channels?.replay || null;
if (!channelId) {
await message.reply({ content: 'Replay denied: replay channel is not configured.' });
return;
}
const resolved = sourceResolver.resolve(query);
if (resolved?.error) {
await message.reply({ content: resolved.error });
@@ -99,7 +97,6 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
type: 'replay.requested',
payload: {
jobId,
channelId,
requester,
title: '',
includeSidebar: true,
@@ -113,18 +110,18 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
};
}
function createChatCommandMessage({ socket, text, sendSystemMessage }) {
function createChatCommandRequest({ socket, text, sendSystemMessage }) {
const nickname = buildRequesterLabel(socket);
return {
content: String(text || '').trim(),
author: {
actor: {
bot: false,
id: socket.id,
username: nickname,
},
member: {
nickname,
label: nickname,
isAdmin: isAdmin(socket),
isLockdownAdmin: isLockdownAdmin(socket),
},
transport: 'web-chat',
reply: async (payload) => {
const response = sanitizeMentions(commandReplyToText(payload));
if (!response) return null;
@@ -139,8 +136,8 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// keeps ordinary chatService initialization from changing the service boot
// order, while still letting `rs replay` use the existing replay pipeline.
const replayApi = require('../replayEngineV2');
const message = createChatCommandMessage({ socket, text, sendSystemMessage });
const commands = createCommandHandlers({
const message = createChatCommandRequest({ socket, text, sendSystemMessage });
const commandDependencies = {
logger: null,
client: null,
io,
@@ -168,6 +165,9 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// lights lock/unlock` from becoming transport-specific, and it preserves
// the existing session update path for all connected browsers.
homeAssistantService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig: () => null,
setGuildConfig: () => null,
removeGuildConfig: () => null,
@@ -183,9 +183,12 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
discordConfig,
siteUrl: String(discordConfig.siteUrl || ''),
config,
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
});
};
commandDependencies.transportHandlers = createWebTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
// Let the shared router perform normal command permission checks. Site chat
// has already broadcast the user's command text, so command replies become a
@@ -0,0 +1,39 @@
// Discord Command Adapter
// Purpose: Supplies Discord-specific renderers and extension commands to the operator command service.
// Scope: Keeps Discord embeds, attachments, guild permissions, and bridge context outside the shared command core.
const { createStatusCommand } = require('./commands/status');
const { createReplayCommand } = require('./commands/replay');
const { createBridgeCommand } = require('./commands/bridge');
const { createTimeStatusCommand } = require('./commands/timeStatus');
function createDiscordTransportHandlers(deps) {
const status = createStatusCommand(deps);
const replay = createReplayCommand(deps);
const bridge = createBridgeCommand(deps);
const timeStatus = createTimeStatusCommand(deps);
return {
status: (request, query) => status(request.context.discordMessage, query),
replay: (request, query) => replay(request.context.discordMessage, query),
bridge: (request, tokens) => bridge(request.context.discordMessage, tokens),
timeStatus: (request) => timeStatus(request.context.discordMessage),
};
}
function createDiscordCommandRequest(message, { isAdminUser, isLockdownAdminUser }) {
const id = message.author?.id || null;
return {
content: String(message.content || ''),
transport: 'discord',
actor: {
id,
label: message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord',
bot: Boolean(message.author?.bot),
isAdmin: isAdminUser(id),
isLockdownAdmin: isLockdownAdminUser(id),
},
reply: (payload) => message.reply(payload),
context: { discordMessage: message },
};
}
module.exports = { createDiscordTransportHandlers, createDiscordCommandRequest };
@@ -2,11 +2,12 @@
// Purpose: Handles chat bridge configuration/status commands per guild.
// Scope: Manages bridge channel, mode, and webhook provisioning.
const { PermissionsBitField } = require('discord.js');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser, discordConfig }) {
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser, config }) {
// Error text should name the active prefix because bridge setup is one of the
// first commands an admin runs when a bot instance joins a shared Discord.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
const { prefix: commandPrefix } = getCommandConfig(config);
function canManageBridge(message) {
if (isAdminUser(message.author.id)) return true;
if (!message.guild || !message.member) return false;
@@ -1,32 +0,0 @@
// Discord Help Command
// Purpose: Provides help text for rover bot Discord commands.
// Scope: Returns usage text with the configured command names for this bot instance.
function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts' } = {}) {
const prefix = String(commandPrefix || 'rs').trim() || 'rs';
const timeCommand = timeStatusCommand ? String(timeStatusCommand).trim() : '';
return [
'**Rover Bot Commands**',
`\`${prefix} help\` — show this help`,
`\`${prefix} status [rover]\` — show rover status; rover names can be fuzzy`,
`\`${prefix} replay [sources]\` — send instant replay; source names can be fuzzy`,
`\`${prefix} bridge\` — show chat bridge status for this server`,
`\`${prefix} bridge here <global|private>\` — set chat bridge to this channel`,
`\`${prefix} bridge mode <global|private>\` — change chat bridge mode`,
`\`${prefix} bridge off\` — disable chat bridge for this server`,
`\`${prefix} lights <status|lock|unlock>\` — show or change room light lock state`,
`\`${prefix} kick <user> [reason]\` — remove a user from their current rover; use \`user | reason\` for multi-word names`,
`\`${prefix} lock <rover>\` — lock a rover; rover names can be fuzzy`,
`\`${prefix} unlock <rover>\` — unlock a rover; rover names can be fuzzy`,
`\`${prefix} mode <open|turns|admin|lockdown>\` — change server mode`,
`\`${prefix} reason [text|clear]\` — show or set admin mode reason`,
`\`${prefix} goal [text|clear]\` — show or set global objective`,
`\`${prefix} verify list\` — list verified users (lockdown admins)`,
`\`${prefix} verify remove <cookieUserId|nickname>\` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)`,
`\`${prefix} deter list\` — list deterred users (lockdown admins)`,
`\`${prefix} deter ban <cookieUserId|nickname|ip>\` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)`,
`\`${prefix} deter unban <id|cookieUserId|nickname|ip>\` — remove deterrence; nicknames can be fuzzy or multi-word (lockdown admins)`,
timeCommand ? `\`${timeCommand}\` — show time status` : '',
].filter(Boolean).join('\n');
}
module.exports = { formatHelp };
@@ -1,141 +0,0 @@
// Discord Commands Router
// Purpose: Routes incoming Discord command messages to one-file-per-command handlers.
// Scope: Central command dispatcher and permission gate orchestration.
const { formatHelp } = require('./help');
const { createStatusCommand } = require('./status');
const { createReplayCommand } = require('./replay');
const { createLockCommand } = require('./lock');
const { createModeCommand } = require('./mode');
const { createReasonCommand } = require('./reason');
const { createGoalCommand } = require('./goal');
const { createVerifyCommand } = require('./verify');
const { createDeterCommand } = require('./deter');
const { createBridgeCommand } = require('./bridge');
const { createTimeStatusCommand } = require('./timeStatus');
const { createLightsCommand } = require('./lights');
const { createKickCommand } = require('./kick');
function createCommandHandlers(deps) {
const {
getMode,
MODES,
isAdminUser,
isLockdownAdminUser,
} = deps;
// Each running rover server can bring its own Discord bot into the same
// guild, so the primary command prefix must come from config instead of
// being hard-coded globally. The fallback preserves existing installs.
const commandPrefix = String(deps.discordConfig?.commandPrefix || 'rs').trim() || 'rs';
// The legacy time command is a bare word rather than a prefixed command. It
// therefore needs its own configurable value, and `null` intentionally
// disables it so multiple bots do not all answer `ts` in the same channel.
const timeStatusCommand = deps.discordConfig?.timeStatusCommand === null
? ''
: String(deps.discordConfig?.timeStatusCommand || 'ts').trim();
// Lowercase cached copies avoid re-normalizing every message and keep command
// matching case-insensitive without changing the original configured text
// that is shown in help output.
const normalizedCommandPrefix = commandPrefix.toLowerCase();
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
const handleStatusCommand = createStatusCommand(deps);
const handleReplayCommand = deps.createReplayTextCommand
? deps.createReplayTextCommand(deps)
: createReplayCommand(deps);
const handleLockCommand = createLockCommand(deps);
const handleModeCommand = createModeCommand(deps);
const handleReasonCommand = createReasonCommand(deps);
const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps);
const handleBridgeCommand = createBridgeCommand(deps);
const handleTimeStatusCommand = createTimeStatusCommand(deps);
const handleLightsCommand = createLightsCommand(deps);
const handleKickCommand = createKickCommand(deps);
function stripCommandPrefix(content) {
const trimmed = String(content || '').trim();
const lower = trimmed.toLowerCase();
if (!lower.startsWith(normalizedCommandPrefix)) return null;
const nextCharacter = trimmed.charAt(commandPrefix.length);
// Prefixes are matched as whole command tokens so an instance using `rs`
// still ignores ordinary words such as `rsvp`. This mirrors the old regex
// behavior while letting each Discord bot instance use its own prefix.
if (nextCharacter && !/\s/.test(nextCharacter)) return null;
return trimmed.slice(commandPrefix.length).trim();
}
async function handleCommand(message) {
if (message.author.bot) return;
const content = (message.content || '').trim();
const lower = content.toLowerCase();
// Commands are intentionally matched as whole prefixes. The previous
// startsWith checks made ordinary messages such as "rsvp" or "tshirt" look
// like commands, which is especially bad now that web chat will run the
// same server-side dispatcher before broadcasting user text.
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return handleTimeStatusCommand(message);
const commandBody = stripCommandPrefix(content);
if (commandBody === null) return;
const tokens = commandBody ? commandBody.split(/\s+/) : [];
const action = (tokens.shift() || '').toLowerCase();
const rest = tokens.join(' ').trim();
const isAdmin = isAdminUser(message.author.id);
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
const mode = getMode();
// Actions in this set can change operational safety or access policy, so
// lockdown mode narrows them from normal admins to lockdown admins. Room
// light locking belongs here because it can force the physical room lights
// on and disables ordinary Home Assistant room controls for everyone else.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick']);
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
await message.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
switch (action) {
case '':
case 'status':
return handleStatusCommand(message, rest);
case 'help':
return message.reply(formatHelp({ commandPrefix, timeStatusCommand }));
case 'replay':
return handleReplayCommand(message, tokens.join(' '));
case 'bridge':
return handleBridgeCommand(message, tokens);
case 'lights':
return handleLightsCommand(message, tokens);
case 'kick':
return handleKickCommand(message, rest);
case 'lock':
return handleLockCommand(message, rest, true);
case 'unlock':
return handleLockCommand(message, rest, false);
case 'mode':
return handleModeCommand(message, tokens);
case 'goal':
return handleGoalCommand(message, tokens);
case 'reason':
return handleReasonCommand(message, tokens);
case 'verify':
return handleVerifyCommand(message, tokens);
case 'deter':
return handleDeterCommand(message, tokens);
default:
return message.reply(formatHelp({ commandPrefix, timeStatusCommand }));
}
}
return { handleCommand };
}
module.exports = { createCommandHandlers };
@@ -3,6 +3,7 @@
// 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,
@@ -17,7 +18,7 @@ const {
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
} = require('../replayWorkflow');
} = require('../../replayDeliveryService/workflow');
function createReplayCommand({
logger,
@@ -32,6 +33,7 @@ function createReplayCommand({
getActiveDrivers,
getNickname,
rovers,
discordConfig,
}) {
const sourceResolver = createReplaySourceResolver({
rovers,
@@ -80,18 +82,21 @@ function createReplayCommand({
});
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 });
}
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
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) {
@@ -108,14 +113,36 @@ function createReplayCommand({
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');
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 });
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) {
@@ -3,7 +3,7 @@
// Scope: Builds and sends rover status embed for one rover or all visible rovers.
const { EmbedBuilder } = require('discord.js');
const { buildBatteryStatusEmbed } = require('../batteryEmbeds');
const { resolveRoverSelector } = require('./resolvers');
const { resolveRoverSelector } = require('../../operatorCommandService/commands/resolvers');
function createStatusCommand({ rovers, roverManager }) {
return async function handleStatusCommand(message, roverId) {
+97 -22
View File
@@ -5,10 +5,13 @@ const {
Client,
GatewayIntentBits,
Partials,
AttachmentBuilder,
} = require('discord.js');
const logger = require('../../globals/logger').child('discordBot');
const io = require('../../globals/io');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { parseCommandText } = require('../operatorCommandService/config');
const roverManager = require('../roverManager');
const { getRoster, lockRover, rovers } = roverManager;
const { MODES, getMode, setMode } = require('../modeManager');
@@ -20,6 +23,8 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const {
getGuildConfig,
listGuildConfigs,
@@ -48,26 +53,32 @@ const {
const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('./commands');
const { createCommandHandlers } = require('../operatorCommandService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations');
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
const {
DEFAULT_ALLOWED_MENTIONS,
createReplayCaptionBuilder,
startDiscordTypingLoop,
sanitizeReplayTitleForFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.token);
const enabled = isFeatureEnabled('discord');
// These normalized command names mirror the command router. Bridge-channel
// command replies are mirrored into web chat, so this entrypoint needs to know
// the configured command names before it wraps message.reply.
const commandPrefix = String(discordConfig.commandPrefix || 'rs').trim() || 'rs';
const timeStatusCommand = discordConfig.timeStatusCommand === null
? ''
: String(discordConfig.timeStatusCommand || 'ts').trim();
const normalizedCommandPrefix = commandPrefix.toLowerCase();
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean));
const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean));
if (!enabled) {
logger.info('Discord bot disabled; missing token in config.discord.token');
logger.info('Discord feature disabled or missing required token');
return;
}
@@ -123,7 +134,72 @@ const presence = createPresenceManager({
countReady,
});
const commands = createCommandHandlers({
const replayCaption = createReplayCaptionBuilder({
io,
rovers,
getActiveDrivers,
getNickname,
sanitizeMentions,
});
// Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails.
if (discordConfig?.channels?.replay) {
registerPreferredDeliveryProvider({
async begin(job) {
const channelId = discordConfig.channels.replay;
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId);
return {
channelId,
progressMessage,
stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'),
};
},
async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) {
const progressMessage = context?.progressMessage;
try {
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 channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
if (!uploadMessage) throw new Error('Discord upload did not return a message');
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
if (progressMessage?.edit) {
// The attachment URL is already durable once Discord returns it. A
// cosmetic progress-edit failure must not trigger a duplicate local
// replay or replace the successful media payload sent to clients.
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
});
}
return media;
} catch (err) {
err.progressMessage = progressMessage;
throw err;
} finally {
if (context?.stopTyping) context.stopTyping();
}
},
async completeFallback({ context, media }) {
const siteUrl = String(discordConfig.siteUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
if (context?.progressMessage?.reply) {
await context.progressMessage.reply({
content: `Replay hosted by the rover server: ${publicUrl}`,
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
}
},
});
}
const commandDependencies = {
logger,
client,
io,
@@ -151,6 +227,9 @@ const commands = createCommandHandlers({
// service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig,
setGuildConfig,
removeGuildConfig,
@@ -167,7 +246,9 @@ const commands = createCommandHandlers({
isLockdownAdminUser,
discordConfig,
config,
});
};
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
const integrations = createIntegrations({
logger,
@@ -209,16 +290,9 @@ const commands = createCommandHandlers({
const integrationHandlers = integrations.register();
function isTextCommand(content) {
const clean = String(content || '').trim();
const lower = clean.toLowerCase();
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return true;
if (!lower.startsWith(normalizedCommandPrefix)) return false;
const nextCharacter = clean.charAt(commandPrefix.length);
// Mirrored bridge commands must use the exact same whole-token prefix rule
// as the command router. If this check is looser than the router, normal
// bridge chat can be wrapped as a command reply even though no command runs.
return !nextCharacter || /\s/.test(nextCharacter);
// Both transports share this parser so command detection cannot drift from
// the dispatcher when an installation changes its prefix.
return parseCommandText(content, config).matched;
}
function isBridgeChannelMessage(message) {
@@ -263,7 +337,8 @@ function createBridgeMirroredCommandMessage(message) {
client.on('messageCreate', async (message) => {
try {
await integrationHandlers.handleBridgeInbound(message);
await commands.handleCommand(createBridgeMirroredCommandMessage(message));
const commandMessage = createBridgeMirroredCommandMessage(message);
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
@@ -2,28 +2,12 @@
// Purpose: Handles event-bus announcements to Discord channels.
// Scope: Processes supported event types and posts formatted messages/embeds.
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, fetchChannel, buildReplayVideo, getActiveDrivers, getNickname, sanitizeMentions } = deps;
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = 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);
@@ -55,66 +39,6 @@ function createBusEventHandler(deps) {
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId);
}
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true, jobId = null, requestedBy = null) {
if (!channelId) throw new Error('Replay channel not configured');
const job = createReplayJob({
id: jobId,
requester,
source: 'web',
title: explicitTitle,
sources,
includeSidebar,
requestedBy,
});
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) {
const payload = event?.payload || {};
sendReplayToChannel(
payload?.channelId,
payload?.requester,
payload?.sources || [],
payload?.title || '',
payload?.includeSidebar !== false,
payload?.jobId || null,
payload?.requestedBy || null,
).catch((err) => {
logger.warn('Replay send failed', { error: err.message });
});
}
function handleBusEvent(event) {
const { type, payload } = event || {};
const channels = discordConfig.channels || {};
@@ -200,9 +124,9 @@ function createBusEventHandler(deps) {
});
break;
}
case 'replay.requested':
handleReplayRequested(event);
break;
// Replay requests are deliberately consumed by replayDeliveryService.
// Discord registers only a preferred delivery provider, allowing the
// same request to fall back locally without a second event subscriber.
case 'buttonBox.discordStalkerPing': {
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
announce({
@@ -234,7 +158,7 @@ function createBusEventHandler(deps) {
}
}
return { handleBusEvent, handleReplayRequested };
return { handleBusEvent };
}
module.exports = { createBusEventHandler };
@@ -1,16 +1,15 @@
// Discord Deter Command
// Operator Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions, discordConfig }) {
// Moderation errors often get copied into Discord chat, so they should show
// the configured bot prefix instead of the legacy default when several bots
// are present in the same server.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, sanitizeMentions, config }) {
// Moderation usage errors use the same core prefix shown by organized help.
const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleDeterCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
if (!message.actor?.isLockdownAdmin) {
await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
@@ -33,7 +32,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
// full remaining text is now always the selector, which lets lockdown
// admins deter multi-word nicknames without quoting or delimiter rules.
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
const deterred = deterUser(stableSelector, { actor: message.author?.id || null });
const deterred = deterUser(stableSelector, { actor: message.actor?.id || null });
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -45,7 +44,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
try {
const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true });
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
const removed = undeterUser(resolved.record.id || resolved.record.cookieUserId || selector, message.author?.id || null);
const removed = undeterUser(resolved.record.id || resolved.record.cookieUserId || selector, message.actor?.id || null);
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -1,7 +1,7 @@
// Discord Goal Command
// Operator Goal Command
// Purpose: Handles global objective view/update/clear operations.
// Scope: Allows read by all and write by admins.
function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobalObjective, isAdminUser, sanitizeMentions }) {
function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobalObjective, sanitizeMentions }) {
return async function handleGoalCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
@@ -10,16 +10,16 @@ function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobal
await message.reply({ content: goal?.text ? `Global objective: ${sanitizeMentions(goal.text)}` : 'No global objective set.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (!isAdminUser(message.author.id)) {
if (!message.actor?.isAdmin) {
await message.reply({ content: 'Only admins can update the global objective.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
try {
if (lower === 'clear') {
clearGlobalObjective({ by: message.author?.id || null });
clearGlobalObjective({ by: message.actor?.id || null });
await message.reply({ content: 'Global objective cleared.', allowedMentions: { parse: [], repliedUser: false } });
} else {
setGlobalObjective(query, { by: message.author?.id || null });
setGlobalObjective(query, { by: message.actor?.id || null });
await message.reply({ content: sanitizeMentions(`Global objective set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
}
} catch (err) {
@@ -1,7 +1,8 @@
// Discord Kick Command
// Operator Kick Command
// Purpose: Removes a connected user from their current rover without applying any persistent moderation state.
// Scope: Resolves an online driver, sends them a UI-visible reason, and releases their current rover assignment.
const Fuse = require('fuse.js');
const { getCommandConfig } = require('../../operatorCommandService/config');
const DEFAULT_KICK_REASON = 'Removed from rover by admin.';
@@ -97,11 +98,11 @@ function resolveKickTarget(selector, candidates, commandPrefix = 'rs') {
return { target: first.item };
}
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, discordConfig }) {
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, config }) {
// The kick parser itself does not need the prefix, but its validation message
// does. Keeping this local avoids passing display-only config through the
// lower-level fuzzy target resolver except when an error string is needed.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleKickCommand(message, rawText) {
const { selector, reason } = splitSelectorAndReason(rawText);
const assignmentService = require('../../assignmentService');
@@ -127,7 +128,7 @@ function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, di
title: 'Removed by admin',
message: removalReason,
reasonCode: 'admin-kick',
actor: message.author?.id || null,
actor: message.actor?.id || null,
});
await message.reply({
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
@@ -0,0 +1,27 @@
// Lift Feature Command
// Purpose: Exposes lift state and movement through the shared text command route.
// Scope: Delegates interlocks, cooldowns, Home Assistant access, and runtime safety to liftService.
function describeState(state = {}) {
const position = state.position || 'unknown';
const connection = state.connected ? 'connected' : 'offline';
const activity = state.busy ? `moving ${state.target || ''}`.trim() : 'idle';
return `Lift: ${connection}; position ${position}; ${activity}.`;
}
function createLiftCommand({ liftService, sanitizeMentions }) {
return async function handleLiftCommand(message, tokens = []) {
const action = String(tokens.shift() || 'status').toLowerCase();
if (action === 'status') return message.reply({ content: describeState(liftService.getState()) });
try {
if (action === 'up') await liftService.moveUp(`command:${message.actor?.id || 'unknown'}`);
else if (action === 'down') await liftService.moveDown(`command:${message.actor?.id || 'unknown'}`);
else return message.reply({ content: 'Invalid lift command. Use `lift status`, `lift up`, or `lift down`.' });
return message.reply({ content: `Lift moving ${action}.` });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Lift command failed: ${err.message}`) });
}
};
}
module.exports = { createLiftCommand };
@@ -1,6 +1,8 @@
// Discord Lights Command
// Operator Lights Command
// Purpose: Handles admin room-light lock policy commands from Discord and web chat.
// Scope: Delegates all actual Home Assistant policy behavior to homeAssistantService.
const { getCommandConfig } = require('../../operatorCommandService/config');
function describeLightPolicy(lightPolicy = {}) {
// The HA service exposes both the newer explicit lockState and the older
// lockedOn boolean. Prefer lockState because it can distinguish locked-on
@@ -12,10 +14,10 @@ function describeLightPolicy(lightPolicy = {}) {
return 'Room lights are unlocked.';
}
function createLightsCommand({ homeAssistantService, sanitizeMentions, discordConfig }) {
function createLightsCommand({ homeAssistantService, sanitizeMentions, config }) {
// The HA policy behavior is prefix-agnostic; this value is only used so
// invalid-command guidance points admins at this bot instance's namespace.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleLightsCommand(message, tokens = []) {
// Defaulting to status makes the bare lights command safe to type while
// still exposing explicit mutating forms under the configured prefix. This
@@ -1,12 +1,13 @@
// Discord Lock Command
// Operator Lock Command
// Purpose: Handles lock and unlock operations for rover availability control.
// Scope: Applies lock state updates for a single rover ID.
const { resolveRoverSelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createLockCommand({ lockRover, sanitizeMentions, rovers, discordConfig }) {
function createLockCommand({ lockRover, sanitizeMentions, rovers, config }) {
// Only the user-facing example depends on the prefix. The actual lock logic
// still receives the already-parsed rover selector from the shared router.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleLockCommand(message, roverId, locked) {
if (!roverId) {
await message.reply({ content: `Specify a rover ID. Example: \`${commandPrefix} lock alpha\``, allowedMentions: { parse: [], repliedUser: false } });
@@ -21,7 +22,9 @@ function createLockCommand({ lockRover, sanitizeMentions, rovers, discordConfig
// Mutate by canonical id after fuzzy resolution. This avoids letting a
// display-name typo create a new path through roverManager, and it also
// makes the response name match the rover that was actually changed.
lockRover(resolved.id, locked, { reason: 'discord' });
// Preserve the established Discord reason while allowing other adapters
// to identify themselves without pretending their request came from Discord.
lockRover(resolved.id, locked, { reason: message.transport === 'discord' ? 'discord' : 'web-chat' });
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${resolved.label || resolved.id}.`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -1,7 +1,7 @@
// Discord Mode Command
// Purpose: Handles mode updates from Discord admins through the configured bot prefix.
// Operator Mode Command
// Purpose: Handles mode updates from authorized operators through the shared command prefix.
// Scope: Validates mode values and applies mode changes with optional reason text.
function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser, sanitizeMentions }) {
function createModeCommand({ MODES, setMode, setAdminReason, sanitizeMentions }) {
return async function handleModeCommand(message, tokens = []) {
const next = String(tokens.shift() || '').toLowerCase();
const reasonText = tokens.join(' ').trim();
@@ -10,9 +10,9 @@ function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser
return;
}
try {
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin';
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } });
if (reasonText) setAdminReason(reasonText, { by: message.author?.id || null });
const role = message.actor?.isLockdownAdmin ? 'lockdown' : 'admin';
setMode(next, { data: { role, user: { username: `${message.transport}:${message.actor?.label || 'unknown'}` } } });
if (reasonText) setAdminReason(reasonText, { by: message.actor?.id || null });
await message.reply({ content: sanitizeMentions(`Mode set to ${next}.`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
await message.reply({ content: sanitizeMentions(`Failed to set mode: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -0,0 +1,35 @@
// Neato Feature Command
// Purpose: Exposes Neato state and supported actions through the shared text command route.
// Scope: Delegates device availability, Home Assistant calls, and operational errors to neatoService.
function describeState(state = {}) {
const telemetry = state.telemetry || {};
const connection = state.connected ? 'connected' : 'offline';
return `Neato: ${connection}; state ${telemetry.robotState || 'unknown'}; battery ${telemetry.batteryLevel ?? 'unknown'}%.`;
}
function createNeatoCommand({ neatoService, sanitizeMentions }) {
return async function handleNeatoCommand(message, tokens = []) {
const action = String(tokens.shift() || 'status').toLowerCase();
if (action === 'status') return message.reply({ content: describeState(neatoService.getState()) });
const actions = {
start: ['starting cleaning', neatoService.startCleaning],
home: ['returning home', neatoService.sendHome],
locate: ['playing locate sound', neatoService.locateRobot],
'clear-errors': ['clearing errors', neatoService.clearErrors],
};
const selected = actions[action];
if (!selected) {
return message.reply({ content: 'Invalid Neato command. Use `neato status`, `neato start`, `neato home`, `neato locate`, or `neato clear-errors`.' });
}
try {
await selected[1]();
return message.reply({ content: `Neato is ${selected[0]}.` });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Neato command failed: ${err.message}`) });
}
};
}
module.exports = { createNeatoCommand };
@@ -1,7 +1,7 @@
// Discord Reason Command
// Operator Reason Command
// Purpose: Handles admin-mode reason view/update/clear operations.
// Scope: Allows read by all and write by admins.
function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason, isAdminUser, sanitizeMentions }) {
function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason, sanitizeMentions }) {
return async function handleReasonCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
@@ -10,16 +10,16 @@ function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason,
await message.reply({ content: reason?.text ? `Admin mode reason: ${sanitizeMentions(reason.text)}` : 'No admin mode reason set.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (!isAdminUser(message.author.id)) {
if (!message.actor?.isAdmin) {
await message.reply({ content: 'Only admins can update the admin mode reason.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
try {
if (lower === 'clear') {
clearAdminReason({ by: message.author?.id || null });
clearAdminReason({ by: message.actor?.id || null });
await message.reply({ content: 'Admin mode reason cleared.', allowedMentions: { parse: [], repliedUser: false } });
} else {
setAdminReason(query, { by: message.author?.id || null });
setAdminReason(query, { by: message.actor?.id || null });
await message.reply({ content: sanitizeMentions(`Admin mode reason set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
}
} catch (err) {
@@ -1,4 +1,4 @@
// Discord Command Resolvers
// Operator Command Resolvers
// Purpose: Provides shared selector parsing and fuzzy matching for command handlers.
// Scope: Keeps potentially destructive commands from each inventing their own lookup rules.
const Fuse = require('fuse.js');
@@ -1,14 +1,14 @@
// Discord Verify Command
// Operator Verify Command
// Purpose: Handles verified-user moderation commands for lockdown admins.
// Scope: Supports list and remove subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions, discordConfig }) {
// Verification usage text follows the configured prefix for the same reason
// as the command router: each bot instance needs its own command namespace.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, sanitizeMentions, config }) {
// Usage text comes from the same core prefix that both transports parse.
const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleVerifyCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
if (!message.actor?.isLockdownAdmin) {
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
@@ -29,7 +29,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
// The command resolver only turns a human-friendly or fuzzy nickname
// into the stable cookie id so the service does not need Discord/Web
// command concerns baked into its storage API.
const removed = removeVerifiedUser(resolved.record.userId || resolved.record.id || resolved.record.cookieUserId, message.author?.id || null);
const removed = removeVerifiedUser(resolved.record.userId || resolved.record.id || resolved.record.cookieUserId, message.actor?.id || null);
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -0,0 +1,45 @@
// Operator Command Configuration
// Purpose: Owns transport-neutral command names used by site chat and optional integrations.
// Scope: Prevents Discord configuration from defining whether core server commands can be parsed.
const { loadConfig } = require('../../helpers/configLoader');
function getCommandConfig(config = loadConfig()) {
const commandConfig = config.commands || {};
const prefix = String(commandConfig.prefix || 'rs').trim() || 'rs';
const timeStatusCommand = commandConfig.timeStatusCommand === null
? ''
: String(commandConfig.timeStatusCommand || 'ts').trim();
return { prefix, timeStatusCommand };
}
function parseCommandText(text, config = loadConfig()) {
const clean = String(text || '').trim();
const lower = clean.toLowerCase();
const { prefix, timeStatusCommand } = getCommandConfig(config);
const normalizedPrefix = prefix.toLowerCase();
const normalizedTimeStatus = timeStatusCommand.toLowerCase();
if (normalizedTimeStatus && lower === normalizedTimeStatus) {
return { matched: true, kind: 'time-status', body: '', action: 'time-status', tokens: [] };
}
if (!lower.startsWith(normalizedPrefix)) return { matched: false };
const nextCharacter = clean.charAt(prefix.length);
if (nextCharacter && !/\s/.test(nextCharacter)) return { matched: false };
const body = clean.slice(prefix.length).trim();
const tokens = body ? body.split(/\s+/) : [];
return {
matched: true,
kind: 'prefixed',
body,
action: String(tokens[0] || '').toLowerCase(),
tokens,
};
}
module.exports = {
getCommandConfig,
parseCommandText,
};
@@ -0,0 +1,46 @@
// Operator Command Help
// Purpose: Generates organized command help from one descriptive command catalogue.
// Scope: Keeps shared command discovery consistent while allowing Discord-only extensions to stay transport-specific.
const { CATEGORIES, buildCommandRegistry } = require('./registry');
function renderDetailed(name, entry, isFeatureEnabled) {
const details = [`**${name}**`, entry.summary];
if (entry.access) details.push(`Permission: ${entry.access}`);
if (entry.requiredFeature) details.push(`Required feature: ${entry.requiredFeature}`);
if (entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature)) details.push('Availability: unavailable on this server');
details.push('Usage:', ...entry.usage.map((usage) => `- \`${usage}\``));
return details.join('\n');
}
function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = '', includeDiscord = true, isFeatureEnabled = () => true } = {}) {
const prefix = String(commandPrefix || 'rs').trim() || 'rs';
const timeCommand = timeStatusCommand ? String(timeStatusCommand).trim() : '';
const entries = buildCommandRegistry(prefix, timeCommand);
const normalizedTopic = String(topic || '').trim().toLowerCase();
if (entries[normalizedTopic] && (normalizedTopic !== 'bridge' || includeDiscord)) {
return renderDetailed(normalizedTopic, entries[normalizedTopic], isFeatureEnabled);
}
const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic;
const categoryNames = requestedCategory && CATEGORIES[requestedCategory]
? [requestedCategory]
: ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])];
const output = ['**Rover Bot Commands**'];
for (const categoryName of categoryNames) {
if (categoryName === 'discord' && !includeDiscord) continue;
const category = CATEGORIES[categoryName];
output.push('', `**${category.title}**`);
for (const name of category.names) {
const entry = entries[name];
if (!entry?.usage?.length) continue;
const availability = entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature) ? ' *(unavailable)*' : '';
output.push(`\`${entry.usage[0]}\`${entry.summary}${availability}`);
}
}
output.push('', `Use \`${prefix} help <command|category>\` for details.`);
return output.join('\n');
}
module.exports = { formatHelp };
@@ -0,0 +1,152 @@
// Operator Command Service
// Purpose: Routes transport-neutral operator command requests to registered server handlers.
// Scope: Owns shared parsing, authorization, feature gating, help, and execution without importing Discord.js.
const { formatHelp } = require('./help');
const { createLockCommand } = require('./commands/lock');
const { createModeCommand } = require('./commands/mode');
const { createReasonCommand } = require('./commands/reason');
const { createGoalCommand } = require('./commands/goal');
const { createVerifyCommand } = require('./commands/verify');
const { createDeterCommand } = require('./commands/deter');
const { createLightsCommand } = require('./commands/lights');
const { createKickCommand } = require('./commands/kick');
const { createLiftCommand } = require('./commands/lift');
const { createNeatoCommand } = require('./commands/neato');
const { getCommandConfig } = require('./config');
const { buildCommandRegistry } = require('./registry');
function createCommandHandlers(deps) {
const {
getMode,
MODES,
} = deps;
// The prefix belongs to the always-available command system so every
// transport parses the same namespace instead of maintaining local defaults.
const { prefix: commandPrefix, timeStatusCommand } = getCommandConfig(deps.config);
// The legacy time command is a bare word rather than a prefixed command. It
// therefore needs its own configurable value, and `null` intentionally
// disables it so multiple bots do not all answer `ts` in the same channel.
// Lowercase cached copies avoid re-normalizing every message and keep command
// matching case-insensitive without changing the original configured text
// that is shown in help output.
const normalizedCommandPrefix = commandPrefix.toLowerCase();
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
const registry = buildCommandRegistry(commandPrefix, timeStatusCommand);
// Status, time status, replay delivery, and transport extensions may have
// different presentation needs. Adapters inject those focused handlers while
// the core retains parsing, policy, and command discovery ownership.
const transportHandlers = deps.transportHandlers || {};
const handleStatusCommand = transportHandlers.status;
const handleReplayCommand = deps.createReplayTextCommand
? deps.createReplayTextCommand(deps)
: transportHandlers.replay;
const handleLockCommand = createLockCommand(deps);
const handleModeCommand = createModeCommand(deps);
const handleReasonCommand = createReasonCommand(deps);
const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps);
const handleBridgeCommand = transportHandlers.bridge;
const handleTimeStatusCommand = transportHandlers.timeStatus;
const handleLightsCommand = createLightsCommand(deps);
const handleKickCommand = createKickCommand(deps);
const handleLiftCommand = createLiftCommand(deps);
const handleNeatoCommand = createNeatoCommand(deps);
function stripCommandPrefix(content) {
const trimmed = String(content || '').trim();
const lower = trimmed.toLowerCase();
if (!lower.startsWith(normalizedCommandPrefix)) return null;
const nextCharacter = trimmed.charAt(commandPrefix.length);
// Prefixes are matched as whole command tokens so an instance using `rs`
// still ignores ordinary words such as `rsvp`. This mirrors the old regex
// behavior while letting each Discord bot instance use its own prefix.
if (nextCharacter && !/\s/.test(nextCharacter)) return null;
return trimmed.slice(commandPrefix.length).trim();
}
async function handleCommand(request) {
if (request.actor?.bot) return;
const content = (request.content || '').trim();
const lower = content.toLowerCase();
// Commands are intentionally matched as whole prefixes. The previous
// startsWith checks made ordinary messages such as "rsvp" or "tshirt" look
// like commands, which is especially bad now that web chat will run the
// same server-side dispatcher before broadcasting user text.
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return handleTimeStatusCommand?.(request);
const commandBody = stripCommandPrefix(content);
if (commandBody === null) return;
const tokens = commandBody ? commandBody.split(/\s+/) : [];
const action = (tokens.shift() || '').toLowerCase();
const rest = tokens.join(' ').trim();
const isAdmin = Boolean(request.actor?.isAdmin);
const isLockdownAdmin = Boolean(request.actor?.isLockdownAdmin);
const mode = getMode();
const commandDefinition = registry[action];
if (commandDefinition?.requiredFeature && !deps.isFeatureEnabled(commandDefinition.requiredFeature)) {
await request.reply({ content: `${commandDefinition.unavailableLabel || commandDefinition.requiredFeature} feature is not configured.` });
return;
}
// Actions in this set can change operational safety or access policy, so
// lockdown mode narrows them from normal admins to lockdown admins. Room
// light locking belongs here because it can force the physical room lights
// on and disables ordinary Home Assistant room controls for everyone else.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
switch (action) {
case '':
case 'status':
return handleStatusCommand?.(request, rest);
case 'help':
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, topic: rest, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
case 'replay':
return handleReplayCommand?.(request, tokens.join(' '));
case 'bridge':
if (!handleBridgeCommand) return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: false, isFeatureEnabled: deps.isFeatureEnabled }));
return handleBridgeCommand(request, tokens);
case 'lights':
return handleLightsCommand(request, tokens);
case 'kick':
return handleKickCommand(request, rest);
case 'lift':
return handleLiftCommand(request, tokens);
case 'neato':
return handleNeatoCommand(request, tokens);
case 'lock':
return handleLockCommand(request, rest, true);
case 'unlock':
return handleLockCommand(request, rest, false);
case 'mode':
return handleModeCommand(request, tokens);
case 'goal':
return handleGoalCommand(request, tokens);
case 'reason':
return handleReasonCommand(request, tokens);
case 'verify':
return handleVerifyCommand(request, tokens);
case 'deter':
return handleDeterCommand(request, tokens);
default:
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
}
}
return { handleCommand };
}
module.exports = { createCommandHandlers };
@@ -0,0 +1,32 @@
// Operator Command Registry
// Purpose: Describes command categories, discovery text, permissions, and feature requirements in one place.
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'lights', 'kick', 'verify', 'deter'] },
features: { title: 'Features', names: ['lift', 'neato'] },
discord: { title: 'Discord', names: ['bridge'] },
};
function buildCommandRegistry(prefix, timeCommand) {
return {
help: { category: 'system', summary: 'Show command help.', usage: [`${prefix} help [command|category]`] },
status: { category: 'system', summary: 'Show rover status; rover names can be fuzzy.', usage: [`${prefix} status [rover]`] },
replay: { category: 'system', summary: 'Create an instant replay from selected sources.', usage: [`${prefix} replay [sources]`] },
'time-status': { category: 'system', summary: 'Show the current time status.', usage: timeCommand ? [timeCommand] : [] },
lock: { category: 'admin', summary: 'Lock a rover.', usage: [`${prefix} lock <rover>`], access: 'Admin', permission: 'admin' },
unlock: { category: 'admin', summary: 'Unlock a rover.', usage: [`${prefix} unlock <rover>`], access: 'Admin', permission: 'admin' },
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
lights: { category: 'admin', summary: 'Show or change the room-light lock.', usage: [`${prefix} lights <status|lock|unlock>`], access: 'Admin', permission: 'admin' },
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Admin', permission: 'admin', requiredFeature: 'lift', unavailableLabel: 'Lift' },
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Admin', permission: 'admin', requiredFeature: 'neato', unavailableLabel: 'Neato' },
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
};
}
module.exports = { CATEGORIES, buildCommandRegistry };
@@ -0,0 +1,68 @@
// Web Chat Command Transport
// Purpose: Renders status-oriented operator commands as the same plain text web chat expects.
// Scope: Avoids importing Discord.js merely to flatten an embed back into text.
const { resolveRoverSelector } = require('./commands/resolvers');
function formatTimeInZone(date, timeZone) {
try {
return new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', minute: '2-digit', hour12: false }).format(date);
} catch (_err) {
return 'n/a';
}
}
function createWebTransportHandlers({ rovers, roverManager, config, siteUrl = '' }) {
return {
async status(message, roverId) {
const resolved = roverId ? resolveRoverSelector(roverId, rovers) : null;
if (roverId && resolved?.error) return message.reply(`Rover Status\n\n${resolved.error}`);
const records = roverId
? [resolved.record]
: Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
if (!records.length) return message.reply('Rover Battery Status\n\nNo rovers online.');
// This mirrors the human-readable content of the established Discord
// battery embed while remaining a plain transport-neutral chat result.
const fields = records.map((record) => {
const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || {};
const battery = record.batteryState || {};
const name = record.meta?.name || record.id;
const docked = Boolean(sensors?.chargingSources?.homeBase);
const chargingLabel = String(sensors?.chargingState?.label || 'unknown');
const charging = ['waiting', 'full charging', 'trickle charging'].includes(chargingLabel.toLowerCase()) || [2, 3, 4].includes(sensors?.chargingState?.code);
const lockLabel = record.locked ? `locked${record.lockReason ? ` (${record.lockReason})` : ''}` : 'unlocked';
const charge = battery.charge != null && battery.capacity != null ? `${battery.charge}/${battery.capacity}mAh` : 'n/a';
const percent = battery.percentDisplay != null ? `${battery.percentDisplay}%` : 'n/a';
return [
name,
`Dock: ${docked ? 'docked' : 'undocked'}`,
`Charging: ${charging ? `charging (${chargingLabel})` : 'not charging'}`,
`Battery: ${charge} (${percent})`,
`Voltage: ${sensors?.voltageMv == null ? 'n/a' : `${(sensors.voltageMv / 1000).toFixed(2)}V`}`,
`Current: ${sensors?.currentMa == null ? 'n/a' : `${sensors.currentMa}mA`}`,
`OI: ${String(sensors?.oiMode?.label || 'unknown').toLowerCase()}`,
`Lock: ${lockLabel}`,
].join('\n');
});
return message.reply(['Rover Battery Status', ...fields].join('\n\n'));
},
async timeStatus(message) {
const serverTimezone = config.timezone || config.server?.timezone || process.env.TZ || 'America/New_York';
const zones = [
['UTC', 'UTC'], ['US Pacific', 'America/Los_Angeles'], ['US Mountain', 'America/Denver'],
['US Central', 'America/Chicago'], ['US Eastern', 'America/New_York'], ['Europe London', 'Europe/London'],
['Europe Berlin', 'Europe/Berlin'], ['Asia Kolkata', 'Asia/Kolkata'], ['Asia Shanghai', 'Asia/Shanghai'],
['Asia Tokyo', 'Asia/Tokyo'], ['Australia Sydney', 'Australia/Sydney'], ['New Zealand Auckland', 'Pacific/Auckland'],
];
const now = new Date();
const lines = zones.map(([label, zone]) => `${label}${formatTimeInZone(now, zone)}${zone.toLowerCase() === String(serverTimezone).toLowerCase() ? ' **(server local timezone)**' : ''}`);
if (!zones.some(([, zone]) => zone.toLowerCase() === String(serverTimezone).toLowerCase())) {
lines.push(`Server Local — ${formatTimeInZone(now, serverTimezone)} **(server local timezone)**`);
}
const siteLink = siteUrl ? `\n\n${siteUrl}` : '';
return message.reply(`Time Status\n${lines.join('\n')}${siteLink}\n\nServer local timezone: ${serverTimezone}`);
},
};
}
module.exports = { createWebTransportHandlers };
@@ -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,
};
@@ -1,6 +1,6 @@
// 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.
// 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 };
@@ -7,11 +7,7 @@ const { getMode, MODES } = require('../modeManager');
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 || {};
const { buildReplayJobId, buildReplayTitle } = require('../replayDeliveryService/workflow');
function buildRequesterLabel(socket) {
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
@@ -34,11 +30,6 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
cb({ error: 'Replay disabled in lockdown', state: null });
return;
}
const channelId = discordConfig?.channels?.replay || null;
if (!channelId) {
cb({ error: 'Replay channel not configured', state: null });
return;
}
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
let sources = requestedSources ? validateSources(requestedSources, socket) : [];
if (!sources.length) {
@@ -65,7 +56,6 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
type: 'replay.requested',
payload: {
jobId,
channelId,
requester,
title,
includeSidebar,
@@ -0,0 +1,123 @@
// Replay Media Service
// Purpose: Stores and serves completed replay videos when Discord delivery is unavailable.
// Scope: Owns only final hosted MP4 files; replay frame caches and active video builds remain outside this service.
const crypto = require('crypto');
const fsp = require('fs/promises');
const path = require('path');
const logger = require('../../globals/logger').child('replayMedia');
const { app } = require('../../globals/http');
const { resolveDataDir } = require('../../helpers/dataPaths');
const REPLAY_DIR = path.join(resolveDataDir(), 'replays');
const MAX_AGE_MS = 6 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
const PUBLIC_FILE_PATTERN = /^[a-f0-9]{32}\.mp4$/;
async function listCompletedFiles() {
await fsp.mkdir(REPLAY_DIR, { recursive: true });
const entries = await fsp.readdir(REPLAY_DIR, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = path.join(REPLAY_DIR, entry.name);
try {
const stat = await fsp.stat(filePath);
files.push({ name: entry.name, path: filePath, size: stat.size, mtimeMs: stat.mtimeMs });
} catch (err) {
if (err.code !== 'ENOENT') logger.warn('Unable to inspect hosted replay', { file: entry.name, error: err.message });
}
}
return files;
}
async function cleanup() {
const now = Date.now();
const files = await listCompletedFiles();
const completed = files.filter((file) => PUBLIC_FILE_PATTERN.test(file.name)).sort((a, b) => a.mtimeMs - b.mtimeMs);
const temporary = files.filter((file) => file.name.endsWith('.tmp'));
// Temporary files are never served. An old one means a write was interrupted,
// so it is safe to remove after the same conservative expiry used for media.
const expiredTemporary = temporary.filter((file) => now - file.mtimeMs > MAX_AGE_MS);
const expiredCompleted = completed.filter((file) => now - file.mtimeMs > MAX_AGE_MS);
const toDelete = new Set([...expiredTemporary, ...expiredCompleted].map((file) => file.path));
let retainedBytes = completed.reduce((total, file) => total + file.size, 0)
- expiredCompleted.reduce((total, file) => total + file.size, 0);
for (const file of completed) {
if (retainedBytes <= MAX_TOTAL_BYTES) break;
if (toDelete.has(file.path)) continue;
toDelete.add(file.path);
retainedBytes -= file.size;
}
await Promise.all(Array.from(toDelete).map(async (filePath) => {
try {
await fsp.unlink(filePath);
} catch (err) {
if (err.code !== 'ENOENT') logger.warn('Unable to remove hosted replay', { file: path.basename(filePath), error: err.message });
}
}));
}
async function hostReplay({ buffer, job }) {
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Replay output was empty');
await fsp.mkdir(REPLAY_DIR, { recursive: true });
const filename = `${crypto.randomBytes(16).toString('hex')}.mp4`;
const finalPath = path.join(REPLAY_DIR, filename);
const temporaryPath = `${finalPath}.${process.pid}.tmp`;
// Atomic rename ensures cleanup and HTTP requests can only observe a fully
// written MP4, never a partially flushed replay.
try {
await fsp.writeFile(temporaryPath, buffer, { flag: 'wx' });
await fsp.rename(temporaryPath, finalPath);
} catch (err) {
await fsp.unlink(temporaryPath).catch(() => {});
throw err;
}
return {
jobId: job.id,
status: 'ready',
title: job.title,
requester: job.requester,
requestedBy: job.requestedBy || null,
url: `/media/replays/${filename}`,
proxyUrl: null,
messageUrl: null,
filename,
size: buffer.length,
contentType: 'video/mp4',
sources: Array.isArray(job.sources) ? job.sources : [],
ts: Date.now(),
};
}
app.get('/media/replays/:filename', (req, res, next) => {
const filename = String(req.params.filename || '');
if (!PUBLIC_FILE_PATTERN.test(filename)) return res.status(404).end();
const filePath = path.join(REPLAY_DIR, filename);
// Express sendFile supports byte-range requests, which preserves seeking in
// the existing browser video players without implementing a second streamer.
res.setHeader('Cache-Control', 'private, max-age=3600');
return res.sendFile(filePath, { headers: { 'Content-Type': 'video/mp4' } }, (err) => {
if (!err || res.headersSent) return;
if (err.code === 'ENOENT') return res.status(404).end();
return next(err);
});
});
cleanup().catch((err) => logger.warn('Initial hosted replay cleanup failed', err.message));
const cleanupTimer = setInterval(() => {
cleanup().catch((err) => logger.warn('Hosted replay cleanup failed', err.message));
}, CLEANUP_INTERVAL_MS);
// Maintenance must never keep a process alive during normal shutdown.
if (typeof cleanupTimer.unref === 'function') cleanupTimer.unref();
module.exports = {
hostReplay,
cleanup,
replayDirectory: REPLAY_DIR,
};