configurable discord prefix! yay

This commit is contained in:
legop3
2026-07-10 00:50:19 -04:00
parent a6f6ccf079
commit 6a31d8bc35
14 changed files with 174 additions and 57 deletions
+9 -1
View File
@@ -145,11 +145,19 @@ discord:
token: "DISCORD_BOT_TOKEN"
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
siteUrl: "https://rover.example.com"
# Give each bot instance a unique command prefix when several rover servers
# share one Discord server. Commands are matched as whole tokens, so "rs"
# handles "rs status" but ignores normal words like "rsvp".
commandPrefix: "rs"
# Set this to null to disable the bare time-status shortcut. It is separate
# from commandPrefix because the legacy command is just "ts", and multiple
# bots in the same Discord server should not all answer the same bare word.
timeStatusCommand: "ts"
channels:
general: "123456789012345678"
announcements: "123456789012345678"
adminAlerts: "123456789012345678"
# chat bridge is configured per guild via `rs bridge` commands
# chat bridge is configured per guild via `<commandPrefix> bridge` commands
replay: "123456789012345678"
humanAlerts: "123456789012345678"
roles:
@@ -3,7 +3,10 @@
// Scope: Manages bridge channel, mode, and webhook provisioning.
const { PermissionsBitField } = require('discord.js');
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser }) {
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser, discordConfig }) {
// 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';
function canManageBridge(message) {
if (isAdminUser(message.author.id)) return true;
if (!message.guild || !message.member) return false;
@@ -45,7 +48,7 @@ function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig
// Every command below this point mutates the guild bridge configuration.
// Keeping the authorization check in one shared gate prevents destructive
// actions, especially `rs bridge off`, from accidentally bypassing the same
// actions, especially bridge disable, from accidentally bypassing the same
// Manage Server/admin requirement used by `here` and `mode`.
if (!canManageBridge(message)) return message.reply({ content: 'You need Manage Server permissions to change the chat bridge.', allowedMentions: { parse: [], repliedUser: false } });
@@ -64,14 +67,14 @@ function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig
if (action === 'mode') {
const current = getGuildConfig(guildId);
if (!current?.channelId) return message.reply({ content: 'No chat bridge channel set. Use `rs bridge here <global|private>` first.', allowedMentions: { parse: [], repliedUser: false } });
if (!current?.channelId) return message.reply({ content: `No chat bridge channel set. Use \`${commandPrefix} bridge here <global|private>\` first.`, allowedMentions: { parse: [], repliedUser: false } });
const nextMode = normalizeMode(mode, null);
if (!VALID_MODES.has(nextMode)) return message.reply({ content: 'Invalid mode. Use `global` or `private`.', allowedMentions: { parse: [], repliedUser: false } });
const entry = setGuildConfig(guildId, { channelId: current.channelId, mode: nextMode, webhookId: current.webhookId, webhookToken: current.webhookToken });
return message.reply({ content: `Chat bridge mode updated to **${entry.mode}** in <#${entry.channelId}>.`, allowedMentions: { parse: [], repliedUser: false } });
}
return message.reply({ content: 'Unknown bridge command. Try `rs bridge`.', allowedMentions: { parse: [], repliedUser: false } });
return message.reply({ content: `Unknown bridge command. Try \`${commandPrefix} bridge\`.`, allowedMentions: { parse: [], repliedUser: false } });
};
}
@@ -3,7 +3,11 @@
// Scope: Supports list, ban, and unban subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers');
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) {
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)) {
@@ -19,7 +23,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
}
if (action === 'ban') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs deter ban <cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
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)) {
@@ -37,7 +41,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
}
if (action === 'unban') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs deter unban <id|cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
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 } });
@@ -47,7 +51,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
return message.reply({ content: 'Unknown deter command. Use `rs deter list`, `rs deter ban <selector>`, or `rs deter unban <selector>`.', 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 } });
};
}
@@ -1,30 +1,32 @@
// Discord Help Command
// Purpose: Provides help text for rover bot Discord commands.
// Scope: Returns static command usage text.
function formatHelp() {
// 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**',
'`rs help` — show this help',
'`rs status [rover]` — show rover status; rover names can be fuzzy',
'`rs replay [sources]` — send instant replay; source names can be fuzzy',
'`rs bridge` — show chat bridge status for this server',
'`rs bridge here <global|private>` — set chat bridge to this channel',
'`rs bridge mode <global|private>` — change chat bridge mode',
'`rs bridge off` — disable chat bridge for this server',
'`rs lights <status|lock|unlock>` — show or change room light lock state',
'`rs kick <user> [reason]` — remove a user from their current rover; use `user | reason` for multi-word names',
'`rs lock <rover>` — lock a rover; rover names can be fuzzy',
'`rs unlock <rover>` — unlock a rover; rover names can be fuzzy',
'`rs mode <open|turns|admin|lockdown>` — change server mode',
'`rs reason [text|clear]` — show or set admin mode reason',
'`rs goal [text|clear]` — show or set global objective',
'`rs verify list` — list verified users (lockdown admins)',
'`rs verify remove <cookieUserId|nickname>` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)',
'`rs deter list` — list deterred users (lockdown admins)',
'`rs deter ban <cookieUserId|nickname|ip>` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)',
'`rs deter unban <id|cookieUserId|nickname|ip>` — remove deterrence; nicknames can be fuzzy or multi-word (lockdown admins)',
'`ts` — show time status',
].join('\n');
`\`${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 };
@@ -22,6 +22,21 @@ function createCommandHandlers(deps) {
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
@@ -38,6 +53,20 @@ function createCommandHandlers(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();
@@ -46,11 +75,12 @@ function createCommandHandlers(deps) {
// 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 (lower === 'ts') return handleTimeStatusCommand(message);
if (!/^rs(?:\s|$)/i.test(content)) return;
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return handleTimeStatusCommand(message);
const tokens = content.split(/\s+/);
tokens.shift();
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);
@@ -77,7 +107,7 @@ function createCommandHandlers(deps) {
case 'status':
return handleStatusCommand(message, rest);
case 'help':
return message.reply(formatHelp());
return message.reply(formatHelp({ commandPrefix, timeStatusCommand }));
case 'replay':
return handleReplayCommand(message, tokens.join(' '));
case 'bridge':
@@ -101,7 +131,7 @@ function createCommandHandlers(deps) {
case 'deter':
return handleDeterCommand(message, tokens);
default:
return message.reply(formatHelp());
return message.reply(formatHelp({ commandPrefix, timeStatusCommand }));
}
}
@@ -21,7 +21,8 @@ function splitSelectorAndReason(rawText) {
/*
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: `rs kick bob being reckless`.
so quick admin commands stay short, for example:
`<configured-prefix> kick bob being reckless`.
*/
return {
selector: normalizeText(text.slice(0, pipeIndex)),
@@ -61,9 +62,9 @@ function buildKickCandidates({ io, roverManager, assignmentService, getNickname
.filter(Boolean);
}
function resolveKickTarget(selector, candidates) {
function resolveKickTarget(selector, candidates, commandPrefix = 'rs') {
const query = normalizeSearchText(selector);
if (!query) return { error: 'Specify a user to kick. Example: `rs kick nickname reason`' };
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 ||
@@ -96,7 +97,11 @@ function resolveKickTarget(selector, candidates) {
return { target: first.item };
}
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions }) {
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');
@@ -106,7 +111,7 @@ function createKickCommand({ io, roverManager, getNickname, sanitizeMentions })
assignmentService,
getNickname,
});
const resolved = resolveKickTarget(selector, candidates);
const resolved = resolveKickTarget(selector, candidates, commandPrefix);
if (resolved.error) {
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
return;
@@ -12,10 +12,15 @@ function describeLightPolicy(lightPolicy = {}) {
return 'Room lights are unlocked.';
}
function createLightsCommand({ homeAssistantService, sanitizeMentions }) {
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 `rs lights` safe to type while still exposing
// the explicit mutating forms as `rs lights lock` and `rs lights unlock`.
// 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) {
@@ -36,7 +41,7 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions }) {
if (action !== 'lock' && action !== 'unlock') {
await message.reply({
content: 'Invalid lights command. Use `rs lights lock`, `rs lights unlock`, or `rs lights status`.',
content: `Invalid lights command. Use \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
allowedMentions: { parse: [], repliedUser: false },
});
return;
@@ -1,12 +1,15 @@
// Discord Lock Command
// Purpose: Handles `rs lock` and `rs unlock` operations for rover availability control.
// 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 }) {
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: `rs lock alpha`', allowedMentions: { parse: [], repliedUser: false } });
await message.reply({ content: `Specify a rover ID. Example: \`${commandPrefix} lock alpha\``, allowedMentions: { parse: [], repliedUser: false } });
return;
}
try {
@@ -1,5 +1,5 @@
// Discord Mode Command
// Purpose: Handles `rs mode` updates from Discord admins.
// 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 = []) {
@@ -1,5 +1,5 @@
// Discord Time Status Command
// Purpose: Handles `ts` command to show timezone snapshots.
// Purpose: Handles the configured time-status shortcut to show timezone snapshots.
// Scope: Builds a concise time embed for common zones and server local zone.
const { EmbedBuilder } = require('discord.js');
@@ -3,7 +3,10 @@
// Scope: Supports list and remove subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers');
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions }) {
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 } });
@@ -18,7 +21,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
}
if (action === 'remove') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: 'Usage: `rs verify remove <cookieUserId|nickname>`', allowedMentions: { parse: [], repliedUser: false } });
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 } });
@@ -32,7 +35,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
return message.reply({ content: 'Unknown verify command. Use `rs verify list` or `rs verify remove <cookieUserId|nickname>`.', 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 } });
};
}
+18 -1
View File
@@ -54,6 +54,15 @@ const { createIntegrations } = require('./integrations');
const config = loadConfig();
const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.token);
// 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));
@@ -201,7 +210,15 @@ const integrationHandlers = integrations.register();
function isTextCommand(content) {
const clean = String(content || '').trim();
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
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);
}
function isBridgeChannelMessage(message) {