mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
the big
This commit is contained in:
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user