This commit is contained in:
legop3
2026-07-15 00:35:39 -04:00
parent 96d06091ee
commit 3a8a2ebb13
34 changed files with 1271 additions and 372 deletions
@@ -0,0 +1,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,58 +0,0 @@
// Discord Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers');
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';
return async function handleDeterCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'list') {
const users = listDeterredUsers();
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
}
if (action === 'ban') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
try {
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
}
// Ban reasons were deliberately removed from the command grammar. The
// 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 });
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 } });
}
}
if (action === 'unban') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter unban <id|cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
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);
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 } });
}
}
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, or \`${commandPrefix} deter unban <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
};
}
module.exports = { createDeterCommand };
@@ -1,31 +0,0 @@
// Discord 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 }) {
return async function handleGoalCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
if (!query) {
const goal = getGlobalObjective();
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)) {
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 });
await message.reply({ content: 'Global objective cleared.', allowedMentions: { parse: [], repliedUser: false } });
} else {
setGlobalObjective(query, { by: message.author?.id || null });
await message.reply({ content: sanitizeMentions(`Global objective set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
}
} catch (err) {
await message.reply({ content: sanitizeMentions(`Failed to update goal: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
};
}
module.exports = { createGoalCommand };
@@ -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 };
@@ -1,141 +0,0 @@
// Discord 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 DEFAULT_KICK_REASON = 'Removed from rover by admin.';
function normalizeText(value) {
return String(value || '').trim();
}
function normalizeSearchText(value) {
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
}
function splitSelectorAndReason(rawText) {
const text = normalizeText(rawText);
if (!text) return { selector: '', reason: '' };
const pipeIndex = text.indexOf('|');
if (pipeIndex >= 0) {
/*
A pipe delimiter is the escape hatch for multi-word nicknames. Without a
delimiter the command intentionally treats the first token as the selector
so quick admin commands stay short, for example:
`<configured-prefix> kick bob being reckless`.
*/
return {
selector: normalizeText(text.slice(0, pipeIndex)),
reason: normalizeText(text.slice(pipeIndex + 1)),
};
}
const parts = text.split(/\s+/);
return {
selector: normalizeText(parts.shift()),
reason: normalizeText(parts.join(' ')),
};
}
function buildKickCandidates({ io, roverManager, assignmentService, getNickname }) {
return Array.from(io.sockets.sockets.values())
.map((socket) => {
const socketId = normalizeText(socket?.id);
const assignedRoverId = assignmentService?.getAssignedRover?.(socketId) || null;
const primaryRoverId = roverManager.getPrimaryRoverForSocket(socketId);
const roverId = assignedRoverId || primaryRoverId || null;
if (!socketId || !roverId) return null;
const nickname = normalizeText(getNickname(socket));
const username = normalizeText(socket?.data?.user?.username);
return {
socket,
socketId,
roverId,
nickname,
username,
label: nickname || username || socketId.slice(0, 6),
searchSocketId: normalizeSearchText(socketId),
searchShortSocketId: normalizeSearchText(socketId.slice(0, 6)),
searchNickname: normalizeSearchText(nickname),
searchUsername: normalizeSearchText(username),
};
})
.filter(Boolean);
}
function resolveKickTarget(selector, candidates, commandPrefix = 'rs') {
const query = normalizeSearchText(selector);
if (!query) return { error: `Specify a user to kick. Example: \`${commandPrefix} kick nickname reason\`` };
const exact = candidates.filter((entry) => (
entry.searchSocketId === query ||
entry.searchShortSocketId === query ||
entry.searchNickname === query ||
entry.searchUsername === query
));
if (exact.length === 1) return { target: exact[0] };
if (exact.length > 1) {
return { error: `User matched multiple drivers: ${exact.map((entry) => entry.label).join(', ')}.` };
}
const fuse = new Fuse(candidates, {
includeScore: true,
threshold: 0.38,
ignoreLocation: true,
keys: [
{ name: 'nickname', weight: 0.7 },
{ name: 'username', weight: 0.2 },
{ name: 'socketId', weight: 0.1 },
],
});
const results = fuse.search(selector);
if (!results.length) return { error: 'User not found among current rover drivers.' };
const first = results[0];
const second = results[1];
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < 0.08) {
return {
error: `User matched multiple drivers: ${results.slice(0, 5).map((entry) => entry.item.label).join(', ')}.`,
};
}
return { target: first.item };
}
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, discordConfig }) {
// 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';
return async function handleKickCommand(message, rawText) {
const { selector, reason } = splitSelectorAndReason(rawText);
const assignmentService = require('../../assignmentService');
const candidates = buildKickCandidates({
io,
roverManager,
assignmentService,
getNickname,
});
const resolved = resolveKickTarget(selector, candidates, commandPrefix);
if (resolved.error) {
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
return;
}
const target = resolved.target;
const removalReason = reason || DEFAULT_KICK_REASON;
/*
The command deliberately calls the notice-aware release helper instead of
roverManager.releaseControl. That keeps admin kicks aligned with automated
removals and gives the driver a stable explanation in the video panel.
*/
assignmentService.forceReleaseWithNotice(target.roverId, target.socketId, {
title: 'Removed by admin',
message: removalReason,
reasonCode: 'admin-kick',
actor: message.author?.id || null,
});
await message.reply({
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
allowedMentions: { parse: [], repliedUser: false },
});
};
}
module.exports = {
createKickCommand,
};
@@ -1,75 +0,0 @@
// Discord 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.
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
// from locked-off, but keep lockedOn as a defensive fallback for any caller
// that passes an older or partial policy object.
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
if (lockState === 'on') return 'Room lights are locked on.';
if (lockState === 'off') return 'Room lights are locked off.';
return 'Room lights are unlocked.';
}
function createLightsCommand({ homeAssistantService, sanitizeMentions, discordConfig }) {
// 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';
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
// matters when several bot instances share a Discord server and each one
// needs its own command namespace.
const action = String(tokens.shift() || 'status').trim().toLowerCase();
if (!homeAssistantService) {
await message.reply({
content: 'Room light controls are unavailable.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (action === 'status') {
await message.reply({
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (action !== 'lock' && action !== 'unlock') {
await message.reply({
content: `Invalid lights command. Use \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
try {
const locked = action === 'lock';
// The bot command intentionally calls the shared policy setter instead of
// issuing direct Home Assistant entity commands. That keeps all secondary
// behavior centralized: web UI controls become disabled through the
// session lightPolicy update, entering lock-on still sets configured
// lights to white where possible once, and commandService sees the same
// update event that forces rover lasers off while the room is locked on.
await homeAssistantService.setLightsLockedOn(locked, {
source: `bot-command:lights:${action}`,
});
await message.reply({
content: sanitizeMentions(locked ? 'Room lights locked on.' : 'Room lights unlocked.'),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to update room lights: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
};
}
module.exports = { createLightsCommand };
@@ -1,32 +0,0 @@
// Discord 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');
function createLockCommand({ lockRover, sanitizeMentions, rovers, discordConfig }) {
// 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';
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 } });
return;
}
try {
const resolved = resolveRoverSelector(roverId, rovers);
if (resolved.error) {
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
return;
}
// 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' });
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 } });
}
};
}
module.exports = { createLockCommand };
@@ -1,23 +0,0 @@
// Discord Mode Command
// Purpose: Handles mode updates from Discord admins through the configured bot prefix.
// Scope: Validates mode values and applies mode changes with optional reason text.
function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser, sanitizeMentions }) {
return async function handleModeCommand(message, tokens = []) {
const next = String(tokens.shift() || '').toLowerCase();
const reasonText = tokens.join(' ').trim();
if (!Object.values(MODES).includes(next)) {
await message.reply({ content: 'Invalid mode. Use one of: open, turns, admin, lockdown.', allowedMentions: { parse: [], repliedUser: false } });
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 });
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 } });
}
};
}
module.exports = { createModeCommand };
@@ -1,31 +0,0 @@
// Discord 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 }) {
return async function handleReasonCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
if (!query) {
const reason = getAdminReason();
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)) {
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 });
await message.reply({ content: 'Admin mode reason cleared.', allowedMentions: { parse: [], repliedUser: false } });
} else {
setAdminReason(query, { by: message.author?.id || null });
await message.reply({ content: sanitizeMentions(`Admin mode reason set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
}
} catch (err) {
await message.reply({ content: sanitizeMentions(`Failed to update reason: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
};
}
module.exports = { createReasonCommand };
@@ -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) {
@@ -1,173 +0,0 @@
// Discord 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');
const { normalizeIp } = require('../../../helpers/ipResolver');
const FUZZY_THRESHOLD = 0.38;
const AMBIGUOUS_SCORE_GAP = 0.08;
const MAX_SUGGESTIONS = 5;
function normalizeText(value) {
return String(value || '').trim();
}
function normalizeSearchText(value) {
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
}
function compactJoin(parts, separator = ', ') {
return (Array.isArray(parts) ? parts : []).map(normalizeText).filter(Boolean).join(separator);
}
function uniqueBy(items, getKey) {
const seen = new Set();
const out = [];
(Array.isArray(items) ? items : []).forEach((item) => {
const key = getKey(item);
if (!key || seen.has(key)) return;
seen.add(key);
out.push(item);
});
return out;
}
function buildResultError(kind, label, candidates = []) {
const suggestions = uniqueBy(candidates, (entry) => entry.key).slice(0, MAX_SUGGESTIONS);
const suffix = suggestions.length
? ` Suggestions: ${compactJoin(suggestions.map((entry) => entry.label || entry.key))}.`
: '';
if (kind === 'ambiguous') return `${label} matched multiple records.${suffix}`;
return `${label} not found.${suffix}`;
}
function resolveRoverSelector(selector, rovers) {
const query = normalizeText(selector);
if (!query) return { error: 'Specify a rover.' };
const candidates = Array.from(rovers.values()).map((record) => {
const id = normalizeText(record?.id);
const name = normalizeText(record?.meta?.name || record?.name || id);
return {
key: id,
id,
label: name,
record,
searchId: normalizeSearchText(id),
searchLabel: normalizeSearchText(name),
};
}).filter((entry) => entry.id);
const normalized = normalizeSearchText(query);
const exact = candidates.filter((entry) => entry.searchId === normalized || entry.searchLabel === normalized);
if (exact.length === 1) return { record: exact[0].record, id: exact[0].id, label: exact[0].label };
if (exact.length > 1) return { error: buildResultError('ambiguous', 'Rover', exact) };
const fuse = new Fuse(candidates, {
includeScore: true,
threshold: FUZZY_THRESHOLD,
ignoreLocation: true,
keys: [
{ name: 'label', weight: 0.65 },
{ name: 'id', weight: 0.35 },
],
});
const results = fuse.search(query);
if (!results.length) return { error: buildResultError('not_found', 'Rover', candidates) };
const first = results[0];
const second = results[1];
// Fuzzy matches are allowed for convenience, but destructive commands should
// not act when two targets are similarly plausible. The score gap keeps typo
// tolerance without turning near-ties into accidental locks or removals.
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < AMBIGUOUS_SCORE_GAP) {
return { error: buildResultError('ambiguous', 'Rover', results.map((entry) => entry.item)) };
}
return { record: first.item.record, id: first.item.id, label: first.item.label };
}
function createIdentityCandidates(records = [], { includeId = true } = {}) {
return (Array.isArray(records) ? records : []).map((record) => {
const id = normalizeText(record?.id);
const userId = normalizeText(record?.userId);
const cookieUserId = normalizeText(record?.cookieUserId);
const fingerprintId = normalizeText(record?.fingerprintId);
const nickname = normalizeText(record?.nickname);
const knownIps = Array.isArray(record?.knownIps) ? record.knownIps.map(normalizeText).filter(Boolean) : [];
return {
key: userId || id || cookieUserId || fingerprintId || nickname,
id,
userId,
cookieUserId,
fingerprintId,
nickname,
knownIps,
label: compactJoin([nickname || 'unknown', includeId && (userId || id) ? (userId || id) : '', cookieUserId ? mask(cookieUserId) : '']),
record,
searchId: normalizeSearchText(id),
searchUserId: normalizeSearchText(userId),
searchCookie: normalizeSearchText(cookieUserId),
searchFingerprint: normalizeSearchText(fingerprintId),
searchNickname: normalizeSearchText(nickname),
searchIps: knownIps.map(normalizeSearchText),
};
}).filter((entry) => entry.key);
}
function resolveIdentitySelector(selector, records = [], options = {}) {
const query = normalizeText(selector);
if (!query) return { error: 'Selector required.' };
const candidates = createIdentityCandidates(records, options);
const normalized = normalizeSearchText(query);
const ip = normalizeIp(query);
const exact = candidates.filter((entry) => (
entry.searchId === normalized ||
entry.searchUserId === normalized ||
entry.searchCookie === normalized ||
entry.searchFingerprint === normalized ||
entry.searchNickname === normalized ||
(ip && entry.searchIps.includes(normalizeSearchText(ip)))
));
if (exact.length === 1) return { record: exact[0].record, label: exact[0].label };
if (exact.length > 1) return { error: buildResultError('ambiguous', 'Selector', exact) };
const fuse = new Fuse(candidates, {
includeScore: true,
threshold: FUZZY_THRESHOLD,
ignoreLocation: true,
keys: [
{ name: 'nickname', weight: 0.78 },
{ name: 'cookieUserId', weight: 0.12 },
{ name: 'fingerprintId', weight: 0.08 },
{ name: 'id', weight: 0.08 },
{ name: 'knownIps', weight: 0.02 },
],
});
const results = fuse.search(query);
if (!results.length) return { error: buildResultError('not_found', 'Selector', candidates) };
const first = results[0];
const second = results[1];
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < AMBIGUOUS_SCORE_GAP) {
return { error: buildResultError('ambiguous', 'Selector', results.map((entry) => entry.item)) };
}
return { record: first.item.record, label: first.item.label };
}
function mask(v) {
const key = normalizeText(v);
if (!key) return 'n/a';
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
return `${key.slice(0, 6)}...${key.slice(-6)}`;
}
module.exports = {
compactJoin,
mask,
normalizeText,
normalizeSearchText,
resolveIdentitySelector,
resolveRoverSelector,
};
@@ -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) {
@@ -1,42 +0,0 @@
// Discord Verify Command
// Purpose: Handles verified-user moderation commands for lockdown admins.
// Scope: Supports list and remove subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers');
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';
return async function handleVerifyCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'list') {
const users = listVerifiedUsers();
if (!users.length) return message.reply({ content: 'No verified users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.nickname || 'unknown'} | ${entry.userId || entry.id} | ${mask(entry.cookieUserId)}`);
return message.reply({ content: ['Verified users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
}
if (action === 'remove') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} verify remove <cookieUserId|nickname>\``, allowedMentions: { parse: [], repliedUser: false } });
try {
const resolved = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
// The verification service owns the actual removal and event emission.
// 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);
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 } });
}
}
return message.reply({ content: `Unknown verify command. Use \`${commandPrefix} verify list\` or \`${commandPrefix} verify remove <cookieUserId|nickname>\`.`, allowedMentions: { parse: [], repliedUser: false } });
};
}
module.exports = { createVerifyCommand };
+97 -22
View File
@@ -5,10 +5,13 @@ const {
Client,
GatewayIntentBits,
Partials,
AttachmentBuilder,
} = require('discord.js');
const logger = require('../../globals/logger').child('discordBot');
const io = require('../../globals/io');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { parseCommandText } = require('../operatorCommandService/config');
const roverManager = require('../roverManager');
const { getRoster, lockRover, rovers } = roverManager;
const { MODES, getMode, setMode } = require('../modeManager');
@@ -20,6 +23,8 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const {
getGuildConfig,
listGuildConfigs,
@@ -48,26 +53,32 @@ const {
const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('./commands');
const { createCommandHandlers } = require('../operatorCommandService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations');
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
const {
DEFAULT_ALLOWED_MENTIONS,
createReplayCaptionBuilder,
startDiscordTypingLoop,
sanitizeReplayTitleForFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.token);
const enabled = isFeatureEnabled('discord');
// These normalized command names mirror the command router. Bridge-channel
// command replies are mirrored into web chat, so this entrypoint needs to know
// the configured command names before it wraps message.reply.
const commandPrefix = String(discordConfig.commandPrefix || 'rs').trim() || 'rs';
const timeStatusCommand = discordConfig.timeStatusCommand === null
? ''
: String(discordConfig.timeStatusCommand || 'ts').trim();
const normalizedCommandPrefix = commandPrefix.toLowerCase();
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean));
const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean));
if (!enabled) {
logger.info('Discord bot disabled; missing token in config.discord.token');
logger.info('Discord feature disabled or missing required token');
return;
}
@@ -123,7 +134,72 @@ const presence = createPresenceManager({
countReady,
});
const commands = createCommandHandlers({
const replayCaption = createReplayCaptionBuilder({
io,
rovers,
getActiveDrivers,
getNickname,
sanitizeMentions,
});
// Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails.
if (discordConfig?.channels?.replay) {
registerPreferredDeliveryProvider({
async begin(job) {
const channelId = discordConfig.channels.replay;
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId);
return {
channelId,
progressMessage,
stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'),
};
},
async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) {
const progressMessage = context?.progressMessage;
try {
if (progressMessage?.edit) {
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
}
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
if (!uploadMessage) throw new Error('Discord upload did not return a message');
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
if (progressMessage?.edit) {
// The attachment URL is already durable once Discord returns it. A
// cosmetic progress-edit failure must not trigger a duplicate local
// replay or replace the successful media payload sent to clients.
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
});
}
return media;
} catch (err) {
err.progressMessage = progressMessage;
throw err;
} finally {
if (context?.stopTyping) context.stopTyping();
}
},
async completeFallback({ context, media }) {
const siteUrl = String(discordConfig.siteUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
if (context?.progressMessage?.reply) {
await context.progressMessage.reply({
content: `Replay hosted by the rover server: ${publicUrl}`,
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
}
},
});
}
const commandDependencies = {
logger,
client,
io,
@@ -151,6 +227,9 @@ const commands = createCommandHandlers({
// service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig,
setGuildConfig,
removeGuildConfig,
@@ -167,7 +246,9 @@ const commands = createCommandHandlers({
isLockdownAdminUser,
discordConfig,
config,
});
};
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
const integrations = createIntegrations({
logger,
@@ -209,16 +290,9 @@ const commands = createCommandHandlers({
const integrationHandlers = integrations.register();
function isTextCommand(content) {
const clean = String(content || '').trim();
const lower = clean.toLowerCase();
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return true;
if (!lower.startsWith(normalizedCommandPrefix)) return false;
const nextCharacter = clean.charAt(commandPrefix.length);
// Mirrored bridge commands must use the exact same whole-token prefix rule
// as the command router. If this check is looser than the router, normal
// bridge chat can be wrapped as a command reply even though no command runs.
return !nextCharacter || /\s/.test(nextCharacter);
// Both transports share this parser so command detection cannot drift from
// the dispatcher when an installation changes its prefix.
return parseCommandText(content, config).matched;
}
function isBridgeChannelMessage(message) {
@@ -263,7 +337,8 @@ function createBridgeMirroredCommandMessage(message) {
client.on('messageCreate', async (message) => {
try {
await integrationHandlers.handleBridgeInbound(message);
await commands.handleCommand(createBridgeMirroredCommandMessage(message));
const commandMessage = createBridgeMirroredCommandMessage(message);
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
@@ -2,28 +2,12 @@
// Purpose: Handles event-bus announcements to Discord channels.
// Scope: Processes supported event types and posts formatted messages/embeds.
const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
const io = require('../../../globals/io');
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
const {
DEFAULT_ALLOWED_MENTIONS,
createReplayJob,
createJobStatusEmitter,
createReplayCaptionBuilder,
startDiscordTypingLoop,
sanitizeReplayTitleForFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
} = require('../replayWorkflow');
function createBusEventHandler(deps) {
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel, fetchChannel, buildReplayVideo, getActiveDrivers, getNickname, sanitizeMentions } = deps;
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
let skippedFirstModeAnnouncement = false;
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
@@ -55,66 +39,6 @@ function createBusEventHandler(deps) {
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId);
}
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true, jobId = null, requestedBy = null) {
if (!channelId) throw new Error('Replay channel not configured');
const job = createReplayJob({
id: jobId,
requester,
source: 'web',
title: explicitTitle,
sources,
includeSidebar,
requestedBy,
});
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
const progressMessage = await sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
const channel = await fetchChannel(channelId);
const stopTyping = startDiscordTypingLoop(channel, logger, 'web replay delivery');
try {
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'building'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
sources: job.sources,
title: job.title,
requester: job.requester,
includeSidebar: job.includeSidebar,
});
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await sendToChannel(channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
if (!uploadMessage) throw new Error('Discord upload did not return a message');
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
} catch (err) {
const message = normalizeUserError(err);
jobStatus.emit(job, 'failed', { message });
if (progressMessage?.edit) await progressMessage.edit({ content: sanitizeMentions(message), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
throw err;
} finally {
stopTyping();
}
}
function handleReplayRequested(event) {
const payload = event?.payload || {};
sendReplayToChannel(
payload?.channelId,
payload?.requester,
payload?.sources || [],
payload?.title || '',
payload?.includeSidebar !== false,
payload?.jobId || null,
payload?.requestedBy || null,
).catch((err) => {
logger.warn('Replay send failed', { error: err.message });
});
}
function handleBusEvent(event) {
const { type, payload } = event || {};
const channels = discordConfig.channels || {};
@@ -200,9 +124,9 @@ function createBusEventHandler(deps) {
});
break;
}
case 'replay.requested':
handleReplayRequested(event);
break;
// Replay requests are deliberately consumed by replayDeliveryService.
// Discord registers only a preferred delivery provider, allowing the
// same request to fall back locally without a second event subscriber.
case 'buttonBox.discordStalkerPing': {
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
announce({
@@ -234,7 +158,7 @@ function createBusEventHandler(deps) {
}
}
return { handleBusEvent, handleReplayRequested };
return { handleBusEvent };
}
module.exports = { createBusEventHandler };
@@ -1,388 +0,0 @@
// Discord Replay Workflow
// Purpose: Provides the shared replay job, Discord upload, fuzzy source lookup, and user-facing status helpers.
// Scope: Keeps Discord-command and web-triggered replay delivery on the same status pipeline.
const Fuse = require('fuse.js');
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
const FUZZY_THRESHOLD = 0.42;
const MAX_SUGGESTIONS = 4;
function nowTs() {
return Date.now();
}
function normalizeText(value) {
return String(value || '').trim();
}
function normalizeSearchText(value) {
return normalizeText(value).toLowerCase();
}
function compactJoin(parts, separator = ', ') {
return (Array.isArray(parts) ? parts : []).map(normalizeText).filter(Boolean).join(separator);
}
function buildReplayJobId(prefix = 'replay') {
// Replay rendering continues after the initial command/socket acknowledgement.
// The id is deliberately short but unique enough for correlating UI status, Discord messages, and logs.
const safePrefix = normalizeText(prefix).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24) || 'replay';
return `${safePrefix}-${nowTs()}-${Math.random().toString(36).slice(2, 8)}`;
}
function sourceKey(source) {
return `${source?.type}:${source?.id}`;
}
function sourceName(source) {
return normalizeText(source?.label) || normalizeText(source?.id) || 'unknown source';
}
function describeSource(source) {
const label = sourceName(source);
if (source?.type === 'room') return `${label} room camera`;
return label;
}
function sanitizeReplayTitleForFilename(title) {
// Discord attachment names should be readable and filesystem-safe.
// The title may originate from chat/web input, so strip separators and cap length before upload.
const cleaned = String(title || '')
.replace(/[\\/:*?"<>|]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 96);
return cleaned || 'replay';
}
function buildReplayTitle({ explicitTitle = '', sources = [] } = {}) {
const trimmed = normalizeText(explicitTitle).slice(0, 120);
if (trimmed) return trimmed;
const list = Array.isArray(sources) ? sources : [];
if (!list.length) return 'Replay';
const roomCount = list.filter((entry) => entry?.type === 'room').length;
const roverCount = list.filter((entry) => entry?.type === 'rover').length;
if (roomCount && !roverCount) return roomCount === 1 ? `Replay: ${sourceName(list[0])}` : 'Replay: Room Cameras';
if (roverCount && !roomCount && list.length === 1) return `Replay: ${sourceName(list[0])}`;
// Multi-source titles identify the replay target without claiming the requester was driving.
// Actual driver context is put in the caption where it can be based on live server state.
const shown = list.slice(0, 2).map(sourceName);
const remaining = list.length - shown.length;
return `Replay: ${compactJoin(shown)}${remaining > 0 ? ` + ${remaining} more` : ''}`;
}
function buildSourceSummary(sources = []) {
const list = Array.isArray(sources) ? sources : [];
if (!list.length) return 'no sources';
return compactJoin(list.map(describeSource));
}
function normalizeReplaySources(sources = []) {
return (Array.isArray(sources) ? sources : [])
.filter((source) => source?.type && source?.id != null)
.map((source) => ({
type: source.type,
id: String(source.id),
label: source.label || String(source.id),
}));
}
function normalizeUserError(err) {
const message = normalizeText(err?.message || err);
if (!message) return 'Replay failed.';
if (/No replay segments available/i.test(message)) return 'Replay failed: no recent video coverage was available for the selected source.';
if (/No replay sources selected/i.test(message)) return 'Replay failed: no replay sources were selected.';
if (/upload did not return/i.test(message)) return 'Replay failed: Discord did not confirm the video upload.';
if (/attachment URL/i.test(message)) return 'Replay failed: Discord uploaded the message without a playable video URL.';
if (/Replay channel not configured/i.test(message)) return 'Replay failed: the Discord replay channel is not configured.';
return `Replay failed: ${message}`;
}
function firstAttachmentFromMessage(message) {
if (!message?.attachments) return null;
if (typeof message.attachments.first === 'function') return message.attachments.first() || null;
if (typeof message.attachments.values === 'function') return message.attachments.values().next().value || null;
return null;
}
function buildDiscordReplayMediaPayload({ message, attachment, job }) {
if (!attachment?.url) return null;
return {
jobId: job.id,
status: 'ready',
title: job.title,
requester: job.requester,
requestedBy: job.requestedBy || null,
url: attachment.url,
proxyUrl: attachment.proxyURL || attachment.proxyUrl || null,
messageUrl: message?.url || null,
filename: attachment.name || attachment.filename || 'replay.mp4',
size: Number.isFinite(attachment.size) ? attachment.size : null,
contentType: attachment.contentType || null,
discord: {
channelId: message?.channelId || null,
messageId: message?.id || null,
attachmentId: attachment.id || null,
},
sources: normalizeReplaySources(job.sources),
ts: nowTs(),
};
}
function createReplayJob({ id = null, requester = 'Discord', source = 'discord', title = '', sources = [], includeSidebar = true, requestedBy = null } = {}) {
const normalizedSources = normalizeReplaySources(sources);
const resolvedTitle = buildReplayTitle({ explicitTitle: title, sources: normalizedSources });
return {
id: id || buildReplayJobId(source),
source,
requester: normalizeText(requester) || 'Discord',
title: resolvedTitle,
sources: normalizedSources,
includeSidebar: includeSidebar !== false,
requestedBy: requestedBy && typeof requestedBy === 'object' ? { ...requestedBy } : null,
createdAt: nowTs(),
};
}
function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
function buildPayload(job, status, extra = {}) {
return {
jobId: job.id,
status,
title: job.title,
requester: job.requester,
requestedBy: job.requestedBy || null,
sources: normalizeReplaySources(job.sources),
message: extra.message ? sanitizeMentions(extra.message) : undefined,
media: extra.media || undefined,
ts: nowTs(),
};
}
function emit(job, status, extra = {}) {
const payload = buildPayload(job, status, extra);
io.emit('replay:status', payload);
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
if (status === 'failed') io.emit('replay:failed', payload);
if (logger?.debug) logger.debug('Replay job status', { jobId: job.id, status });
return payload;
}
return { emit };
}
function createReplaySourceResolver({ rovers, getReplaySources, getDefaultDiscordSources, validateSources }) {
function buildAllowedCandidates() {
return getReplaySources().map((source) => ({
type: source.type,
id: String(source.id),
label: source.label || source.id,
access: 'allowed',
reason: null,
}));
}
function buildDeniedRoverCandidates(allowedKeys) {
const denied = [];
for (const record of rovers.values()) {
const candidate = {
type: 'rover',
id: String(record.id),
label: record.meta?.name || record.name || record.id,
access: 'denied',
reason: record.private?.enabled && !record.privateOpen
? 'that rover is private right now'
: 'that rover is not replayable from Discord right now',
};
if (!allowedKeys.has(sourceKey(candidate))) denied.push(candidate);
}
return denied;
}
function buildCandidates() {
const allowed = buildAllowedCandidates();
const allowedKeys = new Set(allowed.map(sourceKey));
return [...allowed, ...buildDeniedRoverCandidates(allowedKeys)].map((source) => ({
...source,
qualifiedId: `${source.type}:${source.id}`,
searchLabel: normalizeSearchText(source.label),
searchId: normalizeSearchText(source.id),
}));
}
function createFuse(candidates) {
return new Fuse(candidates, {
includeScore: true,
threshold: FUZZY_THRESHOLD,
ignoreLocation: true,
keys: [
{ name: 'label', weight: 0.52 },
{ name: 'id', weight: 0.26 },
{ name: 'qualifiedId', weight: 0.18 },
{ name: 'type', weight: 0.04 },
],
});
}
function suggestions(candidates) {
const allowed = candidates.filter((entry) => entry.access === 'allowed').slice(0, MAX_SUGGESTIONS);
if (!allowed.length) return '';
return ` Did you mean: ${compactJoin(allowed.map(describeSource))}?`;
}
function findForToken(token, candidates, fuse) {
const cleaned = normalizeSearchText(token);
const [prefix, rest] = cleaned.includes(':') ? cleaned.split(':', 2) : [null, cleaned];
const exact = candidates.find((entry) => (
(!prefix || entry.type === prefix) &&
(entry.searchId === rest || entry.searchLabel === rest || normalizeSearchText(entry.qualifiedId) === cleaned)
));
if (exact) return exact;
return fuse.search(cleaned).map((entry) => entry.item).find((entry) => !prefix || entry.type === prefix) || null;
}
function resolve(query) {
const cleaned = normalizeSearchText(query);
if (!cleaned || cleaned === 'all' || cleaned === '*') {
const sources = getDefaultDiscordSources();
return sources.length
? { sources: normalizeReplaySources(sources), defaulted: true }
: { error: 'Replay denied: no default Discord replay sources are available.' };
}
const candidates = buildCandidates();
const fuse = createFuse(candidates);
const selected = [];
const denied = [];
const unmatched = [];
normalizeText(query).split(',').map((token) => token.trim()).filter(Boolean).forEach((token) => {
const match = findForToken(token, candidates, fuse);
if (!match) {
unmatched.push(token);
} else if (match.access !== 'allowed') {
denied.push(match);
} else {
selected.push({ type: match.type, id: match.id, label: match.label });
}
});
if (denied.length) {
const deniedText = compactJoin(denied.map((entry) => `${describeSource(entry)} (${entry.reason})`));
return { error: `Replay denied: ${deniedText}.` };
}
if (unmatched.length) return { error: `No replay source matched "${unmatched[0]}".${suggestions(candidates)}` };
const sources = validateSources(selected);
return sources.length
? { sources: normalizeReplaySources(sources), defaulted: false }
: { error: `No replay source matched "${normalizeText(query)}".${suggestions(candidates)}` };
}
return { resolve };
}
function createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions }) {
function buildReplayDriverLines(requester, sources = []) {
const activeDrivers = getActiveDrivers();
const roverSources = normalizeReplaySources(sources).filter((entry) => entry.type === 'rover');
const requestedRoverIds = new Set(roverSources.map((entry) => String(entry.id)));
const lines = [];
requestedRoverIds.forEach((roverId) => {
const socketId = activeDrivers?.[roverId];
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);
const nickname = getNickname(socket) || socket?.data?.user?.username || socketId;
const record = rovers.get(roverId);
const roverName = record?.meta?.name || record?.id || roverId;
const isAuthor = normalizeSearchText(nickname) === normalizeSearchText(requester);
lines.push(`${nickname} was driving ${roverName}${isAuthor ? ' when they requested this' : ''}`);
});
return lines;
}
function buildDriverCaption() {
const activeDrivers = getActiveDrivers();
const roster = Array.from(rovers.values());
if (!roster.length) return 'Drivers: no rovers online.';
const entries = roster.map((record) => {
const driverId = activeDrivers[record.id];
if (!driverId) return `${record.meta?.name || record.id}: none`;
const socket = io.sockets.sockets.get(driverId);
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
return `${record.meta?.name || record.id}: ${nickname}`;
});
return `Drivers: ${entries.join(', ')}`;
}
function formatMissingSource(source) {
const label = source?.label || `${source?.type}:${source?.id}`;
return source?.reason ? `${label} (${source.reason})` : label;
}
function build({ job, usedSources = [], missingSources = [] }) {
const lines = [`# ${job.title}`, '', `Requested by ${job.requester}.`];
const driverLines = buildReplayDriverLines(job.requester, usedSources);
if (driverLines.length) {
lines.push('', ...driverLines);
} else {
lines.push(buildDriverCaption());
}
if (missingSources.length) {
lines.push('', `Missing: ${missingSources.map(formatMissingSource).join(', ')}`);
}
return sanitizeMentions(lines.join('\n'));
}
return { build };
}
function startDiscordTypingLoop(target, logger, label = 'replay') {
let stopped = false;
let intervalId = null;
async function sendTyping() {
if (stopped || typeof target?.sendTyping !== 'function') return;
try {
await target.sendTyping();
} catch (err) {
if (logger?.warn) logger.warn('Failed to send Discord typing indicator', { label, error: err.message });
}
}
// Discord typing indicators expire quickly, so refresh while ffmpeg and upload work is active.
sendTyping();
intervalId = setInterval(sendTyping, 6000);
return () => {
stopped = true;
if (intervalId) clearInterval(intervalId);
};
}
function buildAcceptedMessage(job) {
return `Replay accepted. Building **${job.title}** from ${buildSourceSummary(job.sources)}.`;
}
function buildStatusMessage(job, status) {
if (status === 'building') return `Replay building: **${job.title}**`;
if (status === 'uploading') return `Replay uploading to Discord: **${job.title}**`;
if (status === 'ready') return `Replay ready: **${job.title}**`;
return `Replay ${status}: **${job.title}**`;
}
module.exports = {
DEFAULT_ALLOWED_MENTIONS,
buildReplayJobId,
createReplayJob,
createJobStatusEmitter,
createReplaySourceResolver,
createReplayCaptionBuilder,
startDiscordTypingLoop,
sanitizeReplayTitleForFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
buildReplayTitle,
};