diff --git a/plans/onvif-reolink.md b/plans/onvif-reolink.md new file mode 100644 index 00000000..f23c28d0 --- /dev/null +++ b/plans/onvif-reolink.md @@ -0,0 +1,36 @@ +# ONVIF first, reolink specifics second PTZ camera integration + +## what where who how +- adding support for a reolink PTZ camera +- ideally control everything over ONVIF + - if needed for some of the special features, use https://github.com/verheesj/reolink-api +- VIP (verified user) feature only +- due to upload bandwidth limitations (ONLY UPLOAD TO USERS MATTERS HERE NOT INTERNAl NETWORK STUFF), only one person should be on the camera at a time. only one person at a time should view + - the ptz camera should have a queue and turns that are like 5 mintues long or so, so no one can hog it + - if you are the camera operator, you are not on a rover. ever. + - if you are a spectator, you can see the snapshots for it + - local spectators should get full video like they already do now though +- the camera needs to be a replay source + + +## UI flow: +- whole UI should be very technical and utilitarian + - use cardframe for everything + - match global styling +- new card in VIP tab + - shows whoevers on the camera + - a very slow snapshot of the camera view + - maybe some other stats + - a big button to open the camera controller +- the fullscreen camera interface + - the rest of the site needs to go away when this is open + - desktop + - takes over rover controls + - movement controls pan and tilt + - camera up / down controls zoom + - headlight and laser buttons hopefully control spotlight and IR light or something + - fullscreen inteface + - right sidebar with info and controls info + - mobile + - uhhh idk + - \ No newline at end of file diff --git a/server/config.example.yaml b/server/config.example.yaml index 70ed0ff4..fefc2631 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -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 ` bridge` commands replay: "123456789012345678" humanAlerts: "123456789012345678" roles: diff --git a/server/src/services/discordBotService/commands/bridge.js b/server/src/services/discordBotService/commands/bridge.js index 92d2b138..1ff1388b 100644 --- a/server/src/services/discordBotService/commands/bridge.js +++ b/server/src/services/discordBotService/commands/bridge.js @@ -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 ` first.', allowedMentions: { parse: [], repliedUser: false } }); + if (!current?.channelId) return message.reply({ content: `No chat bridge channel set. Use \`${commandPrefix} bridge here \` 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 } }); }; } diff --git a/server/src/services/discordBotService/commands/deter.js b/server/src/services/discordBotService/commands/deter.js index 1ddbb7b7..4c04e1ea 100644 --- a/server/src/services/discordBotService/commands/deter.js +++ b/server/src/services/discordBotService/commands/deter.js @@ -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 `', allowedMentions: { parse: [], repliedUser: false } }); + if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban \``, 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 `', allowedMentions: { parse: [], repliedUser: false } }); + if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter unban \``, 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 `, or `rs deter unban `.', allowedMentions: { parse: [], repliedUser: false } }); + return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban \`, or \`${commandPrefix} deter unban \`.`, allowedMentions: { parse: [], repliedUser: false } }); }; } diff --git a/server/src/services/discordBotService/commands/help.js b/server/src/services/discordBotService/commands/help.js index 6a27923f..fa281511 100644 --- a/server/src/services/discordBotService/commands/help.js +++ b/server/src/services/discordBotService/commands/help.js @@ -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 ` — set chat bridge to this channel', - '`rs bridge mode ` — change chat bridge mode', - '`rs bridge off` — disable chat bridge for this server', - '`rs lights ` — show or change room light lock state', - '`rs kick [reason]` — remove a user from their current rover; use `user | reason` for multi-word names', - '`rs lock ` — lock a rover; rover names can be fuzzy', - '`rs unlock ` — unlock a rover; rover names can be fuzzy', - '`rs mode ` — 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 ` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)', - '`rs deter list` — list deterred users (lockdown admins)', - '`rs deter ban ` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)', - '`rs deter unban ` — 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 \` — set chat bridge to this channel`, + `\`${prefix} bridge mode \` — change chat bridge mode`, + `\`${prefix} bridge off\` — disable chat bridge for this server`, + `\`${prefix} lights \` — show or change room light lock state`, + `\`${prefix} kick [reason]\` — remove a user from their current rover; use \`user | reason\` for multi-word names`, + `\`${prefix} lock \` — lock a rover; rover names can be fuzzy`, + `\`${prefix} unlock \` — unlock a rover; rover names can be fuzzy`, + `\`${prefix} mode \` — 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 \` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)`, + `\`${prefix} deter list\` — list deterred users (lockdown admins)`, + `\`${prefix} deter ban \` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)`, + `\`${prefix} deter unban \` — remove deterrence; nicknames can be fuzzy or multi-word (lockdown admins)`, + timeCommand ? `\`${timeCommand}\` — show time status` : '', + ].filter(Boolean).join('\n'); } module.exports = { formatHelp }; diff --git a/server/src/services/discordBotService/commands/index.js b/server/src/services/discordBotService/commands/index.js index 290571a3..08b73eeb 100644 --- a/server/src/services/discordBotService/commands/index.js +++ b/server/src/services/discordBotService/commands/index.js @@ -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 })); } } diff --git a/server/src/services/discordBotService/commands/kick.js b/server/src/services/discordBotService/commands/kick.js index 08ac270d..be4880c6 100644 --- a/server/src/services/discordBotService/commands/kick.js +++ b/server/src/services/discordBotService/commands/kick.js @@ -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: + ` 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; diff --git a/server/src/services/discordBotService/commands/lights.js b/server/src/services/discordBotService/commands/lights.js index 5eec4d1d..7f7007f6 100644 --- a/server/src/services/discordBotService/commands/lights.js +++ b/server/src/services/discordBotService/commands/lights.js @@ -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; diff --git a/server/src/services/discordBotService/commands/lock.js b/server/src/services/discordBotService/commands/lock.js index 6edf8f0d..42c5cfc8 100644 --- a/server/src/services/discordBotService/commands/lock.js +++ b/server/src/services/discordBotService/commands/lock.js @@ -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 { diff --git a/server/src/services/discordBotService/commands/mode.js b/server/src/services/discordBotService/commands/mode.js index fe7783af..88052c51 100644 --- a/server/src/services/discordBotService/commands/mode.js +++ b/server/src/services/discordBotService/commands/mode.js @@ -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 = []) { diff --git a/server/src/services/discordBotService/commands/timeStatus.js b/server/src/services/discordBotService/commands/timeStatus.js index 7b7e4d10..5f5ef472 100644 --- a/server/src/services/discordBotService/commands/timeStatus.js +++ b/server/src/services/discordBotService/commands/timeStatus.js @@ -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'); diff --git a/server/src/services/discordBotService/commands/verify.js b/server/src/services/discordBotService/commands/verify.js index ba9356d2..91b1fd09 100644 --- a/server/src/services/discordBotService/commands/verify.js +++ b/server/src/services/discordBotService/commands/verify.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 `', allowedMentions: { parse: [], repliedUser: false } }); + if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} verify remove \``, 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 `.', allowedMentions: { parse: [], repliedUser: false } }); + return message.reply({ content: `Unknown verify command. Use \`${commandPrefix} verify list\` or \`${commandPrefix} verify remove \`.`, allowedMentions: { parse: [], repliedUser: false } }); }; } diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index 319d29d1..4e803742 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -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) { diff --git a/to-do.md b/to-do.md index 28ae36ba..71e3d7e8 100644 --- a/to-do.md +++ b/to-do.md @@ -4,7 +4,8 @@ 4. add config to disable client snapshot forcing, disable bandwidth saving 5. add admin ui for VIP and private requests instead of only through discord 6. make discord bots that can be fine with multiple in one server -7. add more background gap themes +7. pull page / tab title from session, use the name from profile of interinstance if enabled, if not just default to old one +8. add more background gap themes 8. fix this: `Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92 Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });