mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
the big
This commit is contained in:
@@ -50,6 +50,7 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const ptzCameraConfig = config.ptzCamera || {};
|
||||
const discordConfig = config.discord || {};
|
||||
const homeAssistant = Boolean(
|
||||
asBoolean(homeAssistantConfig.enabled) &&
|
||||
asTrimmedString(homeAssistantConfig.url) &&
|
||||
@@ -87,6 +88,13 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
asTrimmedString(ptzCameraConfig.username) &&
|
||||
asTrimmedString(ptzCameraConfig.password),
|
||||
),
|
||||
/*
|
||||
Discord is an optional transport, not a prerequisite for chat commands.
|
||||
Requiring both the explicit switch and a token prevents an old token from
|
||||
silently enabling external connections on installations that have chosen
|
||||
to run without the integration.
|
||||
*/
|
||||
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+8
-9
@@ -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 } });
|
||||
+5
-5
@@ -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) {
|
||||
+5
-4
@@ -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 };
|
||||
+5
-3
@@ -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
|
||||
+7
-4
@@ -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 } });
|
||||
+6
-6
@@ -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 };
|
||||
+5
-5
@@ -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
-1
@@ -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');
|
||||
+7
-7
@@ -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,
|
||||
};
|
||||
+3
-3
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user