Compare commits

...
Author SHA1 Message Date
legop3 8d1761afe2 forgot to build roverd binary lol lol 2026-08-04 02:08:56 -04:00
legop3 c7c52eb39c switched rover <-> server streams to rtsptcp, and moved mediamtx to be a server owned and configured child process! 2026-08-04 01:47:03 -04:00
legop3 28fcbad902 better replays panels 2026-08-03 03:48:30 -04:00
legop3 208b89fd7f add identity to socket auth hopefully will fix a lot of probem 2026-08-03 01:09:21 -04:00
legop3 15a60a58e6 moving a LOT of stuff around in web ui 2026-08-02 23:06:30 -04:00
legop3 3ebd1c7f9c adding added ad slot support for adddss 2026-08-01 01:58:34 -04:00
legop3 7fdcb53041 update 2026-07-31 22:28:35 -04:00
legop3 a2dde4fd3d image updates 2026-07-31 22:18:57 -04:00
legop3 8c5d98bed6 analytics and embed meta update!!! 2026-07-31 22:12:04 -04:00
legop3 1aecab66e7 Merge pull request #19 from Saul5662/feat/fun-commands
feat(commands): add a fun command category with 18 public `rs` commands
2026-07-29 01:55:28 -04:00
legop3 ef18ef89e0 Merge pull request #20 from Saul5662/fix/gain-ambiguous-selector
fix(commands): resolve duplicate nicknames in `rs gain`, add a help subcommand
2026-07-29 00:10:01 -04:00
Saul5662andClaude Opus 5 46bbe5c531 fix(commands): resolve duplicate nicknames in rs gain, add a help subcommand
`rs gain grant Saul` failed with "Selector matched multiple records. Suggestions:
Saul, cu_a28...33ab5c, Saul, cu_5a5...b6add3." Nicknames are not unique — one
person re-verifying from a new browser produces a second verified record with the
same name — so an exact nickname match can legitimately return several records,
and the shared resolver refuses on ambiguity.

That refusal is correct for deter, kick, and verify, where acting on the wrong of
two plausible targets is a moderation mistake. It is wrong for gain: granting a
volume ceiling to the wrong account belonging to the same person is recoverable.
So the disambiguation is local to this command and resolvers.js is untouched.

Order of preference on an exact match with several hits:

1. The account that is currently online, since that is who the admin is reacting
   to. Sockets are deduped to user ids first, so extra tabs do not matter.
2. Otherwise the first stored record.

Either way the reply says which happened and how many accounts shared the name.
A selector with no exact match still goes through the shared fuzzy resolver, so
typo tolerance and the existing error text are unchanged.

Also adds `rs gain help`, which lists the subcommands and explains the selector
and the online-wins rule. The previous unknown-subcommand fallback listed the
subcommands inline; it now shares the same help text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 05:08:25 +01:00
Saul5662andClaude Opus 5 dc8267073b feat(commands): play a bonk sound on the bonked user's rover
`rs bonk` now publishes a `fun.bonked` event carrying the rover the target is
currently driving, and a new audioForwardService listener plays a sound file on
it. Wired as an event rather than a direct call so the command layer stays
unaware of ffmpeg, matching how the charging-complete cue is already done.

The sound file goes at `server/assets/bonk.wav` and is NOT committed here. Note
that `server/assets` is the correct home rather than `server/public`: the webui
builds to `../server/public` with `emptyOutDir: true`, so anything stored there
is deleted by the next build.

Details:

- The audio is rate limited per rover on a 20s window, separate from the 4s text
  cooldown. Playback interrupts whatever that rover is forwarding, including a
  live microphone, so a group of people cannot chain it against one driver.
- No sound plays if the target is not currently driving, is not a real user, or
  is the caller themselves. The text bonk and the tally still work in all cases.
- A missing sound file logs once and skips, so the command works on a server that
  never installs one. A playback failure is caught and logged rather than
  surfacing as a failed chat command.
- Discord bonks play the sound too; only commands needing the caller's own socket
  are unavailable from there.

Server suite: 138 passing, 0 failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 05:05:36 +01:00
Saul5662andClaude Opus 5 eb0db3508f fix(commands): remove a stray NUL byte from the pairSeed separator
`pair.join('\0')` was written where `pair.join(' ')` was meant. The NUL made git
classify funHelpers.js as a binary file, so it showed as `Bin 0 -> 6397 bytes`
instead of a reviewable diff. The seed stayed deterministic either way, so no
behavior changes and the tests were already passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:58:24 +01:00
legop3 6aee49bfdb built webui lol 2026-07-28 23:57:43 -04:00
Saul5662andClaude Opus 5 a9428d3d72 test(commands): cover fun commands, cooldowns, and the dispatcher permission gate
89 new node:test cases. The dispatcher suite is the important one: it pins the
behavior the registry-driven permission gate replaced a hardcoded action list
with, asserting that admin-only commands are still admin-only, that the
self-policing commands (goal/reason/verify/deter) still reach their own handlers
as a non-admin, that unknown actions are still not public, and that `rsvp` is
still not a command.

Also covered:

- cooldown boundaries, including that a refused call does not extend the window
- actor identity keying across transports, and that extra browser tabs do not
  make a target ambiguous
- honk/spin refusing without drive control and being unreachable from Discord
- spin honouring applyPrivateDriveSafety instead of bypassing it
- boo speaking only canned text, never anything the caller typed
- disco obeying the room-light lock and restoring the lights when it ends
- mention sanitizing on replies and on stored nicknames rendered by bonkboard
- the stats store degrading to empty on a corrupt or wrong-shaped file

Full server suite: 126 passing, 0 failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:57:17 +01:00
legop3 30e961d727 Merge pull request #18 from Saul5662/feat/user-volume-gains
feat(audio): per-user horn/TTS/forward gains with admin-capped ceilings and a VIP boost flag
2026-07-28 23:55:29 -04:00
Saul5662andClaude Opus 5 a2fbbc100e feat(commands): add a fun command category with 18 public rs commands
Adds a `fun` category to the operator command registry, reachable identically
from site chat and Discord:

- text: bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted
- counters: bonkboard, pet, snitch
- hardware: honk, boo, spin, disco, vibecheck

Supporting pieces:

- `permission: 'public'` in the registry. The dispatcher previously decided
  non-admin access with a hardcoded chain of `action !== '...'` comparisons, so
  every new public command needed a dispatcher edit. That chain is replaced by a
  registry lookup plus SELF_GATED_ACTIONS, which names the commands that enforce
  their own permissions internally (goal/reason are read-public write-admin;
  verify/deter reject non-lockdown-admins themselves). Existing behavior for
  every pre-existing command is unchanged.
- `cooldowns.js`, a per-actor per-command in-memory gate. Site chat's own rate
  limit is per-socket-per-message and does not bound a specific command, so
  without this one person could turn `rs honk` into a siren. Site chat rebuilds
  its router per message, so the gate is created at module scope there and
  injected.
- `funStatsService`, a small JSON store for the persistent tallies. Counters are
  keyed by an actor key spanning transports (`user:<id>` / `discord:<id>`), and a
  Discord id has no row in `users`, so `user_feature_state` could not hold them
  without violating its foreign key.

Safety notes:

- `issueCommand` is the raw rover transport and performs none of the ownership,
  deterrence, or private-safety checks the socket `command` handler applies, so
  honk and spin re-check `canDrive` themselves and spin re-applies
  `applyPrivateDriveSafety`. Both are therefore site-chat only: a Discord message
  has no socket and can never satisfy those checks.
- `boo` speaks a canned taunt rather than caller-supplied text, so it cannot
  become an unmoderated TTS channel aimed at whoever is nearest a rover.
- `disco` obeys the existing room-light lock and the homeAssistant feature gate.
- The whole fun category is suspended in lockdown mode.
- Mute and deterrence already stop command-shaped chat before the router runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:51:40 +01:00
Saul5662andClaude Opus 5 42a7cefeba build: drop server/public build artifacts from this branch
The committed bundle was produced by a local `vite build`, which runs without
the injected analytics snippet. That stripped the page-wide `window.roverAnalytics`
Umami adapter and both analytics.otter.land script tags out of
server/public/index.html, and repointed the bundle hash at a local build.

Revert server/public to its origin/main state so this branch is source-only.
server/public should be rebuilt on deploy, where the analytics snippet exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:36:51 +01:00
Saul5662andClaude Opus 5 7272c8b4fa fix(commands): say "User not found" instead of "Selector not found"
The identity resolver's fuzzy-miss error was labelled "Selector", which is
internal jargon — the operator running `rs gain grant <vip>` or `rs kick
<user>` typed a username, not a "selector". Relabel that one message to
"User" so the chat reply reads plainly.

The sibling "Selector matched multiple records." and "Selector required."
messages are intentionally left alone for now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:34:19 +01:00
Saul5662andClaude 0e1ad8c6a0 feat(webui): Volume settings section and admin boost-cap editor
Adds a server-backed Volume card to page settings with horn, TTS, and
microphone sliders. Each slider is a 0-100% share of the ceiling the
server resolved for that user, and the card shows the resulting
multiplier plus whether the user is on the normal global limit or a
raised VIP limit. A slider whose ceiling is zero renders disabled rather
than pretending to do something.

The admin section gains sliders for the VIP boost hard caps beside the
existing global gains. Both editors now render from one GAIN_FIELDS list
through a shared GainSlider instead of six copied slider blocks.

Includes the rebuilt bundle so the served UI matches the source.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:10:26 +01:00
Saul5662andClaude 09c1257578 feat(commands): add 'rs gain' to grant the VIP audio gain boost
Admins can now run 'rs gain list|grant <vip>|revoke <vip>' from web chat or
Discord. Grant matches only against the verified list and revoke only
against current holders, so a nickname shared with an unverified visitor
reports not-found rather than resolving to someone ineligible. The action
joins the moderation set so lockdown narrows it to lockdown admins.

Also extracts the ceiling math into audioLevelsService/gainMath.js and
covers both it and the command with node:test suites.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:07:44 +01:00
Saul5662andClaude f3b349bb4a feat(audio): per-user horn/TTS/forward volume with admin-capped ceilings
Every user now gets a personal 0-1 volume for horn, TTS, and mic forward.
The value is stored as a fraction of the ceiling that applies to them, so
lowering the global admin gain quiets everyone immediately instead of
leaving stale absolute values behind.

Ceilings resolve in three layers: the global admin gain is the default
ceiling; the audioGainBoost flag raises it to an admin-editable hard cap
(default 0.5x horn, 0.8x TTS, 0.4x forward); Math.max keeps the flag from
ever lowering a ceiling if the global gain is set higher than a cap.

Preferences live in identity feature state rather than a cookie so they
follow the user and cannot be raised client-side. The rover exposes gain
as three ALSA masters, so the resolved gains pushed to a rover are those
of the socket currently holding audio control -- re-pushed on driver
join/leave and every turn rotation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:04:22 +01:00
Saul5662andClaude 81f52c29d8 feat(identity): add audioGainBoost user flag
Adds an audio_gain_boost_enabled status column (plus _at/_by audit
fields) to user_status, exposed as user.audioGainBoost and copied onto
sockets as socket.data.hasAudioGainBoost. The flag marks VIPs allowed to
raise their personal horn/TTS/mic gain ceiling past the global admin gain
settings.

Grant/revoke goes through verificationService so socket flags refresh and
an event is published. Resolution is restricted to verified users, so an
unverified visitor sharing a nickname can never be matched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 03:59:47 +01:00
legop3 d000b8f4f8 disable hidden video disconnect by default 2026-07-26 21:08:24 -04:00
legop3 0f9bed9c99 slopodometering 2026-07-25 16:37:58 -04:00
legop3 8642fbac3c slopreporting 2026-07-25 15:54:38 -04:00
legop3 3a26b4871a slopfixing queue stuffs 2026-07-21 23:38:57 -04:00
legop3 b26daed93f better banning, new deter mute, replay discord fixes. 2026-07-21 22:50:12 -04:00
legop3 fb9565faf9 hapticks 3 3 3 3 3 3 2026-07-21 18:38:01 -04:00
legop3 358aa0b1d6 replay timestamps in filenamess 2026-07-21 16:29:10 -04:00
legop3 0ecee03f64 top video when tabbed out... 2026-07-19 23:48:03 -04:00
legop3 90d4f778a5 SLOP ANALYTICS!! 2026-07-19 22:43:56 -04:00
legop3 b261a4bfa2 Merge pull request #17 from legop3/aspectratioing
forgot to leave branch lole
2026-07-19 20:56:36 -04:00
190 changed files with 10067 additions and 2139 deletions
+4 -1
View File
@@ -26,7 +26,7 @@ webui/package-lock.json
!server/data/barcode-registry.json !server/data/barcode-registry.json
webui/src/config/analytics.jsx webui/src/config/analytics.jsx
webui/src/config/driverAnalytics.json webui/src/config/driverAnalytics.json
webui/src/config/analytics.html server/data/analytics.html
plans/barcodegames.txt plans/barcodegames.txt
.gitignore .gitignore
server/data/identity.sqlite server/data/identity.sqlite
@@ -34,3 +34,6 @@ server/data/barcode-games.json
server/data/identity.sqlite-shm server/data/identity.sqlite-shm
server/data/identity.sqlite-wal server/data/identity.sqlite-wal
server/src/services/balanceBoardService/native/balance_board_worker server/src/services/balanceBoardService/native/balance_board_worker
server/data/fleet-reports.sqlite
server/data/fleet-reports.sqlite-shm
server/data/fleet-reports.sqlite-wal
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+117
View File
@@ -0,0 +1,117 @@
# Simple spectator bot
A spectator bot connects to the rover server with Socket.IO. It can receive the current session, read chat, and send messages that are visually tagged as bot messages.
## Install
Create a small Node.js project and install the Socket.IO client:
```bash
npm install socket.io-client
```
## Example bot
Create `bot.js`:
```js
import { io } from 'socket.io-client';
// Replace this with the public URL of the MultiRoombaRover server.
const socket = io('https://your-rover-server.example', {
// Match the transports supported by the server while retaining polling as a
// fallback for networks or proxies that do not allow WebSocket connections.
transports: ['websocket', 'polling'],
});
// Socket.IO acknowledgements use callbacks. This small wrapper turns them into
// promises so setup failures and rejected chat messages are easy to handle.
function emitWithAck(event, payload) {
return new Promise((resolve, reject) => {
socket.emit(event, payload, (response = {}) => {
if (response.error) {
reject(new Error(response.error));
return;
}
resolve(response);
});
});
}
socket.on('connect', async () => {
console.log('Connected:', socket.id);
try {
// Set the name that will appear beside this connection and its messages.
await emitWithAck('nickname:set', {
nickname: 'My spectator bot',
});
// Ask the server to make this passive connection a spectator. Performing
// this after every connection also restores the role after a reconnect.
await emitWithAck('session:setRole', {
role: 'spectator',
});
console.log('Connected as a spectator');
} catch (error) {
console.error('Spectator setup failed:', error.message);
}
});
// Each session:sync event is a complete current session snapshot. Replace any
// previously stored session with this object instead of merging snapshots.
socket.on('session:sync', (session) => {
console.log('Session:', session);
});
// chat:init contains the recent chat history available when the bot connects.
socket.on('chat:init', (messages) => {
console.log('Recent chat:', messages);
});
// chat:message fires whenever a new message is broadcast, including messages
// sent by this bot itself.
socket.on('chat:message', (message) => {
console.log(`${message.nickname || 'Unknown'}: ${message.text}`);
});
socket.on('disconnect', (reason) => {
console.log('Disconnected:', reason);
});
// Setting bot to true adds the normal bot tag to the displayed chat message.
// It does not grant the connection any additional permissions.
function sendBotMessage(text) {
return emitWithAck('chat:send', {
text,
bot: true,
});
}
// Send one example message after the connection has had time to finish setup.
// A real bot would call sendBotMessage from its own message-handling logic.
setTimeout(() => {
sendBotMessage('Hello from my spectator bot!').catch((error) => {
console.error('Message failed:', error.message);
});
}, 5000);
```
Run it with:
```bash
node bot.js
```
## Events used
- `nickname:set` sets the bot's visible nickname.
- `session:setRole` changes the connection to a spectator.
- `session:sync` provides the latest complete session state.
- `chat:init` provides recent chat history after connecting.
- `chat:message` provides new chat messages.
- `chat:send` sends a chat message. Include `bot: true` to give it the bot tag.
The server can reject spectator access or a chat message. Always check the acknowledgement callback, as the example does, so those errors are not silently ignored.
+5 -3
View File
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1 exit 1
fi fi
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The # Load KEY=VALUE pairs from media.env without evaluating shell syntax. The forward URL is
# forward URL is data produced by roverd, and treating it as shell code would # data produced by roverd and must never be interpreted as executable shell code.
# break on normal SRT query-string characters such as '&'.
load_env_file() { load_env_file() {
local content="" local content=""
@@ -95,6 +94,9 @@ run_pipeline() {
-flags low_delay -flags low_delay
-analyzeduration 200k -analyzeduration 200k
-probesize 32k -probesize 32k
# The forwarded-audio URL is RTSP. Pinning TCP avoids ffmpeg negotiating the
# separate unreliable RTP/UDP transport that the server intentionally disables.
-rtsp_transport tcp
-i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}" -i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}"
-vn -vn
) )
+20 -8
View File
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1 exit 1
fi fi
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs # Load KEY=VALUE pairs from media.env without evaluating shell syntax. URLs are data;
# contain characters such as '&' and '#!', so sourcing this file would treat a # sourcing this file would unnecessarily treat server-provided values as shell code.
# data file as code and can split a valid URL into shell control operators.
load_env_file() { load_env_file() {
local content="" local content=""
@@ -88,6 +87,7 @@ else
fi fi
run_pipeline() { run_pipeline() {
local -a pipeline_statuses=()
local ffmpeg_args=( local ffmpeg_args=(
-hide_banner -hide_banner
-loglevel warning -loglevel warning
@@ -123,13 +123,14 @@ run_pipeline() {
-frame_duration 20 -frame_duration 20
-compression_level 0 -compression_level 0
# Mirror the video publisher's MPEG-TS low-latency settings. Without # RTSP carries the existing Opus stream directly, avoiding MediaMTX's costly
# these, ffmpeg is allowed to hold packets for mux timing, which is # MPEG-TS demux without changing microphone capture or encoding quality. TCP is
# exactly the wrong tradeoff for live rover feedback. # required for the same reliable local-network behavior as the video publisher.
-flush_packets 1 -flush_packets 1
-muxdelay 0 -muxdelay 0
-muxpreload 0 -muxpreload 0
-f mpegts -f rtsp
-rtsp_transport tcp
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}" "${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
) )
@@ -146,6 +147,17 @@ run_pipeline() {
# latency compared with the old 65,536-byte buffer. # latency compared with the old 65,536-byte buffer.
arecord -D "${CAPTURE_DEVICE}" -f S32_LE -c "${ROVERD_AUDIO_CAPTURE_CHANNELS}" -r "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}" -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \ arecord -D "${CAPTURE_DEVICE}" -f S32_LE -c "${ROVERD_AUDIO_CAPTURE_CHANNELS}" -r "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}" -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \
| "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}" | "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}"
pipeline_statuses=("${PIPESTATUS[@]}")
# PIPESTATUS belongs to the pipeline that just finished and is replaced by the next shell
# command. Capture it immediately, then return the publisher failure first because that is
# normally the reason arecord receives a secondary broken pipe.
LAST_ARECORD_STATUS="${pipeline_statuses[0]:-unknown}"
LAST_FFMPEG_STATUS="${pipeline_statuses[1]:-unknown}"
if [[ "${LAST_FFMPEG_STATUS}" != "0" ]]; then
return "${LAST_FFMPEG_STATUS}"
fi
return "${LAST_ARECORD_STATUS}"
} }
trap 'kill 0 2>/dev/null' EXIT INT TERM trap 'kill 0 2>/dev/null' EXIT INT TERM
@@ -154,6 +166,6 @@ while true; do
if run_pipeline; then if run_pipeline; then
exit 0 exit 0
fi fi
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2 echo "Audio-only publisher exited arecord=${LAST_ARECORD_STATUS:-unknown} ffmpeg=${LAST_FFMPEG_STATUS:-unknown}, restarting in 2s..." >&2
sleep 2 sleep 2
done done
+6 -4
View File
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1 exit 1
fi fi
# Load roverd's generated media.env as data instead of sourcing it as shell. # Load roverd's generated media.env as data instead of sourcing it as shell. URLs are
# The SRT publish URL contains normal query-string characters like '&' and '#!', # configuration data, so evaluating the file would be both fragile and unnecessary.
# so evaluating the file would be both fragile and unnecessary.
load_env_file() { load_env_file() {
local content="" local content=""
@@ -103,6 +102,8 @@ if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then
fi fi
run_pipeline() { run_pipeline() {
# Keep laptop rovers on the same transport contract as Pi camera rovers. This changes
# only the encoded stream's carrier; V4L2 capture and H264 encoding remain untouched.
"${FFMPEG_BIN_PATH}" \ "${FFMPEG_BIN_PATH}" \
-hide_banner \ -hide_banner \
-loglevel warning \ -loglevel warning \
@@ -130,7 +131,8 @@ run_pipeline() {
-flush_packets 1 \ -flush_packets 1 \
-muxdelay 0 \ -muxdelay 0 \
-muxpreload 0 \ -muxpreload 0 \
-f mpegts \ -f rtsp \
-rtsp_transport tcp \
"${ROVERD_VIDEO_PUBLISH_URL}" "${ROVERD_VIDEO_PUBLISH_URL}"
} }
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
# These publishers contain hardware-facing infinite retry loops, so executing them in a unit
# test would require unsafe process-group traps and fake camera/ALSA devices. Pin the small
# transport boundary directly instead: every publisher must request RTSP/TCP and none may
# reintroduce the high-latency MPEG-TS muxer.
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
assert_rtsp_tcp() {
local file="$1"
if ! grep -q -- '-f rtsp' "$file"; then
echo "Missing RTSP muxer in $file" >&2
exit 1
fi
if ! grep -q -- '-rtsp_transport tcp' "$file"; then
echo "Missing RTSP/TCP pin in $file" >&2
exit 1
fi
if grep -q -- '-f mpegts' "$file"; then
echo "Unexpected MPEG-TS muxer in $file" >&2
exit 1
fi
}
assert_rtsp_tcp "$SCRIPT_DIR/video-publisher.sh"
assert_rtsp_tcp "$SCRIPT_DIR/debian-laptop-video-publisher.sh"
assert_rtsp_tcp "$SCRIPT_DIR/audio-only-publisher.sh"
# The speaker path reads rather than publishes, so it has no output muxer. It must still pin
# RTSP/TCP before its input URL to match the server's TCP-only listener.
if ! grep -q -- '-rtsp_transport tcp' "$SCRIPT_DIR/audio-forward-listener.sh"; then
echo "Missing RTSP/TCP input pin in audio-forward-listener.sh" >&2
exit 1
fi
echo "Media publisher transport checks passed"
+6 -1
View File
@@ -110,6 +110,10 @@ else
fi fi
run_pipeline() { run_pipeline() {
# MPEG-TS added most of the former rover-to-browser latency inside MediaMTX's
# demuxer. RTSP carries the same encoded H264 without changing the camera or codec.
# TCP is explicit because plain RTSP/RTP over UDP has no retransmission and proved
# unreliable even though MediaMTX still reported the incomplete stream as ready.
"${LIBCAMERA_BIN_PATH}" \ "${LIBCAMERA_BIN_PATH}" \
--inline \ --inline \
--timeout 0 \ --timeout 0 \
@@ -142,7 +146,8 @@ run_pipeline() {
-flush_packets 1 \ -flush_packets 1 \
-muxdelay 0 \ -muxdelay 0 \
-muxpreload 0 \ -muxpreload 0 \
-f mpegts \ -f rtsp \
-rtsp_transport tcp \
"${ROVERD_VIDEO_PUBLISH_URL}" "${ROVERD_VIDEO_PUBLISH_URL}"
} }
+6 -6
View File
@@ -13,7 +13,7 @@ write_media_env_placeholder() {
# Managed by roverd; placeholder values will be overwritten at runtime. # Managed by roverd; placeholder values will be overwritten at runtime.
ROVERD_VIDEO_ENABLE=1 ROVERD_VIDEO_ENABLE=1
ROVERD_VIDEO_PUBLISHER=pi-libcamera ROVERD_VIDEO_PUBLISHER=pi-libcamera
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME
ROVERD_VIDEO_DEVICE= ROVERD_VIDEO_DEVICE=
ROVERD_VIDEO_INPUT_FORMAT= ROVERD_VIDEO_INPUT_FORMAT=
ROVERD_VIDEO_WIDTH=640 ROVERD_VIDEO_WIDTH=640
@@ -23,13 +23,13 @@ ROVERD_VIDEO_BITRATE=2000000
ROVERD_VIDEO_INVERT=1 ROVERD_VIDEO_INVERT=1
ROVERD_VIDEO_SENSOR_MODE=1296:972 ROVERD_VIDEO_SENSOR_MODE=1296:972
ROVERD_AUDIO_CAPTURE_ENABLE=0 ROVERD_AUDIO_CAPTURE_ENABLE=0
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio
ROVERD_AUDIO_CAPTURE_DEVICE=hw:0,0 ROVERD_AUDIO_CAPTURE_DEVICE=hw:0,0
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000 ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
ROVERD_AUDIO_CAPTURE_CHANNELS=2 ROVERD_AUDIO_CAPTURE_CHANNELS=2
ROVERD_AUDIO_CAPTURE_BITRATE=510000 ROVERD_AUDIO_CAPTURE_BITRATE=510000
ROVERD_AUDIO_PLAYBACK_ENABLE=1 ROVERD_AUDIO_PLAYBACK_ENABLE=1
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316 ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd
ROVERD_AUDIO_PLAYBACK_DEVICE=forward ROVERD_AUDIO_PLAYBACK_DEVICE=forward
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1 ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
@@ -43,7 +43,7 @@ ENV
# Managed by roverd; placeholder values will be overwritten at runtime. # Managed by roverd; placeholder values will be overwritten at runtime.
ROVERD_VIDEO_ENABLE=1 ROVERD_VIDEO_ENABLE=1
ROVERD_VIDEO_PUBLISHER=debian-laptop-v4l2 ROVERD_VIDEO_PUBLISHER=debian-laptop-v4l2
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME
ROVERD_VIDEO_DEVICE=/dev/video0 ROVERD_VIDEO_DEVICE=/dev/video0
ROVERD_VIDEO_INPUT_FORMAT=mjpeg ROVERD_VIDEO_INPUT_FORMAT=mjpeg
ROVERD_VIDEO_WIDTH=640 ROVERD_VIDEO_WIDTH=640
@@ -53,13 +53,13 @@ ROVERD_VIDEO_BITRATE=2000000
ROVERD_VIDEO_INVERT=0 ROVERD_VIDEO_INVERT=0
ROVERD_VIDEO_SENSOR_MODE= ROVERD_VIDEO_SENSOR_MODE=
ROVERD_AUDIO_CAPTURE_ENABLE=1 ROVERD_AUDIO_CAPTURE_ENABLE=1
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio
ROVERD_AUDIO_CAPTURE_DEVICE=default ROVERD_AUDIO_CAPTURE_DEVICE=default
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000 ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
ROVERD_AUDIO_CAPTURE_CHANNELS=2 ROVERD_AUDIO_CAPTURE_CHANNELS=2
ROVERD_AUDIO_CAPTURE_BITRATE=510000 ROVERD_AUDIO_CAPTURE_BITRATE=510000
ROVERD_AUDIO_PLAYBACK_ENABLE=1 ROVERD_AUDIO_PLAYBACK_ENABLE=1
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316 ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd
ROVERD_AUDIO_PLAYBACK_DEVICE=forward ROVERD_AUDIO_PLAYBACK_DEVICE=forward
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1 ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
+41 -34
View File
@@ -3,9 +3,11 @@ package roverd
import ( import (
"errors" "errors"
"fmt" "fmt"
"net"
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -77,10 +79,10 @@ type HornConfig struct {
} }
type MediaConfig struct { type MediaConfig struct {
// PublishPort is shared by the derived video, microphone, and forwarded-audio // RTSPPort is shared by the derived video, microphone, and forwarded-audio
// SRT URLs. Keeping it at this level prevents each nested block from needing // RTSP URLs. Keeping it at this level prevents each nested block from needing
// to repeat the same server port when the common MediaMTX listener is used. // to repeat the same server port when the common MediaMTX listener is used.
PublishPort int `yaml:"publishPort" json:"-"` RTSPPort int `yaml:"rtspPort" json:"-"`
Manage bool `yaml:"manage" json:"manage"` Manage bool `yaml:"manage" json:"manage"`
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"` HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
HealthInterval Duration `yaml:"healthInterval" json:"-"` HealthInterval Duration `yaml:"healthInterval" json:"-"`
@@ -96,7 +98,9 @@ type VideoMediaConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"` Service string `yaml:"service" json:"service,omitempty"`
Publisher string `yaml:"publisher" json:"publisher,omitempty"` Publisher string `yaml:"publisher" json:"publisher,omitempty"`
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` // PublishURL is derived during validation. It remains in rover metadata for server-side
// consumers, but is not a second hand-written endpoint in /etc/roverd.yaml.
PublishURL string `yaml:"-" json:"publishUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"` Device string `yaml:"device" json:"device,omitempty"`
InputFormat string `yaml:"inputFormat" json:"-"` InputFormat string `yaml:"inputFormat" json:"-"`
Width int `yaml:"width" json:"-"` Width int `yaml:"width" json:"-"`
@@ -113,7 +117,8 @@ type AudioCaptureConfig struct {
// normalized defaults so enabling it only requires flipping enabled: true. // normalized defaults so enabling it only requires flipping enabled: true.
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"` Service string `yaml:"service" json:"service,omitempty"`
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` // PublishURL follows the same derived-only contract as the video path.
PublishURL string `yaml:"-" json:"publishUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"` Device string `yaml:"device" json:"device,omitempty"`
SampleRate int `yaml:"sampleRate" json:"-"` SampleRate int `yaml:"sampleRate" json:"-"`
Channels int `yaml:"channels" json:"-"` Channels int `yaml:"channels" json:"-"`
@@ -127,7 +132,8 @@ type AudioPlaybackConfig struct {
// it needs to inject audio. // it needs to inject audio.
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"` Service string `yaml:"service" json:"service,omitempty"`
ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,omitempty"` // ForwardURL is derived because the server and rover must agree on the exact -fwd path.
ForwardURL string `yaml:"-" json:"forwardUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"` Device string `yaml:"device" json:"device,omitempty"`
Normalize bool `yaml:"normalize" json:"-"` Normalize bool `yaml:"normalize" json:"-"`
NormalizeFilter string `yaml:"normalizeFilter" json:"-"` NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
@@ -230,7 +236,7 @@ func LoadConfig(path string) (*Config, error) {
}, },
}, },
Media: MediaConfig{ Media: MediaConfig{
PublishPort: 9000, RTSPPort: 8554,
HealthInterval: Duration{Duration: 30 * time.Second}, HealthInterval: Duration{Duration: 30 * time.Second},
Video: VideoMediaConfig{ Video: VideoMediaConfig{
Enabled: true, Enabled: true,
@@ -349,8 +355,8 @@ func LoadConfig(path string) (*Config, error) {
if cfg.BRC.GPIOChip == "" { if cfg.BRC.GPIOChip == "" {
cfg.BRC.GPIOChip = "gpiochip0" cfg.BRC.GPIOChip = "gpiochip0"
} }
if cfg.Media.PublishPort <= 0 { if cfg.Media.RTSPPort <= 0 {
cfg.Media.PublishPort = 9000 cfg.Media.RTSPPort = 8554
} }
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil { if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
return nil, fmt.Errorf("media: %w", err) return nil, fmt.Errorf("media: %w", err)
@@ -432,25 +438,25 @@ func validateMediaConfig(cfg *MediaConfig, serverURL string, roverName string) e
file is written. This keeps the Pi behavior stable while making laptop file is written. This keeps the Pi behavior stable while making laptop
and future publisher variants explicit configuration choices. and future publisher variants explicit configuration choices.
*/ */
if cfg.PublishPort <= 0 { if cfg.RTSPPort <= 0 {
cfg.PublishPort = 9000 cfg.RTSPPort = 8554
} }
if cfg.HealthInterval.Duration <= 0 { if cfg.HealthInterval.Duration <= 0 {
cfg.HealthInterval = Duration{Duration: 30 * time.Second} cfg.HealthInterval = Duration{Duration: 30 * time.Second}
} }
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.PublishPort); err != nil { if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.RTSPPort); err != nil {
return fmt.Errorf("video: %w", err) return fmt.Errorf("video: %w", err)
} }
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.PublishPort); err != nil { if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.RTSPPort); err != nil {
return fmt.Errorf("audioCapture: %w", err) return fmt.Errorf("audioCapture: %w", err)
} }
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.PublishPort); err != nil { if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.RTSPPort); err != nil {
return fmt.Errorf("audioPlayback: %w", err) return fmt.Errorf("audioPlayback: %w", err)
} }
return nil return nil
} }
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, publishPort int) error { func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, rtspPort int) error {
if cfg.Service == "" { if cfg.Service == "" {
cfg.Service = "video-publisher.service" cfg.Service = "video-publisher.service"
} }
@@ -476,17 +482,19 @@ func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" { if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
cfg.SensorMode = "1296:972" cfg.SensorMode = "1296:972"
} }
if cfg.PublishURL == "" { /*
derived, err := derivePublishURL(serverURL, roverName, publishPort) Always derive this endpoint. Older rover configs can contain an explicit SRT publishUrl;
honoring it after a binary update would silently leave that rover on the old transport.
*/
derived, err := derivePublishURL(serverURL, roverName, rtspPort)
if err != nil { if err != nil {
return fmt.Errorf("derive publishUrl: %w", err) return fmt.Errorf("derive publishUrl: %w", err)
} }
cfg.PublishURL = derived cfg.PublishURL = derived
}
return nil return nil
} }
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, publishPort int) error { func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, rtspPort int) error {
if cfg.Service == "" { if cfg.Service == "" {
cfg.Service = "audio-only-publisher.service" cfg.Service = "audio-only-publisher.service"
} }
@@ -502,17 +510,15 @@ func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, rover
if cfg.Bitrate <= 0 { if cfg.Bitrate <= 0 {
cfg.Bitrate = 510000 cfg.Bitrate = 510000
} }
if cfg.PublishURL == "" { derived, err := derivePublishURL(serverURL, roverName+"-audio", rtspPort)
derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort)
if err != nil { if err != nil {
return fmt.Errorf("derive publishUrl: %w", err) return fmt.Errorf("derive publishUrl: %w", err)
} }
cfg.PublishURL = derived cfg.PublishURL = derived
}
return nil return nil
} }
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, publishPort int) error { func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, rtspPort int) error {
if cfg.Service == "" { if cfg.Service == "" {
cfg.Service = "audio-forward-listener.service" cfg.Service = "audio-forward-listener.service"
} }
@@ -522,13 +528,11 @@ func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, rov
if cfg.NormalizeFilter == "" { if cfg.NormalizeFilter == "" {
cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled" cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled"
} }
if cfg.ForwardURL == "" { derived, err := deriveReadURL(serverURL, roverName+"-fwd", rtspPort)
derived, err := deriveReadURL(serverURL, roverName+"-fwd", publishPort)
if err != nil { if err != nil {
return fmt.Errorf("derive forwardUrl: %w", err) return fmt.Errorf("derive forwardUrl: %w", err)
} }
cfg.ForwardURL = derived cfg.ForwardURL = derived
}
return nil return nil
} }
@@ -577,20 +581,17 @@ func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
} }
func derivePublishURL(serverURL, streamName string, port int) (string, error) { func derivePublishURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "publish") return deriveRTSPURL(serverURL, streamName, port)
} }
func deriveReadURL(serverURL, streamName string, port int) (string, error) { func deriveReadURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "request") return deriveRTSPURL(serverURL, streamName, port)
} }
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) { func deriveRTSPURL(serverURL, streamName string, port int) (string, error) {
if streamName == "" { if streamName == "" {
return "", errors.New("missing stream name for publishUrl") return "", errors.New("missing stream name for publishUrl")
} }
if mode == "" {
mode = "publish"
}
parsed, err := url.Parse(serverURL) parsed, err := url.Parse(serverURL)
if err != nil { if err != nil {
return "", err return "", err
@@ -600,10 +601,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
return "", errors.New("serverUrl missing host") return "", errors.New("serverUrl missing host")
} }
if port <= 0 { if port <= 0 {
port = 9000 port = 8554
} }
/*
JoinHostPort handles both ordinary hostnames and bracketed IPv6 addresses. The rover name
is a MediaMTX path, so it is escaped independently instead of interpolated into the host.
RTSP distinguishes publishing from reading through protocol methods, which is why both
directions intentionally use the same URL shape.
*/
escaped := url.PathEscape(streamName) escaped := url.PathEscape(streamName)
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil return fmt.Sprintf("rtsp://%s/%s", net.JoinHostPort(host, strconv.Itoa(port)), escaped), nil
} }
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
+80
View File
@@ -0,0 +1,80 @@
package roverd
// These tests pin the network-agnostic RTSP contract. A rover provides its server URL and name
// once; all three media paths must then resolve to distinct, safely escaped MediaMTX paths.
import (
"strings"
"testing"
)
func TestMediaURLsDeriveFromServerURLAndRoverName(t *testing.T) {
cfg := MediaConfig{
Video: VideoMediaConfig{Enabled: true},
AudioCapture: AudioCaptureConfig{Enabled: true},
AudioPlayback: AudioPlaybackConfig{Enabled: true},
}
if err := validateMediaConfig(&cfg, "ws://control-server.local:8080/rover", "rover one"); err != nil {
t.Fatalf("validate media config: %v", err)
}
wants := map[string]string{
"video": "rtsp://control-server.local:8554/rover%20one",
"mic": "rtsp://control-server.local:8554/rover%20one-audio",
"speaker": "rtsp://control-server.local:8554/rover%20one-fwd",
}
got := map[string]string{
"video": cfg.Video.PublishURL,
"mic": cfg.AudioCapture.PublishURL,
"speaker": cfg.AudioPlayback.ForwardURL,
}
for name, want := range wants {
if got[name] != want {
t.Errorf("%s URL: got %q, want %q", name, got[name], want)
}
}
if cfg.RTSPPort != 8554 {
t.Fatalf("RTSP port: got %d, want 8554", cfg.RTSPPort)
}
}
func TestExplicitMediaPortAppliesToEveryRTSPPath(t *testing.T) {
cfg := MediaConfig{
RTSPPort: 10554,
Video: VideoMediaConfig{Enabled: true},
AudioCapture: AudioCaptureConfig{Enabled: true},
AudioPlayback: AudioPlaybackConfig{Enabled: true},
}
if err := validateMediaConfig(&cfg, "ws://media.example/rover", "r1"); err != nil {
t.Fatalf("validate media config: %v", err)
}
for name, value := range map[string]string{
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
} {
if !strings.Contains(value, ":10554/") {
t.Errorf("%s URL did not use configured port: %q", name, value)
}
}
}
func TestLegacyExplicitSRTURLsCannotKeepAnUpdatedRoverOnTheOldTransport(t *testing.T) {
/*
Deployed rover configs can still contain these former fields. Validation must replace
them unconditionally so updating roverd is sufficient to move the whole media path.
*/
cfg := MediaConfig{
Video: VideoMediaConfig{Enabled: true, PublishURL: "srt://old/video"},
AudioCapture: AudioCaptureConfig{Enabled: true, PublishURL: "srt://old/audio"},
AudioPlayback: AudioPlaybackConfig{Enabled: true, ForwardURL: "srt://old/forward"},
}
if err := validateMediaConfig(&cfg, "ws://new-server.local:8080/rover", "r1"); err != nil {
t.Fatalf("validate media config: %v", err)
}
for name, value := range map[string]string{
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
} {
if !strings.HasPrefix(value, "rtsp://new-server.local:8554/") {
t.Errorf("%s retained an old transport URL: %q", name, value)
}
}
}
+2 -1
View File
@@ -22,7 +22,8 @@ battery:
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
publishPort: 9000 # Media URLs are derived from serverUrl's hostname, this port, and the rover name.
rtspPort: 8554
manage: true manage: true
healthUrl: "" healthUrl: ""
healthInterval: 30s healthInterval: 30s
+2 -4
View File
@@ -17,7 +17,8 @@ battery:
urgent: 1650 urgent: 1650
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
publishPort: 9000 # Media URLs are derived from serverUrl's hostname, this port, and the rover name.
rtspPort: 8554
manage: true manage: true
healthUrl: "" healthUrl: ""
healthInterval: 30s healthInterval: 30s
@@ -25,7 +26,6 @@ media:
enabled: true enabled: true
service: video-publisher.service service: video-publisher.service
publisher: pi-libcamera publisher: pi-libcamera
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
width: 640 width: 640
height: 480 height: 480
fps: 30 fps: 30
@@ -36,7 +36,6 @@ media:
audioCapture: audioCapture:
enabled: false enabled: false
service: audio-only-publisher.service service: audio-only-publisher.service
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
device: hw:0,0 device: hw:0,0
sampleRate: 48000 sampleRate: 48000
channels: 2 channels: 2
@@ -44,7 +43,6 @@ media:
audioPlayback: audioPlayback:
enabled: true enabled: true
service: audio-forward-listener.service service: audio-forward-listener.service
forwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
device: forward device: forward
normalize: true normalize: true
cameraServo: cameraServo:
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Rover Audio Forward Listener (SRT -> ALSA) Description=Rover Audio Forward Listener (RTSP/TCP -> ALSA)
After=network-online.target roverd.service After=network-online.target roverd.service
Wants=network-online.target Wants=network-online.target
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Rover Audio Publisher (ALSA -> SRT) Description=Rover Audio Publisher (ALSA -> RTSP/TCP)
After=network-online.target roverd.service After=network-online.target roverd.service
Wants=network-online.target Wants=network-online.target
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Rover Debian Laptop Video Publisher (V4L2 -> SRT) Description=Rover Debian Laptop Video Publisher (V4L2 -> RTSP/TCP)
After=network-online.target roverd.service After=network-online.target roverd.service
Wants=network-online.target Wants=network-online.target
+1 -1
View File
@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Multi-Roomba rover control agent Description=Multi-Roomba rover control agent
After=network-online.target mediamtx.service After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Rover Video Publisher (libcamera -> SRT) Description=Rover Video Publisher (libcamera -> RTSP/TCP)
After=network-online.target roverd.service After=network-online.target roverd.service
Wants=network-online.target Wants=network-online.target
+46 -2
View File
@@ -51,8 +51,15 @@ barcodeGames:
media: media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request # Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep # http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video # Example: http://media-server.local:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video" whepBaseUrl: "http://media-server.local:8889/video"
# MediaMTX advertises these instance-specific DNS names or IP addresses as WebRTC ICE
# candidates. Include every public and LAN address browsers use to reach this server.
# The server generates MediaMTX's runtime configuration from this list; never edit a
# separate mediamtx.yml for a new installation.
additionalHosts:
- "rover.example.com"
- "media-server.local"
bandwidthSavings: bandwidthSavings:
# Duplicate driver-tab handling for the same browser identity. # Duplicate driver-tab handling for the same browser identity.
@@ -60,6 +67,11 @@ bandwidthSavings:
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not # verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
# notAllowed: every identity is limited to one driver tab # notAllowed: every identity is limited to one driver tab
multiTabProtection: "verifiedOnly" multiTabProtection: "verifiedOnly"
# Disconnect rover video when its player is outside the viewport or the web
# page is in a background browser tab. Rover audio is a separate stream and
# remains connected. /mini intentionally keeps its existing always-warm video
# behavior regardless of this option.
pauseHiddenRoverVideo: false
# Video for users who are attached to a source but do not currently own its # Video for users who are attached to a source but do not currently own its
# active turn. "snapshots" saves upload bandwidth; "live" allows full video # active turn. "snapshots" saves upload bandwidth; "live" allows full video
# whenever the normal mode/visibility rules allow it. # whenever the normal mode/visibility rules allow it.
@@ -227,3 +239,35 @@ socials:
url: "https://ko-fi.com/your-handle" url: "https://ko-fi.com/your-handle"
icon: "FaCoffee" icon: "FaCoffee"
color: "#29ABE0" color: "#29ABE0"
# Optional trusted HTML card shown at the bottom of the desktop driver page's
# left column. Leave html empty (or omit this section) to hide the card. This
# content is sent to driver browsers without sanitization, so only place markup
# here that is controlled by the server operator.
driverAd:
title: "Advertisement"
html: |
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
<img src="https://example.com/ad.png" alt="Advertisement" style="display:block;width:100%;height:auto;">
</a>
# Optional passive fleet telemetry, history, and daily reporting. The collector
# observes existing server events and rover sensor frames but never participates
# in command, assignment, docking, or safety decisions.
fleetReports:
enabled: false
retention:
# Zero retains evidence indefinitely. Set explicit day counts on servers
# that prefer bounded storage over complete long-term history.
detailedDays: 0
minuteSamplesDays: 0
battery:
enabled: true
maximumIntegrationGapSeconds: 5
minimumCapacityTestDepthPercent: 60
discord:
enabled: true
sendAt: "08:00"
timezone: "America/New_York"
privacy:
retainChatBodies: true
+21
View File
@@ -0,0 +1,21 @@
<!--
Umami example for the optional provider-neutral rover analytics bridge.
Copy this file to analytics.html in the same data directory, replace the
example URLs and attributes, and restart the server. The server injects the
copied file into every web UI entry page; this example filename is not loaded
automatically.
-->
<script defer src="https://analytics.example.com/script.js" data-website-id="replace-with-website-id" data-domains="rover.example.com"></script>
<script defer src="https://analytics.example.com/recorder.js" data-website-id="replace-with-website-id" data-domains="rover.example.com" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<script>
window.roverAnalytics = {
track: function (name, data) {
window.umami?.track(name, data);
},
identify: function (data) {
window.umami?.identify(data);
},
};
</script>
+5
View File
@@ -28,6 +28,7 @@ require('./src/services/serverControlService');
require('./src/services/videoSessions'); require('./src/services/videoSessions');
require('./src/services/ptzCameraService'); require('./src/services/ptzCameraService');
require('./src/services/videoAuthService'); require('./src/services/videoAuthService');
require('./src/services/mediaMtxService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
require('./src/services/roomCameraService'); require('./src/services/roomCameraService');
require('./src/services/roverSnapshotService'); require('./src/services/roverSnapshotService');
@@ -49,6 +50,10 @@ require('./src/services/kinectService');
require('./src/services/balanceBoardService'); require('./src/services/balanceBoardService');
require('./src/services/sessionService'); require('./src/services/sessionService');
require('./src/services/batteryManager'); require('./src/services/batteryManager');
// Fleet reporting starts after the rover and battery services so its passive
// subscriptions see fully decoded state without becoming an initialization
// dependency of either control path.
require('./src/services/fleetReportService');
require('./src/services/replayEngineV2'); require('./src/services/replayEngineV2');
// Replay delivery is a core service. It must subscribe before the optional // Replay delivery is a core service. It must subscribe before the optional
// Discord feature so web requests always have a local delivery path. // Discord feature so web requests always have a local delivery path.
+23 -40
View File
@@ -8,8 +8,6 @@ NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/downl
MEDIAMTX_BIN="/usr/local/bin/mediamtx" MEDIAMTX_BIN="/usr/local/bin/mediamtx"
NEOLINK_BIN="/usr/local/bin/neolink" NEOLINK_BIN="/usr/local/bin/neolink"
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav" CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
MEDIAMTX_CONF_DIR="/etc/mediamtx"
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh" ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service" MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service" MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
@@ -35,7 +33,6 @@ SERVER_DIR="$SCRIPT_DIR"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native" BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker" BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml" CONFIG_PATH="$SERVER_DIR/config.yaml"
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh" ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py" CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
@@ -259,53 +256,39 @@ if ! verify_google_tts_helper; then
verify_google_tts_helper verify_google_tts_helper
fi fi
mkdir -p "$MEDIAMTX_CONF_DIR"
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
exit 1
fi
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2 echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
exit 1 exit 1
fi fi
if [[ -f "$MEDIAMTX_CONFIG" ]]; then
echo " Preserving existing mediaMTX config -> $MEDIAMTX_CONFIG"
else
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
fi
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN" echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN" install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
# Validate the new source of truth before disabling a working legacy service. The validator
# performs the same build and YAML serialization as server startup without opening listeners
# or leaving a process behind.
runuser -u "$TARGET_USER" -- env \
SERVER_CONFIG="$CONFIG_PATH" \
ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \
"$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js"
# MediaMTX used to run as its own systemd service with a hand-maintained config in
# /etc/mediamtx. Stop it before multirover starts the new child process, otherwise the two
# processes race for every media listener. Both commands are deliberately idempotent so an
# already-migrated server and a first-time installation follow the same path.
echo " Disabling legacy mediamtx.service"
systemctl disable --now mediamtx.service 2>/dev/null || true
rm -f "$MEDIAMTX_SERVICE"
rm -f /etc/mediamtx/mediamtx.yml
echo "[4/6] Writing systemd units..." echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR" mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR" chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR" mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR" chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
cat > "$MEDIAMTX_SERVICE" <<EOF
[Unit]
Description=mediaMTX WebRTC Server
After=network-online.target
Wants=network-online.target
[Service]
User=$TARGET_USER
Group=$TARGET_USER
WorkingDirectory=$MEDIAMTX_CONF_DIR
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
cat > "$MULTIROVER_SERVICE" <<EOF cat > "$MULTIROVER_SERVICE" <<EOF
[Unit] [Unit]
Description=Multi-Roomba Rover control server Description=Multi-Roomba Rover control server
After=network-online.target mediamtx.service bluetooth.service After=network-online.target bluetooth.service
Wants=network-online.target bluetooth.service Wants=network-online.target bluetooth.service
[Service] [Service]
@@ -316,6 +299,9 @@ Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
RuntimeDirectory=multirover
RuntimeDirectoryMode=0750
ExecStart=$NODE_BIN $SERVER_DIR/index.js ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
@@ -325,20 +311,17 @@ SuccessExitStatus=130 143
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE" chmod 644 "$MULTIROVER_SERVICE"
echo "[5/6] Enabling services..." echo "[5/6] Enabling services..."
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now mediamtx.service
systemctl enable --now multirover.service systemctl enable --now multirover.service
systemctl restart mediamtx.service
systemctl restart multirover.service systemctl restart multirover.service
echo "[6/6] Done." echo "[6/6] Done."
echo echo
echo "Services installed:" echo "Services installed:"
echo " mediamtx.service (WebRTC fan-out)" echo " multirover.service (Node.js control server with MediaMTX child)"
echo " multirover.service (Node.js control server)"
echo echo
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters." echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
echo "Kinect/libfreenect packages and udev permissions were installed." echo "Kinect/libfreenect packages and udev permissions were installed."
-47
View File
@@ -1,47 +0,0 @@
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
logLevel: info
api: yes
apiAddress: 0.0.0.0:9997
metrics: yes
metricsAddress: 0.0.0.0:9998
pprof: no
pprofAddress: 127.0.0.1:9999
rtsp: no
rtmp: no
hls: no
webrtc: yes
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
webrtcICEServers2:
# Google public STUN (world-wide, very commonly used)
- url: stun:stun.l.google.com:19302
- url: stun:stun1.l.google.com:19302
- url: stun:stun2.l.google.com:19302
- url: stun:stun3.l.google.com:19302
- url: stun:stun4.l.google.com:19302
# Cloudflare STUN (anycast, global PoPs)
- url: stun:stun.cloudflare.com:3478
srt: yes
srtAddress: :9000
authMethod: http
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
paths:
all:
source: publisher
sourceOnDemand: no
# Rover Snapshot Writer
# Keep rover snapshots continuously updated while a rover video path is live.
runOnReady: /usr/local/bin/rover-snapshot-writer.sh
runOnReadyRestart: yes
+1
View File
@@ -16,6 +16,7 @@
"home-assistant-js-websocket": "^3.1.2", "home-assistant-js-websocket": "^3.1.2",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"kokoro-js": "^1.2.1", "kokoro-js": "^1.2.1",
"luxon": "^3.7.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"obscenity": "^0.4.6", "obscenity": "^0.4.6",
"ollama": "^0.6.3", "ollama": "^0.6.3",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -72
View File
@@ -4,82 +4,16 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bitmap.png" /> <link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" /> <link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" /> <!-- The server renders this manifest so installed shortcuts use the local instance's configured branding. -->
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. --> <!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <!-- site-metadata:inject -->
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. --> <!-- analytics:inject -->
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. --> <script type="module" crossorigin src="/assets/index-C7V6I437.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
<script>
/*
Build-time analytics adapter for the rover UI.
React only calls window.roverAnalytics.track/identify. Keeping the Umami
adapter here means analytics can still be removed, replaced, or configured
by changing this injected file instead of rebuilding app logic around a
specific analytics provider.
*/
(function () {
var pendingCalls = [];
var flushTimer = null;
function callUmami(method, args) {
if (!window.umami || typeof window.umami[method] !== 'function') return false;
window.umami[method].apply(window.umami, args);
return true;
}
function flushPendingCalls() {
if (!pendingCalls.length) return;
if (!window.umami) return;
pendingCalls = pendingCalls.filter(function (call) {
return !callUmami(call.method, call.args);
});
if (!pendingCalls.length && flushTimer) {
window.clearInterval(flushTimer);
flushTimer = null;
}
}
function enqueue(method, args) {
if (callUmami(method, args)) return;
pendingCalls.push({ method: method, args: args });
/*
The React app may fire route/session events before Umami's deferred
script has executed. Queueing preserves those early events while still
letting the whole adapter no-op harmlessly if the script is blocked.
*/
if (!flushTimer) {
flushTimer = window.setInterval(flushPendingCalls, 500);
}
}
window.roverAnalytics = {
track: function (name, data) {
enqueue('track', typeof data === 'undefined' ? [name] : [name, data]);
},
identify: function (data) {
enqueue('identify', [data || {}]);
},
};
window.addEventListener('load', flushPendingCalls);
})();
</script>
<!-- otterlytics testing for blocking local -->
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-C-g10Rjz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwjDTdpq.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-18
View File
@@ -1,18 +0,0 @@
{
"name": "Multi Roomba Rover",
"short_name": "MRR",
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#020617",
"icons": [
{
"src": "/bitmap.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// MediaMTX Configuration Validator
// Purpose: Lets the installer validate server-owned MediaMTX inputs before disabling the legacy service.
// Scope: Builds and serializes the runtime YAML without starting MediaMTX or changing external state.
const yaml = require('js-yaml');
const { loadConfig } = require('../src/helpers/configLoader');
const { buildMediaMtxConfig } = require('../src/services/mediaMtxService/config');
const config = loadConfig();
const generated = buildMediaMtxConfig({
config,
serverPort: process.env.PORT || 8080,
snapshotWriterPath: process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
});
/*
Serializing is part of validation: it catches values that the builder accepted but js-yaml
cannot represent before the installer removes the previous service configuration.
*/
yaml.dump(generated, { noRefs: true, lineWidth: 120 });
process.stdout.write('MediaMTX server configuration is valid\n');
+14
View File
@@ -10,6 +10,7 @@ const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'a
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({ const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
multiTabProtection: 'verifiedOnly', multiTabProtection: 'verifiedOnly',
pauseHiddenRoverVideo: false,
nonTurnVideo: Object.freeze({ nonTurnVideo: Object.freeze({
mode: 'snapshots', mode: 'snapshots',
userThreshold: 0, userThreshold: 0,
@@ -28,6 +29,15 @@ function normalizeEnum(value, allowed, fallback) {
return allowed.has(normalized) ? normalized : fallback; return allowed.has(normalized) ? normalized : fallback;
} }
function normalizeBoolean(value, fallback) {
/*
YAML booleans must stay real booleans. Treating strings such as "false" as
truthy would silently enable a bandwidth policy that the operator intended
to disable, so invalid values fall back to the documented server default.
*/
return typeof value === 'boolean' ? value : fallback;
}
function normalizeNonTurnVideo(value) { function normalizeNonTurnVideo(value) {
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const threshold = Number(raw.userThreshold); const threshold = Number(raw.userThreshold);
@@ -53,6 +63,10 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
MULTI_TAB_MODES, MULTI_TAB_MODES,
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection, DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
), ),
pauseHiddenRoverVideo: normalizeBoolean(
raw.pauseHiddenRoverVideo,
DEFAULT_BANDWIDTH_SAVINGS.pauseHiddenRoverVideo,
),
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo), nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
externalSpectatorVideo: normalizeEnum( externalSpectatorVideo: normalizeEnum(
raw.externalSpectatorVideo, raw.externalSpectatorVideo,
+6
View File
@@ -52,6 +52,7 @@ function buildFeatureFlags(config = loadConfig()) {
const interInstanceConfig = config.interInstance || {}; const interInstanceConfig = config.interInstance || {};
const ptzCameraConfig = config.ptzCamera || {}; const ptzCameraConfig = config.ptzCamera || {};
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
const fleetReportsConfig = config.fleetReports || {};
const homeAssistant = Boolean( const homeAssistant = Boolean(
asBoolean(homeAssistantConfig.enabled) && asBoolean(homeAssistantConfig.enabled) &&
asTrimmedString(homeAssistantConfig.url) && asTrimmedString(homeAssistantConfig.url) &&
@@ -100,6 +101,11 @@ function buildFeatureFlags(config = loadConfig()) {
to run without the integration. to run without the integration.
*/ */
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)), discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
// Fleet reports are deliberately controlled by one explicit server switch.
// Storage contents, Discord availability, or historical database files must
// never cause the reporting UI to appear on an installation that has not
// opted into the collector.
fleetReports: asBoolean(fleetReportsConfig.enabled),
}; };
} }
+120
View File
@@ -0,0 +1,120 @@
// Site Metadata Helper
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
const { loadConfig } = require('./configLoader');
const DEFAULT_SITE_METADATA = Object.freeze({
name: 'Multi Roomba Rover',
shortName: 'Multi Roomba Rover',
description: 'Drive and watch remote rovers from your browser.',
accentColor: '#38bdf8',
backgroundColor: '#020617',
publicUrl: null,
});
const BACKGROUND_BLEND_AMOUNT = 0.15;
function asTrimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeHexColor(value) {
const color = asTrimmedString(value).toLowerCase();
/*
Supporting both common CSS hex forms keeps the operator-facing setting
forgiving while still preventing arbitrary CSS from being injected into
generated HTML and SVG attributes.
*/
if (/^#[0-9a-f]{6}$/.test(color)) return color;
if (/^#[0-9a-f]{3}$/.test(color)) {
return `#${color.slice(1).split('').map((character) => character.repeat(2)).join('')}`;
}
return null;
}
function blendHexColors(baseColor, accentColor, accentAmount) {
const base = baseColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
const accent = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
/*
The profile color is deliberately only a tint. A full-strength profile
color could produce a glaring PWA launch screen, while this blend preserves
the application's established dark appearance and still makes each server
visually recognizable.
*/
const channels = base.map((channel, index) =>
Math.round(channel * (1 - accentAmount) + accent[index] * accentAmount),
);
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
}
function normalizePublicUrl(value) {
const candidate = asTrimmedString(value);
if (!candidate) return null;
/*
URL() helpfully repairs strings such as `http:192.168.0.1`, but preserving
that typo in public metadata would conceal a configuration mistake. Require
the conventional absolute URL form so the published address is explicit.
*/
if (!/^https?:\/\//i.test(candidate)) return null;
try {
const url = new URL(candidate);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
/*
Removing a trailing slash gives callers one stable base URL to combine
with paths. Invalid values are ignored instead of producing broken
canonical and social metadata on every page.
*/
return url.toString().replace(/\/$/, '');
} catch {
return null;
}
}
function getReadableAccentText(accentColor) {
const channels = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
const luminance = (channels[0] * 299 + channels[1] * 587 + channels[2] * 114) / 1000;
// A simple luminance split keeps the generated preview badge legible for both dark and light profile colors.
return luminance > 150 ? '#020617' : '#ffffff';
}
function resolveSiteMetadata(config = loadConfig()) {
const interInstance = config?.interInstance;
const profile = interInstance?.profile;
const profileName = asTrimmedString(profile?.name);
/*
A partially filled profile must not unexpectedly rename the site. The
inter-instance feature must be explicitly enabled and have a usable name
before any profile branding is applied; otherwise every value comes from
the coherent default set above.
*/
if (interInstance?.enabled !== true || !profileName) {
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) };
}
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
return {
name: profileName,
shortName: profileName,
description: asTrimmedString(profile.description) || DEFAULT_SITE_METADATA.description,
accentColor,
backgroundColor: blendHexColors(
DEFAULT_SITE_METADATA.backgroundColor,
accentColor,
BACKGROUND_BLEND_AMOUNT,
),
accentTextColor: getReadableAccentText(accentColor),
publicUrl: normalizePublicUrl(profile.publicUrl),
};
}
module.exports = {
DEFAULT_SITE_METADATA,
resolveSiteMetadata,
};
@@ -0,0 +1,55 @@
// audio Forward Service bonk sound
// Purpose: Plays the built-in bonk sound effect on the rover a bonked user is driving.
// Scope: Keeps the fun commands and the audio pipeline decoupled by listening to the server event bus only.
const path = require('path');
const fs = require('fs');
const { subscribe } = require('../eventBus');
/*
Lives in server/assets rather than server/public because the webui build writes
to server/public with emptyOutDir enabled, which deletes anything else in there.
server/assets is a plain checked-in asset directory that no build step touches.
*/
const BONK_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'assets', 'bonk.wav');
function registerBonkSound(deps) {
const {
logger,
playServerAudioFile,
soundPath = BONK_SOUND_PATH,
} = deps;
subscribe('fun.bonked', (event = {}) => {
const roverId = String(event?.payload?.roverId || '').trim();
if (!roverId) return;
/*
The sound is optional. An operator who has not dropped a bonk.wav into
server/assets still gets a fully working `rs bonk` command, so a missing
file is reported once at debug volume rather than thrown at the caller.
*/
if (!fs.existsSync(soundPath)) {
logger.info('Bonk sound file is not installed; skipping playback', { soundPath });
return;
}
try {
playServerAudioFile(roverId, soundPath, { source: 'bonk' });
logger.info('Played bonk sound', { roverId, soundPath });
} catch (err) {
// Playback interrupts mic forwarding and spawns ffmpeg, so an offline rover
// or a missing encoder must not turn into a failed chat command. The bonk
// itself already happened; the sound is layered on top of it.
logger.warn('Failed to play bonk sound', {
roverId,
soundPath,
error: err?.message || String(err),
});
}
});
}
module.exports = {
registerBonkSound,
BONK_SOUND_PATH,
};
@@ -0,0 +1,84 @@
// audio Forward Service bonk sound tests
// Purpose: Verifies the bonk cue plays for a real event and stays contained when the file or rover is missing.
// Scope: Subscribes through the real event bus with a playback double; no ffmpeg runs.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { publishEvent } = require('../eventBus');
const { registerBonkSound, BONK_SOUND_PATH } = require('./bonkSound');
const soundDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bonk-sound-test-'));
const presentSound = path.join(soundDir, 'bonk.wav');
fs.writeFileSync(presentSound, 'not really audio, only the path is read here');
const missingSound = path.join(soundDir, 'absent.wav');
function harness({ soundPath = presentSound, playImpl = null } = {}) {
const played = [];
const warnings = [];
registerBonkSound({
logger: {
info: () => {},
warn: (message, meta) => warnings.push({ message, meta }),
},
playServerAudioFile: (roverId, filePath, options) => {
played.push({ roverId, filePath, options });
if (playImpl) playImpl();
},
soundPath,
});
return { played, warnings };
}
// Each registerBonkSound call adds another subscriber to the shared bus, so every
// test publishes a distinct rover id and asserts only on its own rover.
function bonk(roverId) {
publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId, targetLabel: 'bob' } });
}
test('a bonk event plays the sound on the named rover', () => {
const { played } = harness();
bonk('rover-play');
const mine = played.filter((entry) => entry.roverId === 'rover-play');
assert.equal(mine.length, 1);
assert.equal(mine[0].filePath, presentSound);
assert.equal(mine[0].options.source, 'bonk');
});
test('an event with no rover id is ignored', () => {
const { played } = harness();
publishEvent({ source: 'test', type: 'fun.bonked', payload: {} });
publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId: ' ' } });
assert.equal(played.length, 0);
});
test('a missing sound file skips playback instead of throwing', () => {
const { played, warnings } = harness({ soundPath: missingSound });
assert.doesNotThrow(() => bonk('rover-missing'));
assert.equal(played.filter((entry) => entry.roverId === 'rover-missing').length, 0);
assert.equal(warnings.length, 0, 'a not-installed sound is informational, not a warning');
});
test('a playback failure is contained and logged rather than thrown at the caller', () => {
const { warnings } = harness({
playImpl: () => {
throw new Error('Rover offline');
},
});
assert.doesNotThrow(() => bonk('rover-offline'));
assert.ok(warnings.some((entry) => entry.meta?.error === 'Rover offline'));
});
test('the default sound path lives in server/assets, which the webui build does not wipe', () => {
// webui/vite.config.js builds to ../server/public with emptyOutDir enabled, so a
// sound stored there would be deleted by the next build.
assert.match(BONK_SOUND_PATH, /server\/assets\/bonk\.wav$/);
assert.doesNotMatch(BONK_SOUND_PATH, /server\/public/);
});
test.after(() => {
fs.rmSync(soundDir, { recursive: true, force: true });
});
@@ -23,8 +23,34 @@ function registerAudioForwardHooks(deps) {
buildWhipUrl, buildWhipUrl,
videoSessions, videoSessions,
startSilenceWriter, startSilenceWriter,
isMuted,
verificationEvents,
} = deps; } = deps;
verificationEvents.on('change', ({ socketId } = {}) => {
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);
if (!socket || !isMuted(socket)) return;
/*
Permission checks stop new muted audio, but an upload or microphone can
already be live when moderation changes. Stop only streams owned by this
socket so muting does not disturb another driver's audio or unrelated
server-generated sounds.
*/
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
if (ownerSocketId === socketId) {
stopWhipForRover(roverId, 'owner_muted');
}
}
workers.forEach((worker, roverId) => {
if (worker?.contentKind === 'upload' && worker.activeOwnerSocketId === socketId) {
logger.info('Stopping uploaded audio because its owner was muted', { roverId, socketId });
startSilenceWriter(roverId);
}
});
});
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => { roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return; if (!roverId) return;
if (action === 'removed') { if (action === 'removed') {
@@ -8,12 +8,13 @@ const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const turnService = require('../turnService'); const turnService = require('../turnService');
const { isVerified } = require('../verificationService'); const { isMuted, isVerified, verificationEvents } = require('../verificationService');
const videoSessions = require('../videoSessions'); const videoSessions = require('../videoSessions');
const { createAudioForwardPolicy } = require('./policy'); const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine'); const { createAudioForwardWorkerEngine } = require('./workerEngine');
const { registerAudioForwardHooks } = require('./hooks'); const { registerAudioForwardHooks } = require('./hooks');
const { registerChargeCompleteSound } = require('./chargeCompleteSound'); const { registerChargeCompleteSound } = require('./chargeCompleteSound');
const { registerBonkSound } = require('./bonkSound');
const audioForwardEvents = new EventEmitter(); const audioForwardEvents = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
@@ -62,6 +63,7 @@ function getAudioForwardState() {
const audioForwardPolicy = createAudioForwardPolicy({ const audioForwardPolicy = createAudioForwardPolicy({
isVerified, isVerified,
isMuted,
roverManager, roverManager,
turnService, turnService,
streamSuffix, streamSuffix,
@@ -141,6 +143,8 @@ registerAudioForwardHooks({
buildWhipUrl, buildWhipUrl,
videoSessions, videoSessions,
startSilenceWriter, startSilenceWriter,
isMuted,
verificationEvents,
}); });
registerChargeCompleteSound({ registerChargeCompleteSound({
@@ -148,6 +152,11 @@ registerChargeCompleteSound({
playServerAudioFile, playServerAudioFile,
}); });
registerBonkSound({
logger,
playServerAudioFile,
});
module.exports = { module.exports = {
getAudioForwardState, getAudioForwardState,
audioForwardEvents, audioForwardEvents,
@@ -4,6 +4,7 @@
function createAudioForwardPolicy(deps) { function createAudioForwardPolicy(deps) {
const { const {
isVerified, isVerified,
isMuted,
roverManager, roverManager,
turnService, turnService,
streamSuffix, streamSuffix,
@@ -18,6 +19,9 @@ function createAudioForwardPolicy(deps) {
function ensureAudioForwardPermission(socket, roverId) { function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket); ensureVipVerified(socket);
if (isMuted(socket)) {
throw new Error('Muted');
}
if (!roverManager.isDriver(roverId, socket)) { if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover'); throw new Error('Audio forwarding is only allowed on your own rover');
} }
@@ -26,25 +30,13 @@ function createAudioForwardPolicy(deps) {
} }
} }
function forcePublishStreamMode(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) return '';
if (!/[?&]streamid=#!::/.test(value)) return value;
if (/,m=publish\b/.test(value)) return value;
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
}
function resolveForwardUrl(roverId) { function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId); /*
// Rovers listen to the playback stream with a request/read URL. The VIP The server publishes to its own MediaMTX child, so loopback is the stable and correct
// upload path needs to publish into that same stream, so the configured route regardless of which hostname a rover uses to reach this machine. RTSP uses the
// nested playback URL is converted to publish mode below. same path for publish and read; ANNOUNCE/RECORD and DESCRIBE/PLAY distinguish direction.
const configured = record?.meta?.media?.audioPlayback?.forwardUrl; */
if (configured) return forcePublishStreamMode(configured); return `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId + streamSuffix)}`;
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
} }
function resolveForwardPathId(roverId) { function resolveForwardPathId(roverId) {
@@ -0,0 +1,32 @@
// Audio Forward Policy Tests
// Purpose: Verifies that mute blocks user-owned forwarding without changing ordinary driver authorization.
// Scope: Exercises the pure permission policy with small injected role, rover, and turn doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createAudioForwardPolicy } = require('./policy');
function createPolicy({ verified = true, muted = false, driver = true, canDrive = true } = {}) {
return createAudioForwardPolicy({
isVerified: () => verified,
isMuted: () => muted,
roverManager: { isDriver: () => driver },
turnService: { canDrive: () => canDrive },
streamSuffix: '-fwd',
mediaConfig: {},
});
}
test('rejects audio forwarding for a muted verified driver', () => {
const policy = createPolicy({ muted: true });
assert.throws(() => policy.ensureAudioForwardPermission({}, 'rover'), /Muted/);
});
test('preserves normal audio forwarding for an unmuted verified driver', () => {
const policy = createPolicy();
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
});
test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
const policy = createPolicy();
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
});
@@ -86,7 +86,7 @@ function createAudioForwardWorkerEngine(deps) {
exited = true; exited = true;
}; };
// ChildProcess.killed only means Node successfully sent a signal, not that // ChildProcess.killed only means Node successfully sent a signal, not that
// ffmpeg actually exited. Track the real exit event so FIFO/SRT hangs still // ffmpeg actually exited. Track the real exit event so FIFO/publisher hangs still
// get escalated to SIGKILL instead of making systemd wait for its timeout. // get escalated to SIGKILL instead of making systemd wait for its timeout.
proc.once('exit', markExited); proc.once('exit', markExited);
try { try {
@@ -145,7 +145,13 @@ function createAudioForwardWorkerEngine(deps) {
'-muxpreload', '-muxpreload',
'0', '0',
'-f', '-f',
'mpegts', 'rtsp',
/*
The MediaMTX listener accepts RTSP over TCP only. Pinning it here makes the server's
own publisher follow the same reliable transport contract as every rover publisher.
*/
'-rtsp_transport',
'tcp',
outputUrl, outputUrl,
]; ];
} }
@@ -0,0 +1,79 @@
// audio Levels Gain Math
// Purpose: Holds the pure clamping and ceiling rules shared by every gain layer.
// Scope: No IO, no state; keeps the volume policy independently reviewable and testable.
/*
The three gain keys are the same on every layer of this feature: the global
admin gains, the admin-editable VIP boost caps, and each user's personal
preference. Iterating one list keeps those layers from drifting apart.
*/
const GAIN_KEYS = ['hornGain', 'ttsGain', 'forwardGain'];
// Absolute gain limits accepted anywhere a multiplier is stored.
const MIN_GAIN = 0;
const MAX_GAIN = 4;
function clampGain(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, num));
}
function clampFraction(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(1, num));
}
function normalizeUserGains(raw = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampFraction(raw?.[key], 1);
});
return out;
}
function normalizeGainSet(raw = {}, fallback = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1));
});
return out;
}
/*
A user without the boost flag can never exceed the global admin gain. The flag
raises the ceiling to the admin-managed hard cap, and Math.max keeps the flag
from ever being a downgrade: if an admin runs the global gain higher than the
boost cap, a boosted user keeps the global ceiling instead of losing volume
for holding a permission.
*/
function resolveCeilings({ adminLimits = {}, boostCaps = {}, hasBoost = false } = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
const adminCeiling = clampGain(adminLimits?.[key], 0);
out[key] = hasBoost ? Math.max(adminCeiling, clampGain(boostCaps?.[key], 0)) : adminCeiling;
});
return out;
}
// Personal preferences are fractions of whichever ceiling applies to the user.
function applyCeilings(fractions = {}, ceilings = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0);
});
return out;
}
module.exports = {
GAIN_KEYS,
MIN_GAIN,
MAX_GAIN,
clampGain,
clampFraction,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
};
@@ -0,0 +1,85 @@
// audio Levels Gain Math Tests
// Purpose: Pins the ceiling rules that keep user volume inside admin limits.
// Scope: Pure math only; no store, socket, or rover involvement.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
clampFraction,
clampGain,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
} = require('./gainMath');
const ADMIN_LIMITS = { hornGain: 0.3, ttsGain: 0.2, forwardGain: 0.1 };
const BOOST_CAPS = { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 };
test('an unboosted user is capped by the global admin gains', () => {
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: false });
assert.deepEqual(ceilings, ADMIN_LIMITS);
});
test('the boost flag raises the ceiling to the hard caps', () => {
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: true });
assert.deepEqual(ceilings, BOOST_CAPS);
});
test('the boost flag never lowers a ceiling when admin gains exceed the caps', () => {
const loud = { hornGain: 2, ttsGain: 1.5, forwardGain: 3 };
const ceilings = resolveCeilings({ adminLimits: loud, boostCaps: BOOST_CAPS, hasBoost: true });
assert.deepEqual(ceilings, loud);
});
test('a full personal slider resolves to exactly the ceiling', () => {
const effective = applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ADMIN_LIMITS);
assert.deepEqual(effective, ADMIN_LIMITS);
});
test('a personal slider scales the ceiling rather than replacing it', () => {
const effective = applyCeilings({ hornGain: 0.5, ttsGain: 0.5, forwardGain: 0.5 }, BOOST_CAPS);
assert.deepEqual(effective, { hornGain: 0.25, ttsGain: 0.4, forwardGain: 0.2 });
});
test('an out-of-range personal value cannot escape the ceiling', () => {
const effective = applyCeilings({ hornGain: 12, ttsGain: -4, forwardGain: 'loud' }, ADMIN_LIMITS);
assert.equal(effective.hornGain, ADMIN_LIMITS.hornGain);
assert.equal(effective.ttsGain, 0);
// A non-numeric value falls back to the full slider, still bounded by the ceiling.
assert.equal(effective.forwardGain, ADMIN_LIMITS.forwardGain);
});
test('a zero admin gain silences even a boosted user at full slider', () => {
const ceilings = resolveCeilings({
adminLimits: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
boostCaps: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
hasBoost: true,
});
assert.deepEqual(applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ceilings), {
hornGain: 0,
ttsGain: 0,
forwardGain: 0,
});
});
test('personal values normalize into the 0..1 range with a full-volume default', () => {
assert.deepEqual(normalizeUserGains({ hornGain: 0.25, ttsGain: 9 }), {
hornGain: 0.25,
ttsGain: 1,
forwardGain: 1,
});
});
test('gain sets normalize into the 0..4 range and fall back per key', () => {
assert.deepEqual(normalizeGainSet({ hornGain: 9, ttsGain: 'x' }, BOOST_CAPS), {
hornGain: 4,
ttsGain: BOOST_CAPS.ttsGain,
forwardGain: BOOST_CAPS.forwardGain,
});
});
test('clamps reject non-finite input by returning the supplied fallback', () => {
assert.equal(clampGain(Number.NaN, 0.7), 0.7);
assert.equal(clampGain(Infinity, 0.7), 0.7);
assert.equal(clampFraction(undefined, 0.4), 0.4);
});
+228 -6
View File
@@ -9,24 +9,53 @@ const { loadConfig } = require('../../helpers/configLoader');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin } = require('../roleService'); const { isAdmin } = require('../roleService');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
const { issueCommand } = require('../commandService'); const { issueCommand } = require('../commandService');
const {
GAIN_KEYS,
clampGain,
clampFraction,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
} = require('./gainMath');
const audioLevelsEvents = new EventEmitter(); const audioLevelsEvents = new EventEmitter();
const DATA_DIR = resolveDataDir(); const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('audio-levels.json'); const STORE_PATH = resolveDataPath('audio-levels.json');
const config = loadConfig(); const config = loadConfig();
const configuredDefaults = config.audioLevels || {}; const configuredDefaults = config.audioLevels || {};
const configuredUserCaps = configuredDefaults.userGainCaps || {};
/*
Per-user preferences live in identity feature state so they follow the user
across browsers and cannot be raised by editing a client-side cookie. They are
stored as a 0..1 fraction of whatever ceiling currently applies rather than an
absolute gain, so lowering the global admin gain immediately quiets everyone
without having to rewrite every stored preference.
*/
const USER_GAINS_NAMESPACE = 'audioGains';
/*
Absolute ceilings for users holding the audioGainBoost flag. These are the
hard caps the flag cannot exceed; admins can retune them from the driver page.
*/
const USER_GAIN_CAP_DEFAULTS = {
hornGain: 0.5,
ttsGain: 0.8,
forwardGain: 0.4,
};
const DEFAULTS = { const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1), hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1), ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1), forwardGain: clampGain(configuredDefaults.forwardGain, 1),
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
}; };
function clampGain(value, fallback = 1) { function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
const num = Number(value); return normalizeGainSet(raw, fallback);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(4, num));
} }
function normalizeStore(raw = {}) { function normalizeStore(raw = {}) {
@@ -34,8 +63,11 @@ function normalizeStore(raw = {}) {
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain), hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain), ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain), forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null, updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null, updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
capsUpdatedAt: Number.isFinite(raw.capsUpdatedAt) ? raw.capsUpdatedAt : null,
capsUpdatedBy: typeof raw.capsUpdatedBy === 'string' ? raw.capsUpdatedBy : null,
}; };
} }
@@ -71,18 +103,94 @@ function getAudioLevels() {
hornGain: current.hornGain, hornGain: current.hornGain,
ttsGain: current.ttsGain, ttsGain: current.ttsGain,
forwardGain: current.forwardGain, forwardGain: current.forwardGain,
userGainCaps: { ...current.userGainCaps },
updatedAt: current.updatedAt, updatedAt: current.updatedAt,
updatedBy: current.updatedBy, updatedBy: current.updatedBy,
capsUpdatedAt: current.capsUpdatedAt,
capsUpdatedBy: current.capsUpdatedBy,
}; };
} }
function emitChange(reason = 'update') { function getUserGainCaps() {
return { ...loadState().userGainCaps };
}
function emitChange(reason = 'update', extra = {}) {
audioLevelsEvents.emit('change', { audioLevelsEvents.emit('change', {
reason, reason,
levels: getAudioLevels(), levels: getAudioLevels(),
...extra,
}); });
} }
function getAdminLimits() {
const current = loadState();
return {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
};
}
function getGainCeilings(hasBoost) {
const current = loadState();
return resolveCeilings({
adminLimits: getAdminLimits(),
boostCaps: current.userGainCaps,
hasBoost,
});
}
function getGainCeilingsForSocket(socket) {
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
}
function getUserGains(userId) {
if (!userId) return normalizeUserGains({});
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
}
function getUserGainsForSocket(socket) {
return getUserGains(getUserIdForSocket(socket));
}
function getEffectiveLevelsForSocket(socket) {
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
}
/*
The rover applies gain as three ALSA master controls, so only one set of gains
can be live per rover at a time. That is not a limitation in practice: horn,
TTS, and mic forwarding are all restricted to the socket currently holding
audio control, so pushing that socket's resolved gains gives genuinely
per-user volume. When nobody owns audio the global admin gains apply.
*/
function resolveAudioOwnerSocket(roverId) {
const record = roverManager.rovers.get(roverId);
if (!record) return null;
const driverIds = Array.from(record.drivers || []);
if (!driverIds.length) return null;
// Required lazily: turnService reaches back into roverManager during startup.
let activeSocketId = null;
try {
activeSocketId = require('../turnService').getActiveDrivers()[roverId] || null;
} catch (err) {
logger.warn('Failed to resolve active driver for audio levels', roverId, err.message);
}
const chosenId = activeSocketId && driverIds.includes(activeSocketId)
? activeSocketId
: (driverIds.length === 1 ? driverIds[0] : null);
if (!chosenId) return null;
return io.sockets.sockets.get(chosenId) || null;
}
function resolveLevelsForRover(roverId) {
const owner = resolveAudioOwnerSocket(roverId);
return owner ? getEffectiveLevelsForSocket(owner) : getAdminLimits();
}
function pushLevelsToRover(roverId) { function pushLevelsToRover(roverId) {
if (!roverId) return; if (!roverId) return;
const record = roverManager.rovers.get(roverId); const record = roverManager.rovers.get(roverId);
@@ -90,7 +198,7 @@ function pushLevelsToRover(roverId) {
try { try {
issueCommand(roverId, { issueCommand(roverId, {
type: 'audioLevels', type: 'audioLevels',
audioLevels: getAudioLevels(), audioLevels: resolveLevelsForRover(roverId),
}); });
} catch (err) { } catch (err) {
logger.warn('Failed to push audio levels to rover', roverId, err.message); logger.warn('Failed to push audio levels to rover', roverId, err.message);
@@ -105,6 +213,11 @@ function pushLevelsToAllRovers() {
}); });
} }
function pushLevelsForSocket(socket) {
if (!socket) return;
roverManager.getRoversForSocket(socket.id).forEach((roverId) => pushLevelsToRover(roverId));
}
function setAudioLevels(input = {}, actor = null) { function setAudioLevels(input = {}, actor = null) {
const current = loadState(); const current = loadState();
const next = { const next = {
@@ -121,12 +234,83 @@ function setAudioLevels(input = {}, actor = null) {
return getAudioLevels(); return getAudioLevels();
} }
function setUserGainCaps(input = {}, actor = null) {
const current = loadState();
const next = {
...current,
userGainCaps: normalizeUserGainCaps(input, current.userGainCaps),
capsUpdatedAt: Date.now(),
capsUpdatedBy: actor,
};
persistState(next);
/*
Lowering a cap has to take effect immediately for anyone already driving,
otherwise a boosted user keeps the louder gain until their next turn.
*/
pushLevelsToAllRovers();
emitChange('user_caps_set');
return getUserGainCaps();
}
function setUserGains(socket, input = {}) {
const userId = getUserIdForSocket(socket);
if (!userId) throw new Error('Identity required');
const current = getUserGains(userId);
const next = { ...current };
GAIN_KEYS.forEach((key) => {
if (input?.[key] === undefined) return;
next[key] = clampFraction(input[key], current[key]);
});
setFeatureState(userId, USER_GAINS_NAMESPACE, next);
pushLevelsForSocket(socket);
emitChange('user_gains_set', { scope: 'user', userId });
return getAudioGainStateForSocket(socket);
}
/*
The client needs all three layers to render an honest slider: its own stored
fraction, the ceiling that fraction is measured against, and the resolved gain
so the UI can show what the rover will actually play.
*/
function getAudioGainStateForSocket(socket) {
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
const values = getUserGainsForSocket(socket);
const ceilings = getGainCeilings(hasBoost);
return {
values,
ceilings,
effective: applyCeilings(values, ceilings),
boostGranted: hasBoost,
adminLimits: getAdminLimits(),
boostCaps: getUserGainCaps(),
};
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => { roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (action === 'upsert' && roverId) { if (action === 'upsert' && roverId) {
pushLevelsToRover(roverId); pushLevelsToRover(roverId);
} }
}); });
/*
Whoever owns a rover's audio determines which gains are live, so the rover has
to be re-pushed whenever that ownership moves: joining or leaving a rover, and
every turn rotation.
*/
roverManager.managerEvents.on('driver', ({ roverId } = {}) => {
if (roverId) pushLevelsToRover(roverId);
});
setImmediate(() => {
try {
require('../turnService').turnEvents.on('queue', ({ roverId } = {}) => {
if (roverId) pushLevelsToRover(roverId);
});
} catch (err) {
logger.warn('Failed to subscribe to turn changes for audio levels', err.message);
}
});
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('audioLevels:get', (_, cb = () => {}) => { socket.on('audioLevels:get', (_, cb = () => {}) => {
cb({ success: true, levels: getAudioLevels() }); cb({ success: true, levels: getAudioLevels() });
@@ -144,13 +328,51 @@ io.on('connection', (socket) => {
cb({ error: err.message }); cb({ error: err.message });
} }
}); });
socket.on('audioLevels:setUserCaps', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const actor = socket?.data?.user?.username || null;
const userGainCaps = setUserGainCaps(payload || {}, actor);
cb({ success: true, userGainCaps });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audioLevels:getUserGains', (_, cb = () => {}) => {
try {
cb({ success: true, audioGains: getAudioGainStateForSocket(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audioLevels:setUserGains', (payload = {}, cb = () => {}) => {
try {
cb({ success: true, audioGains: setUserGains(socket, payload || {}) });
} catch (err) {
cb({ error: err.message });
}
});
}); });
loadState(); loadState();
module.exports = { module.exports = {
GAIN_KEYS,
USER_GAIN_CAP_DEFAULTS,
getAudioLevels, getAudioLevels,
setAudioLevels, setAudioLevels,
getUserGainCaps,
setUserGainCaps,
getUserGains,
setUserGains,
getGainCeilingsForSocket,
getEffectiveLevelsForSocket,
getAudioGainStateForSocket,
pushLevelsToRover, pushLevelsToRover,
audioLevelsEvents, audioLevelsEvents,
}; };
+25 -1
View File
@@ -3,6 +3,7 @@
// Scope: Owns message validation pipeline and typed outbound message construction. // Scope: Owns message validation pipeline and typed outbound message construction.
const logger = require('../../globals/logger').child('chatService'); const logger = require('../../globals/logger').child('chatService');
const { getRole } = require('../roleService'); const { getRole } = require('../roleService');
const { isDeterred, isMuted } = require('../verificationService');
const { withinRateLimit } = require('./state'); const { withinRateLimit } = require('./state');
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters'); const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders'); const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
@@ -17,6 +18,12 @@ function createHandlers({ sendSystemMessage }) {
const normalized = normalizeUserText(text); const normalized = normalizeUserText(text);
const clean = normalized.trim(); const clean = normalized.trim();
if (!clean) return cb({ error: 'Message required' }); if (!clean) return cb({ error: 'Message required' });
/*
Mute is narrower than deterrence: the socket may continue driving and
using ordinary features, but its message must stop before broadcast,
command parsing, TTS, or any other chat-derived side effect occurs.
*/
if (isMuted(socket)) return cb({ error: 'Muted' });
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' }); if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
// This service no longer enforces a character-count ceiling for chat text. // This service no longer enforces a character-count ceiling for chat text.
// The chat layer only rejects empty, rate-limited, or moderated content so // The chat layer only rejects empty, rate-limited, or moderated content so
@@ -37,23 +44,40 @@ function createHandlers({ sendSystemMessage }) {
}); });
logger.info('Chat message', { socket: socket.id, roverId: message.roverId }); logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
const deterred = isDeterred(socket);
/*
Deterred users retain text chat, but chat must not become an indirect
hardware-control path. Suppress the rover typing note and TTS while still
constructing and broadcasting the same visible message as everyone else.
*/
if (!deterred) {
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id); playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
}
if (isPrivateClosedRoverId(message.roverId)) { if (isPrivateClosedRoverId(message.roverId)) {
// Private-closed chat does not broadcast the text, so TTS is the only // Private-closed chat does not broadcast the text, so TTS is the only
// delivery path. Use the same Google speech default as normal chat when // delivery path. Use the same Google speech default as normal chat when
// the sender did not provide explicit TTS settings. // the sender did not provide explicit TTS settings.
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' }; const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
if (!deterred) {
maybeSpeak(socket, message, forcedTts); maybeSpeak(socket, message, forcedTts);
}
cb({ success: true, privateOnly: true }); cb({ success: true, privateOnly: true });
return; return;
} }
broadcastMessage(message); broadcastMessage(message);
maybeSendAccessNotice(message, sendSystemMessage); maybeSendAccessNotice(message, sendSystemMessage);
if (!deterred) {
maybeSpeak(socket, message, ttsOptions); maybeSpeak(socket, message, ttsOptions);
}
const command = isTextCommand(clean); /*
Command-shaped text from a deterred user remains ordinary visible chat.
Reporting command=false prevents the client from implying that the server
accepted an action, and the command router is never invoked.
*/
const command = !deterred && isTextCommand(clean);
// Chat delivery is complete once validation, broadcast, and local side // Chat delivery is complete once validation, broadcast, and local side
// effects above have succeeded. A command may wait on Home Assistant, // effects above have succeeded. A command may wait on Home Assistant,
// hardware, replay preparation, or an external transport, so tying the // hardware, replay preparation, or an external transport, so tying the
+20 -1
View File
@@ -3,6 +3,7 @@
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients. // Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
const io = require('../../globals/io'); const io = require('../../globals/io');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { isDeterred, isMuted } = require('../verificationService');
const { typingBySocket } = require('./state'); const { typingBySocket } = require('./state');
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders'); const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
const { broadcastTyping } = require('./broadcast'); const { broadcastTyping } = require('./broadcast');
@@ -13,11 +14,29 @@ function registerChatSocketHooks({ history, handleIncoming }) {
socket.emit('chat:init', history); socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb)); socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => { socket.on('chat:typing', (payload = {}) => {
/*
A muted typing packet must not leak presence or produce rover notes.
Clearing any prior state also removes a typing indicator that began
immediately before an administrator applied the mute.
*/
if (isMuted(socket)) {
const wasTyping = typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id);
if (wasTyping && !isPrivateClosedRoverId(roverId)) {
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }));
}
return;
}
const isTyping = Boolean(payload?.isTyping); const isTyping = Boolean(payload?.isTyping);
const wasTyping = typingBySocket.get(socket.id); const wasTyping = typingBySocket.get(socket.id);
if (isTyping) { if (isTyping) {
typingBySocket.set(socket.id, true); typingBySocket.set(socket.id, true);
if (!wasTyping) { /*
The typing indicator is part of chat and remains available to a
deterred user. The rover note is a physical side effect, however, so
text-only deterrence suppresses that note without changing presence.
*/
if (!wasTyping && !isDeterred(socket)) {
const roverId = resolveRoverId(socket?.id); const roverId = resolveRoverId(socket?.id);
playTypingNote(roverId, TYPING_START_NOTE, socket?.id); playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
} }
@@ -17,13 +17,21 @@ const {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
} = require('../verificationService'); } = require('../verificationService');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
const funStatsService = require('../funStatsService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { createCommandHandlers } = require('../operatorCommandService'); const { createCommandHandlers } = require('../operatorCommandService');
const { createCooldownGate } = require('../operatorCommandService/cooldowns');
const { parseCommandText } = require('../operatorCommandService/config'); const { parseCommandText } = require('../operatorCommandService/config');
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport'); const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
const { commandReplyToText } = require('./commandResultFormatter'); const { commandReplyToText } = require('./commandResultFormatter');
@@ -36,6 +44,14 @@ const {
const config = loadConfig(); const config = loadConfig();
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
/*
Site chat builds a fresh command router for every message so each router can
close over the sending socket. Fun command cooldowns therefore have to live out
here: a gate created inside the router would be thrown away after one message
and would never actually rate limit anything.
*/
const commandCooldowns = createCooldownGate();
function isTextCommand(text) { function isTextCommand(text) {
return parseCommandText(text, config).matched; return parseCommandText(text, config).matched;
} }
@@ -117,6 +133,12 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) {
actor: { actor: {
bot: false, bot: false,
id: socket.id, id: socket.id,
/*
Fun command tallies are keyed by identity rather than connection, so the
canonical user id is passed alongside the socket id. Without it a user's
bonk count would reset on every reconnect and split across browser tabs.
*/
userId: String(socket?.data?.userId || '').trim() || null,
label: nickname, label: nickname,
isAdmin: isAdmin(socket), isAdmin: isAdmin(socket),
isLockdownAdmin: isLockdownAdmin(socket), isLockdownAdmin: isLockdownAdmin(socket),
@@ -176,9 +198,28 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
sanitizeMentions, sanitizeMentions,
funStatsService,
commandCooldowns,
// Lets `rs bonk` announce itself so audioForwardService can play the bonk
// sound on the rover the target is driving.
publishEvent,
/*
Fun commands that move hardware need the sending socket so they can prove
the caller holds control. issueCommand is required lazily for the same
reason replayEngineV2 is: commandService registers socket handlers on load,
and chatService should not pull that forward in the boot order.
*/
getActorSocket: () => socket,
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
sendToChannel: null, sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
+46 -1
View File
@@ -2,15 +2,22 @@
// Purpose: Defines the command Service module and the helpers/state used by this service unit. // Purpose: Defines the command Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { isAdmin, isLockdownAdmin } = require('../roleService'); const { isAdmin, isLockdownAdmin } = require('../roleService');
const { isDeterred } = require('../verificationService'); const { isDeterred, isMuted } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService'); const logger = require('../../globals/logger').child('commandService');
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness'); const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
const overcurrentProtectionService = require('../overcurrentProtectionService'); const overcurrentProtectionService = require('../overcurrentProtectionService');
// Command observations are intentionally separate from the global event bus.
// Drive and motor commands can run at control-loop frequency, and publishing
// every packet onto the logging event bus would manufacture noise. Optional
// observers can aggregate this emitter without changing command delivery.
const commandEvents = new EventEmitter();
const pendingCommands = new Map(); // id -> { roverId } const pendingCommands = new Map(); // id -> { roverId }
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin } const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
const driveCooldowns = new Map(); // roverId -> blockedUntil const driveCooldowns = new Map(); // roverId -> blockedUntil
@@ -130,6 +137,16 @@ function handleAck(msg) {
status: msg.status || 'ok', status: msg.status || 'ok',
error: msg.error, error: msg.error,
}); });
commandEvents.emit('observation', {
ts: Date.now(),
roverId: pending.roverId,
type: pending.type,
commandId: msg.id,
outcome: msg.error ? 'failed' : 'acknowledged',
latencyMs: Date.now() - pending.ts,
status: msg.status || 'ok',
error: msg.error || null,
});
} }
function issueUpdateToAllRovers() { function issueUpdateToAllRovers() {
@@ -237,6 +254,7 @@ module.exports = {
handleAck, handleAck,
getRecentDriveActivity, getRecentDriveActivity,
setDriveCooldown, setDriveCooldown,
commandEvents,
}; };
io.on('connection', (socket) => { io.on('connection', (socket) => {
@@ -274,6 +292,14 @@ io.on('connection', (socket) => {
if (!isAdminSocket && isDeterred(socket)) { if (!isAdminSocket && isDeterred(socket)) {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
/*
Both structured song commands and raw Open Interface song payloads
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
and any future browser beeper without affecting unrelated driving.
*/
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
throw new Error('Muted');
}
// Rover updates run a privileged, root-owned helper on the Pi. Keep this // Rover updates run a privileged, root-owned helper on the Pi. Keep this
// in the same explicit admin-only branch as reboot instead of relying on // in the same explicit admin-only branch as reboot instead of relying on
// drive ownership checks, because having a turn should not grant system // drive ownership checks, because having a turn should not grant system
@@ -331,6 +357,17 @@ io.on('connection', (socket) => {
}); });
} }
const id = issueCommand(roverId, { type, ...payload }); const id = issueCommand(roverId, { type, ...payload });
commandEvents.emit('observation', {
ts: Date.now(),
roverId: String(roverId),
type,
commandId: id,
outcome: 'issued',
socketId: socket.id,
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
// commands can carry arbitrary content. Their structured type/outcome
// supplies analytics without accidentally persisting secret material.
});
logger.info('Queued command', socket.id, roverId, type); logger.info('Queued command', socket.id, roverId, type);
if (shouldRecordTurnActivity(type, payload)) { if (shouldRecordTurnActivity(type, payload)) {
try { try {
@@ -343,6 +380,14 @@ io.on('connection', (socket) => {
reply({ id }); reply({ id });
} catch (err) { } catch (err) {
logger.warn('Command rejected', socket.id, err.message); logger.warn('Command rejected', socket.id, err.message);
commandEvents.emit('observation', {
ts: Date.now(),
roverId: roverId ? String(roverId) : null,
type: type || 'unknown',
outcome: 'rejected',
socketId: socket.id,
error: err.message,
});
reply({ error: err.message }); reply({ error: err.message });
} }
} }
@@ -12,7 +12,7 @@ const {
createReplaySourceResolver, createReplaySourceResolver,
createReplayCaptionBuilder, createReplayCaptionBuilder,
startDiscordTypingLoop, startDiscordTypingLoop,
sanitizeReplayTitleForFilename, buildReplayFilename,
firstAttachmentFromMessage, firstAttachmentFromMessage,
buildDiscordReplayMediaPayload, buildDiscordReplayMediaPayload,
buildAcceptedMessage, buildAcceptedMessage,
@@ -103,7 +103,9 @@ function createReplayCommand({
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
} }
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` }); // Keep direct Discord commands consistent with web-triggered uploads and
// with the local hosting fallback used when Discord delivery is unavailable.
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
const body = replayCaption.build({ job, usedSources, missingSources }); const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await progressMessage.reply({ const uploadMessage = await progressMessage.reply({
content: body, content: body,
@@ -0,0 +1,149 @@
// Discord Fleet Daily Reports
// Purpose: Schedules and delivers completed-day fleet summaries to the existing admin alert channel.
// Scope: Discord owns timing/formatting/delivery; the fleet service owns evidence, analysis, and durable delivery state.
const { DateTime } = require('luxon');
const { EmbedBuilder } = require('discord.js');
function parseSendTime(value) {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || '').trim());
if (!match) return { hour: 8, minute: 0 };
return {
hour: Math.max(0, Math.min(23, Number(match[1]))),
minute: Math.max(0, Math.min(59, Number(match[2]))),
};
}
function nextRunAt({ zone, hour, minute }) {
const now = DateTime.now().setZone(zone);
let next = now.set({ hour, minute, second: 0, millisecond: 0 });
if (next <= now) next = next.plus({ days: 1 });
return next;
}
function formatNumber(value, digits = 1) {
return Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: digits });
}
function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetReportService, roverManager, sendToChannel }) {
let timer = null;
const reportConfig = fleetConfig?.discord || {};
const enabled = fleetReportService?.enabled && reportConfig.enabled !== false;
const channelId = discordConfig?.channels?.adminAlerts;
const zone = String(reportConfig.timezone || 'America/New_York');
const { hour, minute } = parseSendTime(reportConfig.sendAt);
function publicRoverIds() {
// Discord's shared admin-alert channel does not provide a per-viewer socket
// against which private-rover grants can be checked. Excluding private
// rovers here preserves the existing privacy boundary instead of assuming
// every channel reader has every private grant.
return roverManager.getRoster()
.filter((rover) => !rover?.private?.enabled)
.map((rover) => String(rover.id));
}
function completedDayRange() {
const end = DateTime.now().setZone(zone).startOf('day');
const start = end.minus({ days: 1 });
return {
reportDate: start.toISODate(),
since: start.toMillis(),
until: end.toMillis(),
};
}
function buildEmbed(reportDate, report) {
const totals = report.totals;
const attention = report.attention
.filter((item) => item.severity !== 'notice')
.slice(0, 12)
.map((item) => `${item.roverId}: ${item.title}`)
.join('\n') || 'No material battery or efficiency changes need attention.';
const roverLines = report.rovers.map((rover) => {
const health = rover.batteryHealth || {};
const efficiency = rover.overallWhPerKm == null
? `efficiency pending (${formatNumber(rover.distanceMm / 1000, 0)} m)`
: `${formatNumber(rover.overallWhPerKm)} Wh/km`;
const capacity = health.measuredUsableMah == null
? `health collecting (${health.confidence || 'low'} confidence)`
: `${formatNumber(health.measuredUsableMah / 1000, 2)} Ah usable · ${formatNumber(health.capacityRetentionPercent)}% retained · ${health.confidence} confidence`;
return `${rover.name}: ${formatNumber(rover.distanceMm / 1e6, 2)} km · ${formatNumber(rover.dischargedWh, 2)} Wh · ${efficiency}\n Battery: ${capacity}`;
}
).join('\n') || 'No public rover telemetry.';
return new EmbedBuilder()
.setTitle(`Daily fleet report — ${reportDate}`)
.setColor(totals.attentionCount ? 0xf0b651 : 0x4caf50)
.addFields(
{
name: 'Fleet energy',
value: `${formatNumber(totals.distanceMm / 1e6, 2)} km · ${formatNumber(totals.dischargedWh, 2)} Wh · ${totals.overallWhPerKm == null ? 'efficiency pending' : `${formatNumber(totals.overallWhPerKm)} Wh/km`} · ${formatNumber(totals.stationaryDischargedWh, 2)} stationary Wh`,
},
{ name: 'Needs attention', value: attention.slice(0, 1024) },
{ name: 'Rovers', value: roverLines.slice(0, 1024) },
)
.setFooter({ text: 'The server reports page contains the complete all-rovers metric table.' });
}
async function deliverPreviousDay() {
if (!enabled || !channelId) return;
const range = completedDayRange();
const existing = fleetReportService.storage.getDailyReport(range.reportDate);
if (existing?.discordDeliveredAt) return;
const report = fleetReportService.getDailyReport({
since: range.since,
until: range.until,
roverIds: publicRoverIds(),
});
if (!report) return;
/*
Daily storage retains the exact metric report used for delivery, but the
Discord message intentionally has no raw JSON attachment. Admins need
actionable fleet comparisons here; the complete read-only evidence stays
on the reports page without turning routine events into notification noise.
*/
fleetReportService.storage.saveDailyReport(range.reportDate, report);
const sent = await sendToChannel(
channelId,
`Daily fleet report for ${range.reportDate}`,
{ embeds: [buildEmbed(range.reportDate, report)] },
{ parse: [] },
);
if (sent) {
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { deliveredAt: Date.now(), error: null });
} else {
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { error: 'Discord delivery returned no message' });
}
}
function scheduleNext() {
if (!enabled || !channelId) return;
const next = nextRunAt({ zone, hour, minute });
const delay = Math.max(1000, next.toMillis() - Date.now());
timer = setTimeout(async () => {
try {
await deliverPreviousDay();
} catch (err) {
logger.warn('Daily fleet report delivery failed', { error: err.message });
} finally {
scheduleNext();
}
}, delay);
timer.unref?.();
logger.info('Scheduled daily fleet report', { nextRunAt: next.toISO(), channelId });
}
function start() {
scheduleNext();
}
function stop() {
if (timer) clearTimeout(timer);
timer = null;
}
return { start, stop, deliverPreviousDay };
}
module.exports = {
createFleetDailyReports,
};
+49 -3
View File
@@ -41,8 +41,14 @@ const {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
} = require('../verificationService'); } = require('../verificationService');
const { const {
attachDmMessage: attachPrivateAccessDmMessage, attachDmMessage: attachPrivateAccessDmMessage,
@@ -50,18 +56,23 @@ const {
approveRequest: approvePrivateAccessRequest, approveRequest: approvePrivateAccessRequest,
denyRequest: denyPrivateAccessRequest, denyRequest: denyPrivateAccessRequest,
} = require('../privateRoverAccessRequestService'); } = require('../privateRoverAccessRequestService');
const { subscribe } = require('../eventBus'); const { subscribe, publishEvent } = require('../eventBus');
const funStatsService = require('../funStatsService');
const { issueCommand } = require('../commandService');
const { createPresenceManager } = require('./presence'); const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO'); const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('../operatorCommandService'); const { createCommandHandlers } = require('../operatorCommandService');
const { createCooldownGate } = require('../operatorCommandService/cooldowns');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter'); const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations'); const { createIntegrations } = require('./integrations');
const { createFleetDailyReports } = require('./fleetDailyReports');
const fleetReportService = require('../fleetReportService');
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService'); const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
const { const {
DEFAULT_ALLOWED_MENTIONS, DEFAULT_ALLOWED_MENTIONS,
createReplayCaptionBuilder, createReplayCaptionBuilder,
startDiscordTypingLoop, startDiscordTypingLoop,
sanitizeReplayTitleForFilename, buildReplayFilename,
firstAttachmentFromMessage, firstAttachmentFromMessage,
buildDiscordReplayMediaPayload, buildDiscordReplayMediaPayload,
buildAcceptedMessage, buildAcceptedMessage,
@@ -164,7 +175,9 @@ if (discordConfig?.channels?.replay) {
if (progressMessage?.edit) { if (progressMessage?.edit) {
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
} }
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` }); // Every delivery path uses the job creation time, so a Discord upload
// and a server-hosted fallback always expose the same replay filename.
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
const body = replayCaption.build({ job, usedSources, missingSources }); const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS); 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'); if (!uploadMessage) throw new Error('Discord upload did not return a message');
@@ -199,6 +212,10 @@ if (discordConfig?.channels?.replay) {
}); });
} }
// The Discord router is built once for the process, so one gate here covers every
// guild and channel this bot answers in.
const commandCooldowns = createCooldownGate();
const commandDependencies = { const commandDependencies = {
logger, logger,
client, client,
@@ -238,9 +255,27 @@ const commandDependencies = {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
sanitizeMentions, sanitizeMentions,
funStatsService,
commandCooldowns,
// A Discord bonk still plays the sound on the rover the target is driving; only
// the commands that need the caller's own socket are unavailable from here.
publishEvent,
/*
Discord has no socket behind a message, so the hardware-backed fun commands
cannot prove drive control and decline with an explanation instead. The text,
counter, and read-only fun commands work normally from here.
*/
getActorSocket: () => null,
issueCommand,
sendToChannel: channelIO.sendToChannel, sendToChannel: channelIO.sendToChannel,
isAdminUser, isAdminUser,
isLockdownAdminUser, isLockdownAdminUser,
@@ -347,6 +382,17 @@ client.on('messageCreate', async (message) => {
client.once('ready', () => { client.once('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag }); logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation(); presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
createFleetDailyReports({
logger,
discordConfig,
fleetConfig: config.fleetReports || {},
fleetReportService,
roverManager,
sendToChannel: channelIO.sendToChannel,
}).start();
}); });
client.login(discordConfig.token).catch((err) => { client.login(discordConfig.token).catch((err) => {
+12 -2
View File
@@ -2,14 +2,14 @@
// Purpose: Defines the embed Http Service module and the helpers/state used by this service unit. // Purpose: Defines the embed Http Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const { renderIndexHtml, renderOgImage } = require('../embedService'); const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedService');
/* /*
Every client-side BrowserRouter entry point must also be an explicit HTTP Every client-side BrowserRouter entry point must also be an explicit HTTP
entry point. Including /ptz here lets direct loads and browser refreshes entry point. Including /ptz here lets direct loads and browser refreshes
receive the same rendered index document as navigation from the driver page. receive the same rendered index document as navigation from the driver page.
*/ */
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz'], async (req, res) => { app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
try { try {
const html = await renderIndexHtml(req); const html = await renderIndexHtml(req);
res.type('html').send(html); res.type('html').send(html);
@@ -27,3 +27,13 @@ app.get('/og/preview.png', async (req, res) => {
res.status(500).send('Failed to render embed image'); res.status(500).send('Failed to render embed image');
} }
}); });
app.get('/manifest.webmanifest', (req, res) => {
/*
The manifest varies with server configuration, so it is served by the
application rather than copied into Vite's static output. Revalidation
lets browsers pick up branding changes after the server is restarted.
*/
res.set('Cache-Control', 'no-cache');
res.type('application/manifest+json').send(renderWebManifest());
});
+142 -47
View File
@@ -2,26 +2,60 @@
// Purpose: Defines the embed Service module and the helpers/state used by this service unit. // Purpose: Defines the embed Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const path = require('path'); const path = require('path');
const fs = require('fs');
const fsp = require('fs/promises'); const fsp = require('fs/promises');
const sharp = require('sharp'); const sharp = require('sharp');
const logger = require('../../globals/logger').child('embedService');
const { getMode } = require('../modeManager'); const { getMode } = require('../modeManager');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getActiveDrivers, getTurnQueues } = require('../turnService'); const { getActiveDrivers, getTurnQueues } = require('../turnService');
const { getRoomCameras } = require('../roomCameraService'); const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService'); const { getRoomCameraState } = require('../roomCameraService');
const { loadConfig } = require('../../helpers/configLoader'); const { resolveDataPath } = require('../../helpers/dataPaths');
const { resolveSiteMetadata } = require('../../helpers/siteMetadata');
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', '..', 'public', 'index.html'); const INDEX_HTML_PATH = path.join(__dirname, '..', '..', '..', 'public', 'index.html');
const BITMAP_PATH = path.join(__dirname, '..', '..', '..', 'public', 'bitmap.png'); const BITMAP_PATH = path.join(__dirname, '..', '..', '..', 'public', 'bitmap.png');
const ANALYTICS_HTML_PATH = resolveDataPath('analytics.html');
const ANALYTICS_PLACEHOLDER = '<!-- analytics:inject -->';
const SITE_METADATA_PLACEHOLDER = '<!-- site-metadata:inject -->';
const OG_WIDTH = 1200; const OG_WIDTH = 1200;
const OG_HEIGHT = 630; const OG_HEIGHT = 630;
const BASE_BG = { r: 8, g: 12, b: 22 };
let cachedIndexHtml = null; let cachedIndexHtml = null;
let cachedIndexMtimeMs = 0; let cachedIndexMtimeMs = 0;
/*
Analytics provider markup belongs to the server operator, not to the shared
web build. Loading the snippet once at process startup makes deployment
behavior predictable: replacing analytics.html takes effect on the next
normal server restart, and no analytics configuration needs to travel over
Socket.IO or be exposed through a JSON endpoint.
This file is intentionally trusted as raw HTML. Anyone able to write files in
the server data directory already controls the deployment, and allowing a
complete head snippet is what keeps this integration compatible with Umami,
Plausible, Matomo, or a custom provider without provider-specific server code.
*/
function loadAnalyticsHeadHtml() {
if (!fs.existsSync(ANALYTICS_HTML_PATH)) return '';
try {
return fs.readFileSync(ANALYTICS_HTML_PATH, 'utf8').trim();
} catch (err) {
/*
Analytics is observability-only, so a permissions or read error must not
prevent operators and drivers from loading the rover controls.
*/
logger.warn('Unable to read analytics head HTML; continuing without analytics', err.message);
return '';
}
}
const analyticsHeadHtml = loadAnalyticsHeadHtml();
function escapeHtml(value) { function escapeHtml(value) {
return String(value || '') return String(value || '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -52,6 +86,20 @@ function getBaseUrl(req) {
return `${proto}://${host}`; return `${proto}://${host}`;
} }
function getPagePath(req) {
/*
Canonical URLs should describe the page rather than a tracking/query
variant of it. Express's path value excludes the query string and is safe
to combine with either the configured public URL or the current request.
*/
return req.path || '/';
}
function joinPublicUrl(baseUrl, pagePath) {
const normalizedPath = pagePath.startsWith('/') ? pagePath : `/${pagePath}`;
return `${baseUrl}${normalizedPath}`;
}
function getPrimaryRoomCamera() { function getPrimaryRoomCamera() {
const cameras = getRoomCameras(); const cameras = getRoomCameras();
if (!cameras.length) return null; if (!cameras.length) return null;
@@ -86,33 +134,6 @@ function buildEmbedCopy(state, camera) {
lockdown: 'locked', lockdown: 'locked',
}[mode] || mode; }[mode] || mode;
let title = 'Roomba Rover';
if (mode === 'lockdown') {
title = 'Private mode is on';
} else if (roversOnline === 0) {
title = 'Rovers offline - check back soon';
} else if (driverCount > 0) {
title = 'Rovers in use - drive a rover';
} else if (mode === 'turns') {
title = 'Controls open - jump in';
} else {
title = 'Controls open - drive a rover';
}
const descriptionParts = [];
descriptionParts.push(`${roversOnline} rover${roversOnline === 1 ? '' : 's'} online`);
if (driverCount > 0) {
descriptionParts.push(`${driverCount} driving`);
} else {
descriptionParts.push('no active drivers');
}
if (mode === 'lockdown') {
descriptionParts.push('privacy mode');
} else {
descriptionParts.push(modeLabel);
}
const description = descriptionParts.join(' | ');
const statsParts = [ const statsParts = [
`${roversOnline} online`, `${roversOnline} online`,
driverCount > 0 ? `${driverCount} driving` : 'no drivers', driverCount > 0 ? `${driverCount} driving` : 'no drivers',
@@ -125,20 +146,17 @@ function buildEmbedCopy(state, camera) {
const cameraLabel = camera?.name || camera?.id || 'room cam'; const cameraLabel = camera?.name || camera?.id || 'room cam';
return { return {
title,
description,
subtitle: 'Control a live rover from your browser',
stats: statsParts.join(' | '), stats: statsParts.join(' | '),
cameraLabel: mode === 'lockdown' ? 'Room cams hidden' : `Room cam: ${cameraLabel}`, cameraLabel: mode === 'lockdown' ? 'Room cams hidden' : `Room cam: ${cameraLabel}`,
}; };
} }
function buildMetaTags({ title, description, imageUrl, pageUrl }) { function buildMetaTags({ title, description, imageUrl, pageUrl, canonicalUrl }) {
const safeTitle = escapeHtml(title); const safeTitle = escapeHtml(title);
const safeDescription = escapeHtml(description); const safeDescription = escapeHtml(description);
const safeImage = escapeHtml(imageUrl); const safeImage = escapeHtml(imageUrl);
const safeUrl = escapeHtml(pageUrl); const safeUrl = escapeHtml(pageUrl);
return [ const tags = [
'<!-- embed meta -->', '<!-- embed meta -->',
`<meta name="description" content="${safeDescription}" />`, `<meta name="description" content="${safeDescription}" />`,
`<meta property="og:title" content="${safeTitle}" />`, `<meta property="og:title" content="${safeTitle}" />`,
@@ -153,7 +171,27 @@ function buildMetaTags({ title, description, imageUrl, pageUrl }) {
`<meta name="twitter:title" content="${safeTitle}" />`, `<meta name="twitter:title" content="${safeTitle}" />`,
`<meta name="twitter:description" content="${safeDescription}" />`, `<meta name="twitter:description" content="${safeDescription}" />`,
`<meta name="twitter:image" content="${safeImage}" />`, `<meta name="twitter:image" content="${safeImage}" />`,
'<!-- /embed meta -->', ];
/*
Only advertise a canonical address when the operator supplied a valid
public URL. Guessing from request headers would permanently identify a LAN
hostname or reverse-proxy hop as the public home of the instance.
*/
if (canonicalUrl) {
tags.push(`<link rel="canonical" href="${escapeHtml(canonicalUrl)}" />`);
}
tags.push('<!-- /embed meta -->');
return tags.join('\n ');
}
function buildSiteMetadataTags(siteMetadata) {
return [
'<!-- site metadata -->',
`<meta name="theme-color" content="${escapeHtml(siteMetadata.accentColor)}" />`,
`<meta name="apple-mobile-web-app-title" content="${escapeHtml(siteMetadata.shortName)}" />`,
`<title>${escapeHtml(siteMetadata.name)}</title>`,
'<!-- /site metadata -->',
].join('\n '); ].join('\n ');
} }
@@ -165,23 +203,47 @@ async function renderIndexHtml(req) {
activeDrivers: getActiveDrivers(), activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(), turnQueues: getTurnQueues(),
}; };
const config = loadConfig(); const siteMetadata = resolveSiteMetadata();
const pageTitle = config?.site?.title || 'Roomba Rover';
const camera = getPrimaryRoomCamera(); const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera); const copy = buildEmbedCopy(state, camera);
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000)); const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`; const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`; const pagePath = getPagePath(req);
const canonicalUrl = siteMetadata.publicUrl
? joinPublicUrl(siteMetadata.publicUrl, pagePath)
: null;
const pageUrl = canonicalUrl || joinPublicUrl(baseUrl, pagePath);
const metaBlock = buildMetaTags({ const metaBlock = buildMetaTags({
title: pageTitle, title: siteMetadata.name,
description: copy.description, description: siteMetadata.description,
imageUrl, imageUrl,
pageUrl, pageUrl,
canonicalUrl,
}); });
const siteMetadataBlock = buildSiteMetadataTags(siteMetadata);
let html = await loadIndexHtml(); let html = await loadIndexHtml();
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`); /*
Prefer the explicit marker so the insertion point remains stable across
Vite output changes. The closing-head fallback also keeps deployed builds
made before the marker was introduced compatible with the runtime loader.
*/
if (html.includes(ANALYTICS_PLACEHOLDER)) {
html = html.replace(ANALYTICS_PLACEHOLDER, analyticsHeadHtml);
} else if (analyticsHeadHtml) {
html = html.replace('</head>', ` ${analyticsHeadHtml}\n </head>`);
}
/*
Keeping all instance-specific head values behind one marker prevents the
built index from carrying a second set of hardcoded titles and colors.
The fallback supports an older built index during a rolling deployment.
*/
if (html.includes(SITE_METADATA_PLACEHOLDER)) {
html = html.replace(SITE_METADATA_PLACEHOLDER, siteMetadataBlock);
} else {
html = html.replace('</head>', ` ${siteMetadataBlock}\n </head>`);
}
if (html.includes('<!-- embed meta -->')) { if (html.includes('<!-- embed meta -->')) {
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock); html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
} else { } else {
@@ -190,7 +252,7 @@ async function renderIndexHtml(req) {
return html; return html;
} }
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) { function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame, accentColor, accentTextColor }) {
const titleSize = 64; const titleSize = 64;
const subtitleSize = 34; const subtitleSize = 34;
const statsSize = 30; const statsSize = 30;
@@ -207,8 +269,8 @@ function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
</defs> </defs>
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" /> <rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" /> <rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" /> <rect x="58" y="50" width="206" height="36" rx="18" fill="${accentColor}" />
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018"> <text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="${accentTextColor}">
${escapeXml(badgeText)} ${escapeXml(badgeText)}
</text> </text>
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff"> <text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
@@ -235,6 +297,7 @@ async function renderOgImage() {
}; };
const camera = getPrimaryRoomCamera(); const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera); const copy = buildEmbedCopy(state, camera);
const siteMetadata = resolveSiteMetadata();
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id); const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
const frame = cameraState?.frame || null; const frame = cameraState?.frame || null;
const hasFrame = Boolean(frame); const hasFrame = Boolean(frame);
@@ -246,17 +309,20 @@ async function renderOgImage() {
width: OG_WIDTH, width: OG_WIDTH,
height: OG_HEIGHT, height: OG_HEIGHT,
channels: 3, channels: 3,
background: BASE_BG, background: siteMetadata.backgroundColor,
}, },
}); });
const overlaySvg = Buffer.from( const overlaySvg = Buffer.from(
buildOverlaySvg({ buildOverlaySvg({
title: copy.title, title: siteMetadata.name,
subtitle: copy.subtitle, // The image must use the same resolved description as the page metadata and installed shortcut.
subtitle: siteMetadata.description,
stats: copy.stats, stats: copy.stats,
cameraLabel: copy.cameraLabel, cameraLabel: copy.cameraLabel,
hasFrame, hasFrame,
accentColor: siteMetadata.accentColor,
accentTextColor: siteMetadata.accentTextColor,
}), }),
); );
@@ -272,7 +338,36 @@ async function renderOgImage() {
return base.composite(composite).png().toBuffer(); return base.composite(composite).png().toBuffer();
} }
function renderWebManifest() {
const siteMetadata = resolveSiteMetadata();
/*
The manifest is generated from the same resolved values as the HTML and
social image, so browser tabs, installed shortcuts, and launch screens do
not drift into three separately configured identities.
*/
return JSON.stringify({
name: siteMetadata.name,
short_name: siteMetadata.shortName,
description: siteMetadata.description,
start_url: '/',
scope: '/',
display: 'standalone',
background_color: siteMetadata.backgroundColor,
theme_color: siteMetadata.accentColor,
icons: [
{
src: '/bitmap.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
],
});
}
module.exports = { module.exports = {
renderIndexHtml, renderIndexHtml,
renderOgImage, renderOgImage,
renderWebManifest,
}; };
@@ -0,0 +1,629 @@
// Fleet Report Collector
// Purpose: Converts existing server events and high-rate rover sensor frames into bounded historical evidence.
// Scope: Performs passive normalization, battery-current integration, minute aggregation, and battery-session classification.
const crypto = require('crypto');
const MINUTE_MS = 60 * 1000;
const FULL_WAIT_MS = 5 * 60 * 1000;
const SESSION_KIND_CONFIRM_SAMPLES = 3;
function finite(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function minimum(previous, value) {
if (value == null) return previous;
return previous == null ? value : Math.min(previous, value);
}
function maximum(previous, value) {
if (value == null) return previous;
return previous == null ? value : Math.max(previous, value);
}
function eventRoverId(event) {
const payload = event?.payload || {};
// Generic payload `id` fields are commonly message, request, or job IDs and
// must not be mistaken for rover identities. Producers use roverId (or an
// explicit rover object) whenever the existing privacy resolver should scope
// an event to a physical rover.
return payload.roverId || payload.rover?.id || null;
}
function inferVisibility(event) {
const payload = event?.payload || {};
// Producers that know a stricter visibility scope may attach it explicitly.
// Otherwise rover-scoped events are filtered later against the same visible
// roster that drives the live UI, while verification/auth details retain the
// existing lockdown-only boundary.
if (payload.visibility) return String(payload.visibility);
if (event?.source === 'verification' || event?.source === 'identity' || event?.source === 'auth') {
return 'lockdown';
}
return eventRoverId(event) ? 'rover' : 'global';
}
function inferSeverity(type = '') {
const value = String(type).toLowerCase();
if (/fault|critical|urgent|failed|failure/.test(value)) return 'critical';
if (/warn|offline|rejected|stopped|removed|denied/.test(value)) return 'warning';
if (/started|completed|online|resolved|updated/.test(value)) return 'notice';
return 'informational';
}
function batteryKey(roverId) {
// Until an admin registers a physical battery, the stable fallback keeps all
// observations attached to the rover without pretending the hardware has a
// serial number exposed by OI.
return `unregistered:${roverId}`;
}
function makeMinute(roverId, now) {
return {
roverId,
bucketTs: Math.floor(now / MINUTE_MS) * MINUTE_MS,
sampleCount: 0,
coverageMs: 0,
gapCount: 0,
chargedMah: 0,
dischargedMah: 0,
chargedWh: 0,
dischargedWh: 0,
movingDischargedWh: 0,
stationaryDischargedWh: 0,
movingMs: 0,
maximumSpeedMmPerSecond: null,
minVoltageMv: null,
maxVoltageMv: null,
voltageTotal: 0,
voltageCount: 0,
minCurrentMa: null,
maxCurrentMa: null,
currentTotal: 0,
currentCount: 0,
minTemperatureC: null,
maxTemperatureC: null,
temperatureTotal: 0,
temperatureCount: 0,
minChargeMah: null,
maxChargeMah: null,
lastChargeMah: null,
reportedCapacityMah: null,
dockedSamples: 0,
chargingSamples: 0,
commandCount: 0,
driveCommandCount: 0,
rejectedCommandCount: 0,
distanceMm: 0,
bumpCount: 0,
cliffCount: 0,
wheelDropCount: 0,
virtualWallCount: 0,
overcurrentEpisodeCount: 0,
};
}
function persistedMinute(minute) {
return {
roverId: minute.roverId,
bucketTs: minute.bucketTs,
sampleCount: minute.sampleCount,
coverageMs: Math.round(minute.coverageMs),
gapCount: minute.gapCount,
chargedMah: minute.chargedMah,
dischargedMah: minute.dischargedMah,
chargedWh: minute.chargedWh,
dischargedWh: minute.dischargedWh,
movingDischargedWh: minute.movingDischargedWh,
stationaryDischargedWh: minute.stationaryDischargedWh,
movingMs: Math.round(minute.movingMs),
maximumSpeedMmPerSecond: minute.maximumSpeedMmPerSecond,
minVoltageMv: minute.minVoltageMv,
maxVoltageMv: minute.maxVoltageMv,
avgVoltageMv: minute.voltageCount ? minute.voltageTotal / minute.voltageCount : null,
minCurrentMa: minute.minCurrentMa,
maxCurrentMa: minute.maxCurrentMa,
avgCurrentMa: minute.currentCount ? minute.currentTotal / minute.currentCount : null,
minTemperatureC: minute.minTemperatureC,
maxTemperatureC: minute.maxTemperatureC,
avgTemperatureC: minute.temperatureCount ? minute.temperatureTotal / minute.temperatureCount : null,
minChargeMah: minute.minChargeMah,
maxChargeMah: minute.maxChargeMah,
lastChargeMah: minute.lastChargeMah,
reportedCapacityMah: minute.reportedCapacityMah,
dockedSamples: minute.dockedSamples,
chargingSamples: minute.chargingSamples,
commandCount: minute.commandCount,
driveCommandCount: minute.driveCommandCount,
rejectedCommandCount: minute.rejectedCommandCount,
distanceMm: minute.distanceMm,
bumpCount: minute.bumpCount,
cliffCount: minute.cliffCount,
wheelDropCount: minute.wheelDropCount,
virtualWallCount: minute.virtualWallCount,
overcurrentEpisodeCount: minute.overcurrentEpisodeCount,
};
}
function newBatterySession(roverId, kind, now, sensors, state) {
return {
roverId,
batteryKey: state.batteryKey || batteryKey(roverId),
kind,
startedAt: now,
endedAt: null,
startChargeMah: finite(sensors?.batteryChargeMah),
endChargeMah: null,
chargedMah: 0,
dischargedMah: 0,
minVoltageMv: finite(sensors?.voltageMv),
maxVoltageMv: finite(sensors?.voltageMv),
minTemperatureC: finite(sensors?.batteryTemperatureC),
maxTemperatureC: finite(sensors?.batteryTemperatureC),
sampleCount: 0,
gapCount: 0,
status: 'open',
confidence: 'low',
qualificationReason: 'session is still open',
details: {
startedFromQualifiedFull: Boolean(state.fullQualifiedAt),
fullQualifiedAt: state.fullQualifiedAt,
warnMah: finite(state.lastBatteryState?.warn),
urgentMah: finite(state.lastBatteryState?.urgent),
configuredFullMah: finite(state.lastBatteryState?.full),
},
};
}
function observedSessionKind(sensors) {
const code = finite(sensors?.chargingState?.code);
const docked = Boolean(sensors?.chargingSources?.homeBase || sensors?.chargingSources?.internalCharger);
const current = finite(sensors?.currentMa);
if (docked && (code === 1 || code === 2 || code === 3 || (current != null && current > 25))) return 'charging';
if (!docked && current != null && current < -25) return 'discharging';
return 'idle';
}
function createCollector({ storage, logger, maximumIntegrationGapMs, minimumCapacityTestDepthPercent }) {
const roverStates = new Map();
const lastManagerSampleAt = new Map();
const diagnostics = {
startedAt: Date.now(),
eventsObserved: 0,
eventsStored: 0,
sensorFramesObserved: 0,
validBatteryFrames: 0,
integrationGaps: 0,
minuteWrites: 0,
sessionsCompleted: 0,
lastEventAt: null,
lastSensorAt: null,
lastError: null,
};
function stateFor(roverId, now) {
if (!roverStates.has(roverId)) {
roverStates.set(roverId, {
roverId,
lastAt: null,
minute: makeMinute(roverId, now),
candidateKind: null,
candidateCount: 0,
sessionKind: 'idle',
session: null,
waitingSince: null,
fullQualifiedAt: null,
lastBatteryState: null,
batteryKey: storage.getActiveBattery?.(roverId)?.batteryKey || batteryKey(roverId),
lastOdometerTotalMm: null,
safety: {
bump: false,
cliff: false,
wheelDrop: false,
virtualWall: false,
overcurrent: false,
overcurrentStartedAt: null,
overcurrentSamples: 0,
},
});
}
return roverStates.get(roverId);
}
function collectEvent(event = {}) {
diagnostics.eventsObserved += 1;
diagnostics.lastEventAt = Date.now();
try {
const normalized = {
ts: finite(event.ts) || Date.now(),
source: String(event.source || 'unknown'),
type: String(event.type || 'unknown'),
roverId: eventRoverId(event),
visibility: inferVisibility(event),
severity: inferSeverity(event.type),
correlationId: event?.payload?.correlationId || event?.payload?.jobId || event?.payload?.sessionId || null,
payload: event.payload ?? null,
};
if (storage.insertEvent(normalized)) diagnostics.eventsStored += 1;
} catch (err) {
diagnostics.lastError = err.message;
logger.warn('Fleet collector ignored malformed domain event', { error: err.message });
}
}
function updateMinute(minute, sensors, elapsedMs, chargedMah, dischargedMah, chargedWh, dischargedWh, gap) {
const voltage = finite(sensors?.voltageMv);
const current = finite(sensors?.currentMa);
const temperature = finite(sensors?.batteryTemperatureC);
const charge = finite(sensors?.batteryChargeMah);
const capacity = finite(sensors?.batteryCapacityMah);
minute.sampleCount += 1;
minute.coverageMs += elapsedMs;
minute.gapCount += gap ? 1 : 0;
minute.chargedMah += chargedMah;
minute.dischargedMah += dischargedMah;
minute.chargedWh += chargedWh;
minute.dischargedWh += dischargedWh;
/*
Movement classification deliberately consumes the center speed produced
by odometerService. That service already owns encoder rollover, physical
conversion, and impossible-jump rejection; duplicating those rules here
would allow reporting and the rover's actual odometer to disagree.
*/
const speed = finite(sensors?.wheelSpeedsMmPerSecond?.center);
const moving = speed != null && Math.abs(speed) >= 1;
if (moving) {
minute.movingMs += elapsedMs;
minute.movingDischargedWh += dischargedWh;
} else {
minute.stationaryDischargedWh += dischargedWh;
}
minute.maximumSpeedMmPerSecond = maximum(minute.maximumSpeedMmPerSecond, speed == null ? null : Math.abs(speed));
minute.minVoltageMv = minimum(minute.minVoltageMv, voltage);
minute.maxVoltageMv = maximum(minute.maxVoltageMv, voltage);
if (voltage != null) { minute.voltageTotal += voltage; minute.voltageCount += 1; }
minute.minCurrentMa = minimum(minute.minCurrentMa, current);
minute.maxCurrentMa = maximum(minute.maxCurrentMa, current);
if (current != null) { minute.currentTotal += current; minute.currentCount += 1; }
minute.minTemperatureC = minimum(minute.minTemperatureC, temperature);
minute.maxTemperatureC = maximum(minute.maxTemperatureC, temperature);
if (temperature != null) { minute.temperatureTotal += temperature; minute.temperatureCount += 1; }
minute.minChargeMah = minimum(minute.minChargeMah, charge);
minute.maxChargeMah = maximum(minute.maxChargeMah, charge);
minute.lastChargeMah = charge;
minute.reportedCapacityMah = capacity;
if (sensors?.chargingSources?.homeBase) minute.dockedSamples += 1;
if (observedSessionKind(sensors) === 'charging') minute.chargingSamples += 1;
}
function finishSession(state, now, sensors, reason) {
const session = state.session;
if (!session) return;
session.endedAt = now;
session.endChargeMah = finite(sensors?.batteryChargeMah);
session.status = 'completed';
if (session.kind === 'discharging') {
const configuredFull = finite(session.details.configuredFullMah);
const startCharge = finite(session.startChargeMah);
const endCharge = finite(session.endChargeMah);
const reference = configuredFull || startCharge;
const observedDepth = reference && startCharge != null && endCharge != null
? Math.max(0, ((startCharge - endCharge) / reference) * 100)
: 0;
const reachedLowEndpoint = Boolean(
state.lastBatteryState?.urgentActive ||
(finite(session.details.urgentMah) != null && endCharge != null && endCharge <= session.details.urgentMah),
);
const qualified = Boolean(
session.details.startedFromQualifiedFull &&
reachedLowEndpoint &&
observedDepth >= minimumCapacityTestDepthPercent &&
session.gapCount === 0,
);
session.details.observedDepthPercent = observedDepth;
session.details.reachedLowEndpoint = reachedLowEndpoint;
session.details.capacityTestQualified = qualified;
if (qualified) {
session.confidence = 'high';
session.qualificationReason = 'continuous qualified-full to low-endpoint discharge';
} else if (observedDepth >= 30 && session.gapCount <= 1) {
session.confidence = 'medium';
session.qualificationReason = reason || 'useful partial discharge; not a full capacity test';
} else {
session.confidence = 'low';
session.qualificationReason = reason || 'insufficient depth, endpoint, or telemetry coverage';
}
} else {
session.confidence = session.gapCount === 0 ? 'high' : session.gapCount <= 1 ? 'medium' : 'low';
session.qualificationReason = reason || 'charging session completed';
}
storage.insertBatterySession(session);
diagnostics.sessionsCompleted += 1;
state.session = null;
}
function applySessionKind(state, nextKind, now, sensors) {
if (nextKind === state.sessionKind) {
state.candidateKind = null;
state.candidateCount = 0;
return;
}
if (state.candidateKind !== nextKind) {
state.candidateKind = nextKind;
state.candidateCount = 1;
return;
}
state.candidateCount += 1;
if (state.candidateCount < SESSION_KIND_CONFIRM_SAMPLES) return;
finishSession(state, now, sensors, `state changed from ${state.sessionKind} to ${nextKind}`);
state.sessionKind = nextKind;
state.candidateKind = null;
state.candidateCount = 0;
if (nextKind === 'charging' || nextKind === 'discharging') {
state.session = newBatterySession(state.roverId, nextKind, now, sensors, state);
// A qualified-full marker is consumed by the next discharge. Leaving it
// set during the open session records the evidence in session details,
// while clearing it prevents later partial sessions from inheriting it.
if (nextKind === 'discharging') state.fullQualifiedAt = null;
}
}
function updateFullQualification(state, now, sensors) {
const waiting = finite(sensors?.chargingState?.code) === 4;
if (waiting) {
if (state.waitingSince == null) state.waitingSince = now;
if (now - state.waitingSince >= FULL_WAIT_MS && state.fullQualifiedAt == null) {
state.fullQualifiedAt = state.waitingSince + FULL_WAIT_MS;
}
} else {
state.waitingSince = null;
}
}
function collectSensor({ roverId, sensors, batteryState } = {}) {
diagnostics.sensorFramesObserved += 1;
diagnostics.lastSensorAt = Date.now();
if (!roverId || !sensors || finite(sensors.currentMa) == null) return;
diagnostics.validBatteryFrames += 1;
const now = Date.now();
const state = stateFor(String(roverId), now);
state.lastBatteryState = batteryState || state.lastBatteryState;
const elapsedMs = state.lastAt == null ? 0 : Math.max(0, now - state.lastAt);
const gap = elapsedMs > maximumIntegrationGapMs;
const validElapsedMs = gap ? 0 : elapsedMs;
if (gap) diagnostics.integrationGaps += 1;
const currentMa = finite(sensors.currentMa) || 0;
const deltaMah = currentMa * validElapsedMs / 3600000;
const chargedMah = Math.max(0, deltaMah);
const dischargedMah = Math.max(0, -deltaMah);
const voltageMv = finite(sensors.voltageMv);
/*
Millivolts multiplied by milliamps are microwatts. Dividing their
millisecond product by 3.6e12 therefore yields watt-hours. Integrating
voltage and current together here is required: multiplying independent
daily averages later would produce incorrect energy whenever load varies.
*/
const deltaWh = voltageMv == null ? 0 : voltageMv * currentMa * validElapsedMs / 3.6e12;
const chargedWh = Math.max(0, deltaWh);
const dischargedWh = Math.max(0, -deltaWh);
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
if (state.minute.bucketTs !== bucketTs) {
storage.upsertMinute(persistedMinute(state.minute));
diagnostics.minuteWrites += 1;
state.minute = makeMinute(state.roverId, now);
}
updateMinute(
state.minute,
sensors,
validElapsedMs,
chargedMah,
dischargedMah,
chargedWh,
dischargedWh,
gap,
);
const bumps = sensors?.bumpsAndWheelDrops || {};
const nextSafety = {
bump: Boolean(bumps.bumpLeft || bumps.bumpRight),
cliff: Boolean(sensors.cliffLeft || sensors.cliffFrontLeft || sensors.cliffFrontRight || sensors.cliffRight),
wheelDrop: Boolean(bumps.wheelDropLeft || bumps.wheelDropRight),
virtualWall: Boolean(sensors.virtualWall),
overcurrent: Boolean(
sensors?.wheelOvercurrents?.leftWheel || sensors?.wheelOvercurrents?.rightWheel ||
sensors?.wheelOvercurrents?.mainBrush || sensors?.wheelOvercurrents?.sideBrush,
),
};
if (nextSafety.bump && !state.safety.bump) state.minute.bumpCount += 1;
if (nextSafety.cliff && !state.safety.cliff) state.minute.cliffCount += 1;
if (nextSafety.wheelDrop && !state.safety.wheelDrop) state.minute.wheelDropCount += 1;
if (nextSafety.virtualWall && !state.safety.virtualWall) state.minute.virtualWallCount += 1;
if (nextSafety.overcurrent) state.safety.overcurrentSamples += 1;
if (nextSafety.overcurrent && !state.safety.overcurrent) {
state.minute.overcurrentEpisodeCount += 1;
state.safety.overcurrentStartedAt = now;
state.safety.overcurrentSamples = 1;
collectEvent({
source: 'fleetReportService',
type: 'overcurrent.episode.started',
ts: now,
payload: { roverId: state.roverId, motors: sensors.wheelOvercurrents },
});
} else if (!nextSafety.overcurrent && state.safety.overcurrent) {
collectEvent({
source: 'fleetReportService',
type: 'overcurrent.episode.resolved',
ts: now,
payload: {
roverId: state.roverId,
startedAt: state.safety.overcurrentStartedAt,
durationMs: Math.max(0, now - (state.safety.overcurrentStartedAt || now)),
sampleCount: state.safety.overcurrentSamples,
},
});
state.safety.overcurrentStartedAt = null;
state.safety.overcurrentSamples = 0;
}
Object.assign(state.safety, nextSafety);
updateFullQualification(state, now, sensors);
applySessionKind(state, observedSessionKind(sensors), now, sensors);
if (state.session) {
const session = state.session;
session.sampleCount += 1;
session.gapCount += gap ? 1 : 0;
session.chargedMah += chargedMah;
session.dischargedMah += dischargedMah;
session.minVoltageMv = minimum(session.minVoltageMv, finite(sensors.voltageMv));
session.maxVoltageMv = maximum(session.maxVoltageMv, finite(sensors.voltageMv));
session.minTemperatureC = minimum(session.minTemperatureC, finite(sensors.batteryTemperatureC));
session.maxTemperatureC = maximum(session.maxTemperatureC, finite(sensors.batteryTemperatureC));
}
state.lastAt = now;
}
function collectCommand(command = {}) {
if (!command.roverId) return;
const now = finite(command.ts) || Date.now();
const state = stateFor(String(command.roverId), now);
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
if (state.minute.bucketTs !== bucketTs) {
if (state.minute.sampleCount || state.minute.commandCount) {
storage.upsertMinute(persistedMinute(state.minute));
diagnostics.minuteWrites += 1;
}
state.minute = makeMinute(state.roverId, now);
}
state.minute.commandCount += 1;
if (command.type === 'drive' || command.type === 'motors') state.minute.driveCommandCount += 1;
if (command.outcome === 'rejected') state.minute.rejectedCommandCount += 1;
// Drive/motor commands can arrive at control-loop frequency. Their exact
// volume belongs in minute counters, while rejections and low-frequency
// actions remain individually inspectable. This preserves operational
// depth without turning normal held movement into an event-timeline flood.
if ((command.type !== 'drive' && command.type !== 'motors') || command.outcome === 'rejected') {
collectEvent({
source: 'commandService',
type: `command.${command.outcome || 'observed'}`,
ts: now,
payload: command,
});
}
}
function collectManagerEvent(kind, event = {}) {
const roverId = event.roverId ? String(event.roverId) : null;
if (kind === 'hostStats') {
const key = `${kind}:${roverId || 'unknown'}`;
const now = Date.now();
// Host statistics arrive periodically and change gradually. One exact
// sample every five minutes retains long-term diagnostic evidence while
// avoiding a timeline row for every routine host heartbeat.
if (now - (lastManagerSampleAt.get(key) || 0) < 5 * 60 * 1000) return;
lastManagerSampleAt.set(key, now);
collectEvent({
source: 'roverHost',
type: 'host.sample',
ts: event.receivedAt || now,
payload: { roverId, stats: event.stats || null },
});
return;
}
const payload = { ...event };
// Live rover records contain websocket handles, sets, and other runtime
// objects. The lifecycle facts are sufficient evidence and serialize
// predictably without copying those control-owned objects into storage.
delete payload.record;
collectEvent({
source: 'roverManager',
type: `roverManager.${kind}`,
payload,
});
}
function collectOdometer({ roverId, odometer } = {}) {
if (!roverId || !odometer) return;
const now = finite(odometer.updatedAt) || Date.now();
const state = stateFor(String(roverId), now);
const totalMm = finite(odometer.totalMm);
if (totalMm == null) return;
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
if (state.minute.bucketTs !== bucketTs) {
if (state.minute.sampleCount || state.minute.commandCount || state.minute.distanceMm) {
storage.upsertMinute(persistedMinute(state.minute));
diagnostics.minuteWrites += 1;
}
state.minute = makeMinute(state.roverId, now);
}
if (state.lastOdometerTotalMm != null && totalMm >= state.lastOdometerTotalMm) {
// Odometer total is already rollover-corrected and sanity-filtered by its
// owning service. Only non-negative increments belong in this report;
// resets establish a new baseline instead of subtracting fleet distance.
state.minute.distanceMm += totalMm - state.lastOdometerTotalMm;
}
state.lastOdometerTotalMm = totalMm;
}
function flushMinutes() {
roverStates.forEach((state) => {
if (!state.minute.sampleCount && !state.minute.commandCount && !state.minute.distanceMm) return;
storage.upsertMinute(persistedMinute(state.minute));
diagnostics.minuteWrites += 1;
});
}
function getLiveState() {
return Array.from(roverStates.values()).map((state) => ({
roverId: state.roverId,
lastAt: state.lastAt,
sessionKind: state.sessionKind,
waitingSince: state.waitingSince,
fullQualifiedAt: state.fullQualifiedAt,
minute: persistedMinute(state.minute),
openSession: state.session ? {
...state.session,
// The database serializer is not involved in this live response, so a
// defensive copy prevents UI consumers from mutating collector state.
details: { ...state.session.details },
} : null,
}));
}
function getDiagnostics() {
return {
...diagnostics,
activeRovers: roverStates.size,
openSessions: Array.from(roverStates.values()).filter((state) => state.session).length,
instanceId: crypto.createHash('sha1').update(String(diagnostics.startedAt)).digest('hex').slice(0, 10),
};
}
function refreshBatteryIdentity(roverId) {
const id = String(roverId || '');
if (!id) return;
const state = roverStates.get(id);
if (!state) return;
state.batteryKey = storage.getActiveBattery?.(id)?.batteryKey || batteryKey(id);
}
return {
collectEvent,
collectSensor,
collectCommand,
collectManagerEvent,
collectOdometer,
flushMinutes,
getLiveState,
getDiagnostics,
refreshBatteryIdentity,
};
}
module.exports = {
createCollector,
};
@@ -0,0 +1,138 @@
// Fleet Report Collector Tests
// Purpose: Verifies signed-current integration, gap rejection, and high-rate command noise reduction independently of SQLite.
// Scope: Uses an in-memory storage double so tests exercise collection policy without touching development data files.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createCollector } = require('./collector');
function makeHarness() {
const writes = { events: [], minutes: [], sessions: [] };
const storage = {
insertEvent(event) { writes.events.push(event); return { changes: 1 }; },
upsertMinute(minute) { writes.minutes.push({ ...minute }); return { changes: 1 }; },
insertBatterySession(session) { writes.sessions.push({ ...session }); return { changes: 1 }; },
};
const collector = createCollector({
storage,
logger: { warn() {} },
maximumIntegrationGapMs: 5000,
minimumCapacityTestDepthPercent: 60,
});
return { collector, writes };
}
function sensors(overrides = {}) {
return {
currentMa: -3600,
voltageMv: 14500,
batteryTemperatureC: 25,
batteryChargeMah: 2000,
batteryCapacityMah: 3000,
chargingState: { code: 0, label: 'not charging' },
chargingSources: { homeBase: false, internalCharger: false },
...overrides,
};
}
test('integrates signed battery current while excluding long telemetry gaps', () => {
const { collector } = makeHarness();
const originalNow = Date.now;
let now = 1_000_000;
Date.now = () => now;
try {
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
now += 1000;
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
now += 6000;
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
const live = collector.getLiveState()[0].minute;
// -3600 mA for one valid second is exactly one discharged mAh. The six
// second interval exceeds the configured integration gap and adds no
// fictional throughput.
assert.equal(live.dischargedMah, 1);
assert.equal(live.chargedMah, 0);
assert.equal(live.gapCount, 1);
assert.equal(live.coverageMs, 1000);
} finally {
Date.now = originalNow;
}
});
test('integrates watt-hours and classifies energy with existing odometer speed', () => {
const { collector } = makeHarness();
const originalNow = Date.now;
let now = 1_500_000;
Date.now = () => now;
try {
collector.collectSensor({
roverId: 'alpha',
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 200, right: 200, center: 200 } }),
});
now += 1000;
collector.collectSensor({
roverId: 'alpha',
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 200, right: 200, center: 200 } }),
});
now += 1000;
collector.collectSensor({
roverId: 'alpha',
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 0, right: 0, center: 0 } }),
});
const live = collector.getLiveState()[0].minute;
/*
A 14.5 V, 3.6 A discharge is 52.2 W. Two one-second intervals therefore
consume 52.2 / 1800 Wh; the first is moving and the second stationary.
This verifies that the collector uses odometer speed rather than deriving
movement from commands.
*/
assert.ok(Math.abs(live.dischargedWh - (52.2 / 1800)) < 1e-12);
assert.ok(Math.abs(live.movingDischargedWh - (52.2 / 3600)) < 1e-12);
assert.ok(Math.abs(live.stationaryDischargedWh - (52.2 / 3600)) < 1e-12);
assert.equal(live.movingMs, 1000);
assert.equal(live.maximumSpeedMmPerSecond, 200);
} finally {
Date.now = originalNow;
}
});
test('uses cumulative odometer distance without recalculating encoder movement', () => {
const { collector } = makeHarness();
collector.collectOdometer({ roverId: 'alpha', odometer: { totalMm: 1000, updatedAt: 4_000_000 } });
collector.collectOdometer({ roverId: 'alpha', odometer: { totalMm: 1250, updatedAt: 4_001_000 } });
const live = collector.getLiveState()[0].minute;
assert.equal(live.distanceMm, 250);
});
test('aggregates drive commands into minute counters instead of event noise', () => {
const { collector, writes } = makeHarness();
const originalNow = Date.now;
Date.now = () => 2_000_000;
try {
for (let index = 0; index < 100; index += 1) {
collector.collectCommand({ roverId: 'alpha', type: 'drive', outcome: 'issued', ts: 2_000_000 + index });
}
collector.collectCommand({ roverId: 'alpha', type: 'drive', outcome: 'rejected', error: 'safety cooldown' });
collector.collectCommand({ roverId: 'alpha', type: 'horn', outcome: 'issued' });
const live = collector.getLiveState()[0].minute;
assert.equal(live.commandCount, 102);
assert.equal(live.driveCommandCount, 101);
assert.equal(live.rejectedCommandCount, 1);
assert.equal(writes.events.length, 2);
assert.deepEqual(writes.events.map((event) => event.type), ['command.rejected', 'command.issued']);
} finally {
Date.now = originalNow;
}
});
test('preserves chat content in structured global events', () => {
const { collector, writes } = makeHarness();
collector.collectEvent({
source: 'chat',
type: 'chat:message',
ts: 3_000_000,
payload: { text: 'hello fleet history', nickname: 'Otter' },
});
assert.equal(writes.events.length, 1);
assert.equal(writes.events[0].payload.text, 'hello fleet history');
assert.equal(writes.events[0].visibility, 'global');
});
@@ -0,0 +1,119 @@
// Fleet Report Service
// Purpose: Composes optional passive collection, storage, analysis, retention, and read-only transport.
// Scope: This is the sole feature boundary; disabled installations register no collectors, timers, database, or sockets.
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const logger = require('../../globals/logger').child('fleetReportService');
if (!isFeatureEnabled('fleetReports')) {
module.exports = {
enabled: false,
getDailyReport: () => null,
};
} else {
const { subscribeAll } = require('../eventBus');
const roverManager = require('../roverManager');
const { commandEvents } = require('../commandService');
const { odometerEvents } = require('../odometerService');
const { createStorage } = require('./storage');
const { createCollector } = require('./collector');
const { createReportBuilder } = require('./reportBuilder');
const { registerSocketGateway } = require('./socketGateway');
const config = loadConfig().fleetReports || {};
const batteryConfig = config.battery || {};
const retentionConfig = config.retention || {};
const maximumIntegrationGapMs = Math.max(
250,
(Number(batteryConfig.maximumIntegrationGapSeconds) || 5) * 1000,
);
const minimumCapacityTestDepthPercent = Math.max(
10,
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
);
const batteryEnabled = batteryConfig.enabled !== false;
const storage = createStorage({ logger });
const collector = createCollector({
storage,
logger,
maximumIntegrationGapMs,
minimumCapacityTestDepthPercent,
});
const reportBuilder = createReportBuilder({ storage, collector, roverManager });
storage.open();
const unsubscribeEvents = subscribeAll(collector.collectEvent);
if (batteryEnabled) roverManager.managerEvents.on('sensor', collector.collectSensor);
commandEvents.on('observation', collector.collectCommand);
odometerEvents.on('update', collector.collectOdometer);
const managerEventKinds = ['rover', 'hostStats', 'driver', 'switch', 'lock', 'private', 'privateSafety'];
const managerEventHandlers = new Map(managerEventKinds.map((kind) => {
const handler = (event) => collector.collectManagerEvent(kind, event);
roverManager.managerEvents.on(kind, handler);
return [kind, handler];
}));
registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger });
// Periodic upserts bound data-loss on an unclean shutdown while still
// avoiding writes at the 20 Hz sensor-frame rate.
const flushTimer = setInterval(() => collector.flushMinutes(), 30 * 1000);
flushTimer.unref?.();
function retentionDays(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}
function pruneNow() {
const now = Date.now();
const detailedDays = retentionDays(retentionConfig.detailedDays, 0);
const minuteDays = retentionDays(retentionConfig.minuteSamplesDays, 0);
storage.prune({
detailedBefore: detailedDays === 0 ? 0 : now - detailedDays * 86400000,
minuteBefore: minuteDays === 0 ? 0 : now - minuteDays * 86400000,
});
}
pruneNow();
const retentionTimer = setInterval(pruneNow, 6 * 60 * 60 * 1000);
retentionTimer.unref?.();
function getDailyReport({ since, until, roverIds } = {}) {
const end = Number(until) || Date.now();
return reportBuilder.build({
since: Number(since) || end - 24 * 60 * 60 * 1000,
until: end,
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
// Daily Discord output is intentionally metric-only. Avoiding the event
// query here also prevents irrelevant event volume from bloating the
// durable daily snapshot that supports delivery idempotency.
includeEvents: false,
});
}
logger.info('Fleet reporting enabled', {
databaseAvailable: storage.getDiagnostics().available,
maximumIntegrationGapMs,
minimumCapacityTestDepthPercent,
batteryEnabled,
});
module.exports = {
enabled: true,
getDailyReport,
collector,
storage,
reportBuilder,
// Exposed for controlled tests and graceful future shutdown wiring. Normal
// runtime leaves subscriptions active for the lifetime of the server.
stop() {
unsubscribeEvents();
if (batteryEnabled) roverManager.managerEvents.off('sensor', collector.collectSensor);
commandEvents.off('observation', collector.collectCommand);
odometerEvents.off('update', collector.collectOdometer);
managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
clearInterval(flushTimer);
clearInterval(retentionTimer);
collector.flushMinutes();
},
};
}
@@ -0,0 +1,298 @@
// Fleet Report Builder
// Purpose: Produces fleet-wide battery-health and energy-efficiency read models from passive evidence.
// Scope: Keeps estimation, confidence, and comparison policy out of collection, transport, Discord, and UI code.
const MINIMUM_EFFICIENCY_DISTANCE_MM = 25 * 1000;
function sum(rows, key) {
return rows.reduce((total, row) => total + (Number(row?.[key]) || 0), 0);
}
function median(values) {
const usable = values.map(Number).filter(Number.isFinite).sort((a, b) => a - b);
if (!usable.length) return null;
const middle = Math.floor(usable.length / 2);
return usable.length % 2 ? usable[middle] : (usable[middle - 1] + usable[middle]) / 2;
}
function weightedAverage(rows, valueKey, weightKey = 'sampleCount') {
const weighted = rows.reduce((result, row) => {
const value = Number(row[valueKey]);
const weight = Number(row[weightKey]);
if (!Number.isFinite(value) || !Number.isFinite(weight) || weight <= 0) return result;
result.total += value * weight;
result.weight += weight;
return result;
}, { total: 0, weight: 0 });
return weighted.weight ? weighted.total / weighted.weight : null;
}
function minimum(rows, key) {
const values = rows.map((row) => Number(row[key])).filter(Number.isFinite);
return values.length ? Math.min(...values) : null;
}
function maximum(rows, key) {
const values = rows.map((row) => Number(row[key])).filter(Number.isFinite);
return values.length ? Math.max(...values) : null;
}
function confidenceForObservationCount(count, averageDepthPercent) {
/*
Confidence is intentionally continuous evidence summarized into a label,
not a pass/fail cycle judgment. Multiple partial observations can become
strong evidence, while shallow observations remain visible and useful.
*/
if (count >= 5 && averageDepthPercent >= 25) return 'high';
if (count >= 2 && averageDepthPercent >= 10) return 'medium';
return 'low';
}
function buildBatteryHealth({ rover, sessions, registryEntry }) {
const referenceMah = Number(registryEntry?.ratedCapacityMah) || Number(rover.reportedCapacityMah) || null;
const batteryKey = registryEntry?.batteryKey
|| sessions[0]?.batteryKey
|| `unregistered:${rover.roverId}`;
const sameBattery = sessions.filter((session) => session.batteryKey === batteryKey);
const observations = sameBattery.flatMap((session) => {
if (session.kind !== 'discharging' || !referenceMah) return [];
const chargeDropMah = Number(session.startChargeMah) - Number(session.endChargeMah);
const dischargedMah = Number(session.dischargedMah);
if (!Number.isFinite(chargeDropMah) || chargeDropMah < 100 || !Number.isFinite(dischargedMah) || dischargedMah <= 0) {
return [];
}
const depthPercent = chargeDropMah / referenceMah * 100;
/*
Packet 25 provides the changing charge position while signed current
supplies an independent coulomb count. Extrapolating each partial slice
produces a capacity observation without requiring a full-to-empty run.
Depth is retained so callers can see exactly how much evidence supports
the estimate.
*/
return [{
startedAt: session.startedAt,
endedAt: session.endedAt,
depthPercent,
estimatedUsableMah: dischargedMah / (chargeDropMah / referenceMah),
gapCount: Number(session.gapCount) || 0,
}];
});
const cleanObservations = observations.filter((observation) => observation.gapCount <= 1);
const measuredUsableMah = median(cleanObservations.map((observation) => observation.estimatedUsableMah));
const observedChargeHighMah = maximum(rover.minutes, 'maxChargeMah');
const observedChargeLowMah = minimum(rover.minutes, 'minChargeMah');
const observedUsableFloorMah = observedChargeHighMah != null && observedChargeLowMah != null
? Math.max(0, observedChargeHighMah - observedChargeLowMah)
: null;
const averageDepthPercent = cleanObservations.length
? sum(cleanObservations, 'depthPercent') / cleanObservations.length
: 0;
const baselineMah = Number(registryEntry?.healthyBaselineMah) || referenceMah;
const capacityRetentionPercent = measuredUsableMah && baselineMah
? measuredUsableMah / baselineMah * 100
: null;
const nominalVoltageMv = rover.averageVoltageMv;
return {
batteryKey,
referenceMah,
baselineMah,
measuredUsableMah,
measuredUsableWh: measuredUsableMah && nominalVoltageMv
? measuredUsableMah * nominalVoltageMv / 1e6
: null,
capacityRetentionPercent,
observedUsableFloorMah,
observedChargeHighMah,
observedChargeLowMah,
observationCount: cleanObservations.length,
averageObservationDepthPercent: averageDepthPercent,
confidence: confidenceForObservationCount(cleanObservations.length, averageDepthPercent),
confidenceReason: cleanObservations.length
? `${cleanObservations.length} partial current/charge observations averaging ${averageDepthPercent.toFixed(1)}% depth`
: 'collecting partial discharge evidence',
dischargedThroughputMah: sum(sameBattery, 'dischargedMah'),
latestObservationAt: cleanObservations.reduce(
(latest, observation) => Math.max(latest, Number(observation.endedAt) || Number(observation.startedAt) || 0),
0,
) || null,
observations: cleanObservations,
};
}
function groupByRover({ minutes, sessions, roster, batteryRegistry }) {
const rosterById = new Map(roster.map((rover) => [String(rover.id), rover]));
const minuteGroups = new Map();
minutes.forEach((minute) => {
const roverId = String(minute.roverId);
if (!minuteGroups.has(roverId)) minuteGroups.set(roverId, []);
minuteGroups.get(roverId).push(minute);
});
return Array.from(new Set([...rosterById.keys(), ...minuteGroups.keys()])).map((roverId) => {
const rows = minuteGroups.get(roverId) || [];
const distanceMm = sum(rows, 'distanceMm');
const dischargedWh = sum(rows, 'dischargedWh');
const movingDischargedWh = sum(rows, 'movingDischargedWh');
const movingMs = sum(rows, 'movingMs');
const latest = rows[rows.length - 1] || null;
const base = {
roverId,
name: rosterById.get(roverId)?.name || roverId,
color: rosterById.get(roverId)?.color || null,
online: Boolean(rosterById.get(roverId)),
minutes: rows,
sampleCount: sum(rows, 'sampleCount'),
coverageMs: sum(rows, 'coverageMs'),
gapCount: sum(rows, 'gapCount'),
distanceMm,
movingMs,
averageSpeedMmPerSecond: movingMs ? distanceMm / (movingMs / 1000) : null,
maximumSpeedMmPerSecond: maximum(rows, 'maximumSpeedMmPerSecond'),
chargedMah: sum(rows, 'chargedMah'),
dischargedMah: sum(rows, 'dischargedMah'),
chargedWh: sum(rows, 'chargedWh'),
dischargedWh,
movingDischargedWh,
stationaryDischargedWh: sum(rows, 'stationaryDischargedWh'),
overallWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
? dischargedWh / (distanceMm / 1e6)
: null,
movingWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
? movingDischargedWh / (distanceMm / 1e6)
: null,
efficiencyDistanceRequiredMm: Math.max(0, MINIMUM_EFFICIENCY_DISTANCE_MM - distanceMm),
averageVoltageMv: weightedAverage(rows, 'avgVoltageMv'),
averageCurrentMa: weightedAverage(rows, 'avgCurrentMa'),
minimumVoltageMv: minimum(rows, 'minVoltageMv'),
maximumVoltageMv: maximum(rows, 'maxVoltageMv'),
averageTemperatureC: weightedAverage(rows, 'avgTemperatureC'),
minimumTemperatureC: minimum(rows, 'minTemperatureC'),
maximumTemperatureC: maximum(rows, 'maxTemperatureC'),
latestChargeMah: latest?.lastChargeMah ?? null,
reportedCapacityMah: latest?.reportedCapacityMah ?? null,
lastSampleAt: latest ? latest.bucketTs + 60000 : null,
};
const registryEntry = batteryRegistry.find((battery) =>
String(battery.roverId) === roverId && battery.retiredAt == null,
);
base.batteryHealth = buildBatteryHealth({
rover: base,
sessions: sessions.filter((session) => String(session.roverId) === roverId),
registryEntry,
});
delete base.minutes;
return base;
}).sort((a, b) => a.name.localeCompare(b.name));
}
function buildAttention(roverRows, now) {
const attention = [];
roverRows.forEach((rover) => {
if (!rover.sampleCount) {
attention.push({
key: `telemetry:${rover.roverId}`,
roverId: rover.roverId,
severity: 'notice',
title: rover.online ? 'Battery metrics unavailable in this range' : 'Rover was not observed in this range',
});
}
if (rover.maximumTemperatureC >= 45) {
attention.push({
key: `temperature:${rover.roverId}`,
roverId: rover.roverId,
severity: rover.maximumTemperatureC >= 50 ? 'critical' : 'warning',
title: `Battery reached ${rover.maximumTemperatureC} °C`,
});
}
if (rover.batteryHealth.capacityRetentionPercent != null
&& rover.batteryHealth.confidence !== 'low'
&& rover.batteryHealth.capacityRetentionPercent < 80) {
attention.push({
key: `capacity:${rover.roverId}`,
roverId: rover.roverId,
severity: rover.batteryHealth.capacityRetentionPercent < 65 ? 'critical' : 'warning',
title: `Estimated usable capacity is ${rover.batteryHealth.capacityRetentionPercent.toFixed(1)}% of baseline`,
});
}
if (rover.online && rover.lastSampleAt && now - rover.lastSampleAt > 5 * 60 * 1000) {
attention.push({
key: `stale:${rover.roverId}`,
roverId: rover.roverId,
severity: 'warning',
title: 'Battery metrics are stale',
});
}
});
const rank = { critical: 0, warning: 1, notice: 2 };
return attention.sort((a, b) => rank[a.severity] - rank[b.severity]);
}
function createReportBuilder({ storage, collector, roverManager }) {
function build({ since, until, roverIds, includeEvents = false, eventLimit = 500 }) {
const visibleRoster = Array.from(roverManager.rovers?.values?.() || []).map((record) => ({
id: record.id,
name: record.name || record.id,
color: record.color || record.meta?.color || null,
}));
const requestedIds = Array.isArray(roverIds) ? roverIds.map(String) : null;
const roster = requestedIds
? visibleRoster.filter((rover) => requestedIds.includes(String(rover.id)))
: visibleRoster;
const effectiveIds = requestedIds || roster.map((rover) => String(rover.id));
const minutes = storage.listMinutes({ since, until, roverIds: effectiveIds });
const batterySessions = storage.listBatterySessions({ since, until, roverIds: effectiveIds, limit: 2000 });
const batteryRegistry = storage.listBatteries(effectiveIds);
const roverRows = groupByRover({ minutes, sessions: batterySessions, roster, batteryRegistry });
const attention = buildAttention(roverRows, Date.now());
const distanceMm = sum(roverRows, 'distanceMm');
const dischargedWh = sum(roverRows, 'dischargedWh');
const movingDischargedWh = sum(roverRows, 'movingDischargedWh');
return {
generatedAt: Date.now(),
range: { since, until },
methodology: {
minimumEfficiencyDistanceMm: MINIMUM_EFFICIENCY_DISTANCE_MM,
historicalWhAvailable: false,
},
totals: {
roverCount: roverRows.length,
onlineRoverCount: roverRows.filter((rover) => rover.online).length,
distanceMm,
movingMs: sum(roverRows, 'movingMs'),
chargedWh: sum(roverRows, 'chargedWh'),
dischargedWh,
movingDischargedWh,
stationaryDischargedWh: sum(roverRows, 'stationaryDischargedWh'),
overallWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
? dischargedWh / (distanceMm / 1e6)
: null,
movingWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
? movingDischargedWh / (distanceMm / 1e6)
: null,
attentionCount: attention.filter((item) => item.severity !== 'notice').length,
},
rovers: roverRows,
attention,
batteryRegistry,
dailyReportHistory: storage.listDailyReports(365),
// Events remain available only for explicit advanced/debug consumers.
// Neither the normal UI nor Discord requests them.
events: includeEvents
? storage.listEvents({ since, until, roverIds: effectiveIds, limit: eventLimit })
: [],
diagnostics: {
collector: collector.getDiagnostics(),
storage: storage.getDiagnostics(),
},
};
}
return { build };
}
module.exports = {
MINIMUM_EFFICIENCY_DISTANCE_MM,
createReportBuilder,
};
@@ -0,0 +1,96 @@
// Fleet Report Socket Gateway
// Purpose: Exposes read-only, visibility-filtered fleet history to browser clients.
// Scope: Owns query validation and existing private/lockdown access boundaries; it performs no collection or analysis.
const io = require('../../globals/io');
const crypto = require('crypto');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const MAX_RANGE_MS = 366 * 24 * 60 * 60 * 1000;
function normalizeRange(payload = {}) {
const now = Date.now();
const until = Number.isFinite(Number(payload.until)) ? Number(payload.until) : now;
const requestedSince = Number.isFinite(Number(payload.since))
? Number(payload.since)
: until - 24 * 60 * 60 * 1000;
const since = Math.max(0, Math.max(requestedSince, until - MAX_RANGE_MS));
return { since, until: Math.max(since + 1, until) };
}
function registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger }) {
io.on('connection', (socket) => {
socket.on('fleetReports:get', (payload = {}, cb = () => {}) => {
try {
const { since, until } = normalizeRange(payload);
// getRosterForSocket is the canonical live private-rover visibility
// resolver. Historical queries use precisely those currently visible
// rover IDs so fleet totals cannot indirectly disclose a private rover.
const visibleRoverIds = roverManager.getRosterForSocket(socket).map((rover) => String(rover.id));
const requestedIds = Array.isArray(payload.roverIds)
? payload.roverIds.map(String).filter((id) => visibleRoverIds.includes(id))
: visibleRoverIds;
const report = reportBuilder.build({
since,
until,
roverIds: requestedIds,
includeEvents: payload.includeEvents !== false,
eventLimit: payload.eventLimit,
});
// Lockdown-only events are deliberately removed after query assembly.
// They are global rather than rover-scoped, so rover filtering alone is
// insufficient to preserve the pre-existing lockdown privacy boundary.
if (!isLockdownAdmin(socket)) {
report.events = report.events.filter((event) => event.visibility !== 'lockdown');
report.totals.eventCountReturned = report.events.length;
}
if (payload.compact === true) {
// The Activities card now consumes the same all-rovers metric rows
// as fullscreen. The builder no longer attaches minute/session
// evidence by default, so compacting only removes archival metadata.
report.events = [];
report.dailyReportHistory = [];
}
cb({ ok: true, report });
} catch (err) {
logger.warn('Fleet report query failed', { socketId: socket.id, error: err.message });
cb({ error: 'Fleet report query failed' });
}
});
socket.on('fleetReports:replaceBattery', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) throw new Error('Admin access required');
const roverId = String(payload.roverId || '').trim();
if (!roverId || !roverManager.rovers.has(roverId)) throw new Error('Known online rover required');
const ratedCapacityMah = Number(payload.ratedCapacityMah);
if (!Number.isFinite(ratedCapacityMah) || ratedCapacityMah <= 0 || ratedCapacityMah > 65535) {
throw new Error('Rated capacity must be between 1 and 65535 mAh');
}
const installedAt = Number.isFinite(Number(payload.installedAt)) ? Number(payload.installedAt) : Date.now();
const entry = storage.replaceBattery({
roverId,
batteryKey: `battery:${roverId}:${installedAt}:${crypto.randomUUID().slice(0, 8)}`,
chemistry: String(payload.chemistry || '').trim() || null,
ratedCapacityMah: Math.round(ratedCapacityMah),
installedAt,
notes: String(payload.notes || '').trim() || null,
});
if (!entry) throw new Error('Battery registry write failed');
collector.refreshBatteryIdentity(roverId);
collector.collectEvent({
source: 'fleetReportService',
type: 'battery.replaced',
payload: { roverId, battery: entry },
});
cb({ ok: true, battery: entry });
} catch (err) {
logger.warn('Fleet battery replacement rejected', { socketId: socket.id, error: err.message });
cb({ error: err.message });
}
});
});
}
module.exports = {
registerSocketGateway,
};
@@ -0,0 +1,533 @@
// Fleet Report Storage
// Purpose: Owns the reporting database, schema, bounded writes, and read queries.
// Scope: Keeps SQLite details out of telemetry collection, analysis, UI transport, and Discord delivery.
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const { resolveDataPath } = require('../../helpers/dataPaths');
const DB_PATH = resolveDataPath('fleet-reports.sqlite');
function safeJson(value) {
try {
return JSON.stringify(value ?? null);
} catch (_err) {
// An unusual circular payload must not break the event subscriber. The
// placeholder still records that an event occurred and explains why its
// supporting payload is unavailable.
return JSON.stringify({ serializationError: true });
}
}
function parseJson(value, fallback = null) {
try {
return value == null ? fallback : JSON.parse(value);
} catch (_err) {
return fallback;
}
}
function createStorage({ logger }) {
let db = null;
let statements = null;
function open() {
if (db) return true;
try {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS fleet_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
source TEXT NOT NULL,
type TEXT NOT NULL,
rover_id TEXT,
visibility TEXT NOT NULL DEFAULT 'global',
severity TEXT NOT NULL DEFAULT 'informational',
correlation_id TEXT,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events(ts DESC);
CREATE INDEX IF NOT EXISTS idx_fleet_events_rover_ts ON fleet_events(rover_id, ts DESC);
CREATE INDEX IF NOT EXISTS idx_fleet_events_type_ts ON fleet_events(type, ts DESC);
CREATE TABLE IF NOT EXISTS fleet_minute_samples (
rover_id TEXT NOT NULL,
bucket_ts INTEGER NOT NULL,
sample_count INTEGER NOT NULL,
coverage_ms INTEGER NOT NULL,
gap_count INTEGER NOT NULL,
charged_mah REAL NOT NULL,
discharged_mah REAL NOT NULL,
charged_wh REAL NOT NULL DEFAULT 0,
discharged_wh REAL NOT NULL DEFAULT 0,
moving_discharged_wh REAL NOT NULL DEFAULT 0,
stationary_discharged_wh REAL NOT NULL DEFAULT 0,
moving_ms INTEGER NOT NULL DEFAULT 0,
maximum_speed_mm_per_second REAL,
min_voltage_mv INTEGER,
max_voltage_mv INTEGER,
avg_voltage_mv REAL,
min_current_ma INTEGER,
max_current_ma INTEGER,
avg_current_ma REAL,
min_temperature_c INTEGER,
max_temperature_c INTEGER,
avg_temperature_c REAL,
min_charge_mah INTEGER,
max_charge_mah INTEGER,
last_charge_mah INTEGER,
reported_capacity_mah INTEGER,
docked_samples INTEGER NOT NULL,
charging_samples INTEGER NOT NULL,
command_count INTEGER NOT NULL DEFAULT 0,
drive_command_count INTEGER NOT NULL DEFAULT 0,
rejected_command_count INTEGER NOT NULL DEFAULT 0,
distance_mm REAL NOT NULL DEFAULT 0,
bump_count INTEGER NOT NULL DEFAULT 0,
cliff_count INTEGER NOT NULL DEFAULT 0,
wheel_drop_count INTEGER NOT NULL DEFAULT 0,
virtual_wall_count INTEGER NOT NULL DEFAULT 0,
overcurrent_episode_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (rover_id, bucket_ts)
);
CREATE INDEX IF NOT EXISTS idx_fleet_minutes_ts ON fleet_minute_samples(bucket_ts DESC);
CREATE TABLE IF NOT EXISTS fleet_battery_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rover_id TEXT NOT NULL,
battery_key TEXT NOT NULL,
kind TEXT NOT NULL,
started_at INTEGER NOT NULL,
ended_at INTEGER,
start_charge_mah INTEGER,
end_charge_mah INTEGER,
charged_mah REAL NOT NULL DEFAULT 0,
discharged_mah REAL NOT NULL DEFAULT 0,
min_voltage_mv INTEGER,
max_voltage_mv INTEGER,
min_temperature_c INTEGER,
max_temperature_c INTEGER,
sample_count INTEGER NOT NULL DEFAULT 0,
gap_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
confidence TEXT NOT NULL DEFAULT 'low',
qualification_reason TEXT,
details_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_battery_sessions_rover_time ON fleet_battery_sessions(rover_id, started_at DESC);
CREATE TABLE IF NOT EXISTS fleet_batteries (
battery_key TEXT PRIMARY KEY,
rover_id TEXT NOT NULL,
chemistry TEXT,
rated_capacity_mah INTEGER,
installed_at INTEGER,
retired_at INTEGER,
healthy_baseline_mah REAL,
notes TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_fleet_batteries_rover ON fleet_batteries(rover_id, installed_at DESC);
CREATE TABLE IF NOT EXISTS fleet_daily_reports (
report_date TEXT PRIMARY KEY,
generated_at INTEGER NOT NULL,
report_json TEXT NOT NULL,
discord_delivered_at INTEGER,
discord_error TEXT
);
`);
// SQLite's CREATE TABLE IF NOT EXISTS does not add columns to an older
// reporting database. These narrow additive migrations keep development
// databases usable as collection coverage expands without coupling this
// optional feature to the identity database's migration history.
const minuteColumns = new Set(db.prepare('PRAGMA table_info(fleet_minute_samples)').all().map((column) => column.name));
[
['command_count', 'INTEGER NOT NULL DEFAULT 0'],
['drive_command_count', 'INTEGER NOT NULL DEFAULT 0'],
['rejected_command_count', 'INTEGER NOT NULL DEFAULT 0'],
['distance_mm', 'REAL NOT NULL DEFAULT 0'],
['bump_count', 'INTEGER NOT NULL DEFAULT 0'],
['cliff_count', 'INTEGER NOT NULL DEFAULT 0'],
['wheel_drop_count', 'INTEGER NOT NULL DEFAULT 0'],
['virtual_wall_count', 'INTEGER NOT NULL DEFAULT 0'],
['overcurrent_episode_count', 'INTEGER NOT NULL DEFAULT 0'],
['charged_wh', 'REAL NOT NULL DEFAULT 0'],
['discharged_wh', 'REAL NOT NULL DEFAULT 0'],
['moving_discharged_wh', 'REAL NOT NULL DEFAULT 0'],
['stationary_discharged_wh', 'REAL NOT NULL DEFAULT 0'],
['moving_ms', 'INTEGER NOT NULL DEFAULT 0'],
['maximum_speed_mm_per_second', 'REAL'],
].forEach(([name, definition]) => {
if (!minuteColumns.has(name)) db.exec(`ALTER TABLE fleet_minute_samples ADD COLUMN ${name} ${definition}`);
});
statements = {
insertEvent: db.prepare(`
INSERT INTO fleet_events (ts, source, type, rover_id, visibility, severity, correlation_id, payload_json)
VALUES (@ts, @source, @type, @roverId, @visibility, @severity, @correlationId, @payloadJson)
`),
upsertMinute: db.prepare(`
INSERT INTO fleet_minute_samples (
rover_id, bucket_ts, sample_count, coverage_ms, gap_count,
charged_mah, discharged_mah, min_voltage_mv, max_voltage_mv, avg_voltage_mv,
charged_wh, discharged_wh, moving_discharged_wh,
stationary_discharged_wh, moving_ms, maximum_speed_mm_per_second,
min_current_ma, max_current_ma, avg_current_ma, min_temperature_c,
max_temperature_c, avg_temperature_c, min_charge_mah, max_charge_mah,
last_charge_mah, reported_capacity_mah, docked_samples, charging_samples,
command_count, drive_command_count, rejected_command_count,
distance_mm, bump_count, cliff_count, wheel_drop_count,
virtual_wall_count, overcurrent_episode_count
) VALUES (
@roverId, @bucketTs, @sampleCount, @coverageMs, @gapCount,
@chargedMah, @dischargedMah, @minVoltageMv, @maxVoltageMv, @avgVoltageMv,
@chargedWh, @dischargedWh, @movingDischargedWh,
@stationaryDischargedWh, @movingMs, @maximumSpeedMmPerSecond,
@minCurrentMa, @maxCurrentMa, @avgCurrentMa, @minTemperatureC,
@maxTemperatureC, @avgTemperatureC, @minChargeMah, @maxChargeMah,
@lastChargeMah, @reportedCapacityMah, @dockedSamples, @chargingSamples,
@commandCount, @driveCommandCount, @rejectedCommandCount,
@distanceMm, @bumpCount, @cliffCount, @wheelDropCount,
@virtualWallCount, @overcurrentEpisodeCount
)
ON CONFLICT(rover_id, bucket_ts) DO UPDATE SET
sample_count = excluded.sample_count,
coverage_ms = excluded.coverage_ms,
gap_count = excluded.gap_count,
charged_mah = excluded.charged_mah,
discharged_mah = excluded.discharged_mah,
charged_wh = excluded.charged_wh,
discharged_wh = excluded.discharged_wh,
moving_discharged_wh = excluded.moving_discharged_wh,
stationary_discharged_wh = excluded.stationary_discharged_wh,
moving_ms = excluded.moving_ms,
maximum_speed_mm_per_second = excluded.maximum_speed_mm_per_second,
min_voltage_mv = excluded.min_voltage_mv,
max_voltage_mv = excluded.max_voltage_mv,
avg_voltage_mv = excluded.avg_voltage_mv,
min_current_ma = excluded.min_current_ma,
max_current_ma = excluded.max_current_ma,
avg_current_ma = excluded.avg_current_ma,
min_temperature_c = excluded.min_temperature_c,
max_temperature_c = excluded.max_temperature_c,
avg_temperature_c = excluded.avg_temperature_c,
min_charge_mah = excluded.min_charge_mah,
max_charge_mah = excluded.max_charge_mah,
last_charge_mah = excluded.last_charge_mah,
reported_capacity_mah = excluded.reported_capacity_mah,
docked_samples = excluded.docked_samples,
charging_samples = excluded.charging_samples,
command_count = excluded.command_count,
drive_command_count = excluded.drive_command_count,
rejected_command_count = excluded.rejected_command_count
,distance_mm = excluded.distance_mm
,bump_count = excluded.bump_count
,cliff_count = excluded.cliff_count
,wheel_drop_count = excluded.wheel_drop_count
,virtual_wall_count = excluded.virtual_wall_count
,overcurrent_episode_count = excluded.overcurrent_episode_count
`),
insertSession: db.prepare(`
INSERT INTO fleet_battery_sessions (
rover_id, battery_key, kind, started_at, ended_at, start_charge_mah,
end_charge_mah, charged_mah, discharged_mah, min_voltage_mv,
max_voltage_mv, min_temperature_c, max_temperature_c, sample_count,
gap_count, status, confidence, qualification_reason, details_json
) VALUES (
@roverId, @batteryKey, @kind, @startedAt, @endedAt, @startChargeMah,
@endChargeMah, @chargedMah, @dischargedMah, @minVoltageMv,
@maxVoltageMv, @minTemperatureC, @maxTemperatureC, @sampleCount,
@gapCount, @status, @confidence, @qualificationReason, @detailsJson
)
`),
};
return true;
} catch (err) {
logger.error('Failed to open fleet report database; reporting will remain fail-open', {
path: DB_PATH,
error: err.message,
});
if (db) {
try { db.close(); } catch (_closeErr) { /* Best-effort cleanup after failed initialization. */ }
}
db = null;
statements = null;
return false;
}
}
function runSafely(label, operation, fallback = null) {
if (!open()) return fallback;
try {
return operation();
} catch (err) {
// Reporting is observer-only. A failed write/query is visible in logs but
// is never allowed to propagate into a rover sensor or command callback.
logger.warn(`Fleet report storage ${label} failed`, { error: err.message });
return fallback;
}
}
function insertEvent(event) {
return runSafely('event write', () => statements.insertEvent.run({
ts: event.ts,
source: event.source,
type: event.type,
roverId: event.roverId || null,
visibility: event.visibility || 'global',
severity: event.severity || 'informational',
correlationId: event.correlationId || null,
payloadJson: safeJson(event.payload),
}));
}
function upsertMinute(sample) {
return runSafely('minute write', () => statements.upsertMinute.run(sample));
}
function insertBatterySession(session) {
return runSafely('battery session write', () => statements.insertSession.run({
...session,
detailsJson: safeJson(session.details || {}),
}));
}
function listEvents({ since, until, roverIds, limit = 500, offset = 0, type = null }) {
return runSafely('event query', () => {
const clauses = ['ts >= ?', 'ts < ?'];
const params = [since, until];
if (type) {
clauses.push('type = ?');
params.push(type);
}
if (Array.isArray(roverIds)) {
if (roverIds.length === 0) clauses.push('rover_id IS NULL');
else {
clauses.push(`(rover_id IS NULL OR rover_id IN (${roverIds.map(() => '?').join(',')}))`);
params.push(...roverIds);
}
}
params.push(Math.max(1, Math.min(2000, Number(limit) || 500)), Math.max(0, Number(offset) || 0));
const rows = db.prepare(`
SELECT id, ts, source, type, rover_id AS roverId, visibility, severity,
correlation_id AS correlationId, payload_json AS payloadJson
FROM fleet_events
WHERE ${clauses.join(' AND ')}
ORDER BY ts DESC
LIMIT ? OFFSET ?
`).all(...params);
return rows.map(({ payloadJson, ...row }) => ({ ...row, payload: parseJson(payloadJson, {}) }));
}, []);
}
function listMinutes({ since, until, roverIds }) {
return runSafely('minute query', () => {
const clauses = ['bucket_ts >= ?', 'bucket_ts < ?'];
const params = [since, until];
if (Array.isArray(roverIds)) {
if (roverIds.length === 0) return [];
clauses.push(`rover_id IN (${roverIds.map(() => '?').join(',')})`);
params.push(...roverIds);
}
return db.prepare(`
SELECT rover_id AS roverId, bucket_ts AS bucketTs, sample_count AS sampleCount,
coverage_ms AS coverageMs, gap_count AS gapCount, charged_mah AS chargedMah,
discharged_mah AS dischargedMah, min_voltage_mv AS minVoltageMv,
charged_wh AS chargedWh, discharged_wh AS dischargedWh,
moving_discharged_wh AS movingDischargedWh,
stationary_discharged_wh AS stationaryDischargedWh,
moving_ms AS movingMs,
maximum_speed_mm_per_second AS maximumSpeedMmPerSecond,
max_voltage_mv AS maxVoltageMv, avg_voltage_mv AS avgVoltageMv,
min_current_ma AS minCurrentMa, max_current_ma AS maxCurrentMa,
avg_current_ma AS avgCurrentMa, min_temperature_c AS minTemperatureC,
max_temperature_c AS maxTemperatureC, avg_temperature_c AS avgTemperatureC,
min_charge_mah AS minChargeMah, max_charge_mah AS maxChargeMah,
last_charge_mah AS lastChargeMah, reported_capacity_mah AS reportedCapacityMah,
docked_samples AS dockedSamples, charging_samples AS chargingSamples,
command_count AS commandCount, drive_command_count AS driveCommandCount,
rejected_command_count AS rejectedCommandCount
,distance_mm AS distanceMm, bump_count AS bumpCount,
cliff_count AS cliffCount, wheel_drop_count AS wheelDropCount,
virtual_wall_count AS virtualWallCount,
overcurrent_episode_count AS overcurrentEpisodeCount
FROM fleet_minute_samples
WHERE ${clauses.join(' AND ')}
ORDER BY bucket_ts ASC, rover_id ASC
`).all(...params);
}, []);
}
function listBatterySessions({ since, until, roverIds, limit = 500 }) {
return runSafely('battery session query', () => {
if (Array.isArray(roverIds) && roverIds.length === 0) return [];
const roverClause = Array.isArray(roverIds)
? `AND rover_id IN (${roverIds.map(() => '?').join(',')})`
: '';
const params = [since, until, ...(roverIds || []), Math.max(1, Math.min(2000, Number(limit) || 500))];
return db.prepare(`
SELECT id, rover_id AS roverId, battery_key AS batteryKey, kind,
started_at AS startedAt, ended_at AS endedAt,
start_charge_mah AS startChargeMah, end_charge_mah AS endChargeMah,
charged_mah AS chargedMah, discharged_mah AS dischargedMah,
min_voltage_mv AS minVoltageMv, max_voltage_mv AS maxVoltageMv,
min_temperature_c AS minTemperatureC, max_temperature_c AS maxTemperatureC,
sample_count AS sampleCount, gap_count AS gapCount, status,
confidence, qualification_reason AS qualificationReason,
details_json AS detailsJson
FROM fleet_battery_sessions
WHERE started_at < ? AND COALESCE(ended_at, started_at) >= ? ${roverClause}
ORDER BY started_at DESC
LIMIT ?
`).all(until, since, ...(roverIds || []), params[params.length - 1]).map(({ detailsJson, ...row }) => ({
...row,
details: parseJson(detailsJson, {}),
}));
}, []);
}
function prune({ detailedBefore, minuteBefore }) {
return runSafely('retention prune', () => db.transaction(() => {
const events = db.prepare('DELETE FROM fleet_events WHERE ts < ?').run(detailedBefore).changes;
const minutes = db.prepare('DELETE FROM fleet_minute_samples WHERE bucket_ts < ?').run(minuteBefore).changes;
return { events, minutes };
})());
}
function getDailyReport(reportDate) {
return runSafely('daily report query', () => {
const row = db.prepare(`
SELECT report_date AS reportDate, generated_at AS generatedAt,
report_json AS reportJson, discord_delivered_at AS discordDeliveredAt,
discord_error AS discordError
FROM fleet_daily_reports WHERE report_date = ?
`).get(reportDate);
if (!row) return null;
const { reportJson, ...metadata } = row;
return { ...metadata, report: parseJson(reportJson, null) };
});
}
function listBatteries(roverIds = null) {
return runSafely('battery registry query', () => {
if (Array.isArray(roverIds) && roverIds.length === 0) return [];
const clause = Array.isArray(roverIds)
? `WHERE rover_id IN (${roverIds.map(() => '?').join(',')})`
: '';
return db.prepare(`
SELECT battery_key AS batteryKey, rover_id AS roverId, chemistry,
rated_capacity_mah AS ratedCapacityMah, installed_at AS installedAt,
retired_at AS retiredAt, healthy_baseline_mah AS healthyBaselineMah,
notes, updated_at AS updatedAt
FROM fleet_batteries ${clause}
ORDER BY rover_id ASC, installed_at DESC
`).all(...(roverIds || []));
}, []);
}
function getActiveBattery(roverId) {
return runSafely('active battery query', () => db.prepare(`
SELECT battery_key AS batteryKey, rover_id AS roverId, chemistry,
rated_capacity_mah AS ratedCapacityMah, installed_at AS installedAt,
healthy_baseline_mah AS healthyBaselineMah, notes, updated_at AS updatedAt
FROM fleet_batteries
WHERE rover_id = ? AND retired_at IS NULL
ORDER BY installed_at DESC
LIMIT 1
`).get(String(roverId)) || null);
}
function replaceBattery(entry) {
return runSafely('battery replacement write', () => db.transaction(() => {
const now = Date.now();
db.prepare('UPDATE fleet_batteries SET retired_at = ?, updated_at = ? WHERE rover_id = ? AND retired_at IS NULL')
.run(entry.installedAt || now, now, entry.roverId);
db.prepare(`
INSERT INTO fleet_batteries (
battery_key, rover_id, chemistry, rated_capacity_mah, installed_at,
retired_at, healthy_baseline_mah, notes, updated_at
) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)
`).run(
entry.batteryKey,
entry.roverId,
entry.chemistry || null,
entry.ratedCapacityMah || null,
entry.installedAt || now,
entry.healthyBaselineMah || null,
entry.notes || null,
now,
);
return getActiveBattery(entry.roverId);
})());
}
function saveDailyReport(reportDate, report) {
return runSafely('daily report write', () => db.prepare(`
INSERT INTO fleet_daily_reports (report_date, generated_at, report_json)
VALUES (?, ?, ?)
ON CONFLICT(report_date) DO UPDATE SET
generated_at = excluded.generated_at,
report_json = excluded.report_json
`).run(reportDate, Date.now(), safeJson(report)));
}
function listDailyReports(limit = 90) {
return runSafely('daily report history query', () => db.prepare(`
SELECT report_date AS reportDate, generated_at AS generatedAt,
discord_delivered_at AS discordDeliveredAt, discord_error AS discordError,
length(report_json) AS reportBytes
FROM fleet_daily_reports
ORDER BY report_date DESC
LIMIT ?
`).all(Math.max(1, Math.min(1000, Number(limit) || 90))), []);
}
function markDailyReportDelivery(reportDate, { deliveredAt = null, error = null } = {}) {
return runSafely('daily delivery update', () => db.prepare(`
UPDATE fleet_daily_reports
SET discord_delivered_at = ?, discord_error = ?
WHERE report_date = ?
`).run(deliveredAt, error, reportDate));
}
function getDiagnostics() {
return runSafely('diagnostics query', () => ({
available: true,
path: DB_PATH,
bytes: fs.statSync(DB_PATH).size,
eventCount: db.prepare('SELECT COUNT(*) AS count FROM fleet_events').get().count,
minuteCount: db.prepare('SELECT COUNT(*) AS count FROM fleet_minute_samples').get().count,
sessionCount: db.prepare('SELECT COUNT(*) AS count FROM fleet_battery_sessions').get().count,
}), { available: false, path: DB_PATH });
}
return {
open,
insertEvent,
upsertMinute,
insertBatterySession,
listEvents,
listMinutes,
listBatterySessions,
prune,
getDailyReport,
saveDailyReport,
listDailyReports,
markDailyReportDelivery,
listBatteries,
getActiveBattery,
replaceBattery,
getDiagnostics,
};
}
module.exports = {
createStorage,
};
@@ -0,0 +1,178 @@
// Fun Stats Service
// Purpose: Persists the running counters behind the social `rs` fun commands.
// Scope: Owns storage and clamping only; command handlers decide what a counter means.
const fs = require('fs');
const path = require('path');
const logger = require('../../globals/logger').child('funStatsService');
const { resolveDataPath } = require('../../helpers/dataPaths');
const STORE_PATH = resolveDataPath('fun-stats.json');
// Counters are additive and never authoritative for anything but bragging
// rights, so the ceiling only exists to keep a runaway loop from writing an
// unbounded integer into the store.
const MAX_COUNT = 1_000_000;
const MAX_LABEL_LENGTH = 64;
const ACTOR_COUNTERS = [
'bonksGiven',
'bonksTaken',
'hugsGiven',
'hugsTaken',
'slapsGiven',
'slapsTaken',
];
/*
This service deliberately keeps its own tiny JSON store rather than reusing
identityService.createJsonStore. Fun counters are keyed by an actor key that
spans transports (`user:<id>` for site chat, `discord:<id>` for Discord), and
a Discord id has no row in `users`, so it cannot live in `user_feature_state`
without violating that table's foreign key. Keeping storage local also means
the counters can be unit tested without opening the identity database.
*/
let cache = null;
function clampCount(value) {
const count = Number(value);
if (!Number.isFinite(count) || count <= 0) return 0;
return Math.min(Math.floor(count), MAX_COUNT);
}
function normalizeLabel(value) {
const label = String(value || '').trim().replace(/\s+/g, ' ');
if (!label) return null;
return label.slice(0, MAX_LABEL_LENGTH);
}
function normalizeActor(raw = {}) {
const actor = { label: normalizeLabel(raw.label) };
ACTOR_COUNTERS.forEach((key) => {
actor[key] = clampCount(raw[key]);
});
actor.updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : null;
return actor;
}
function normalizeStore(raw = {}) {
const actors = {};
const rawActors = raw && typeof raw.actors === 'object' && raw.actors ? raw.actors : {};
Object.keys(rawActors).forEach((key) => {
const actorKey = String(key || '').trim();
if (!actorKey) return;
actors[actorKey] = normalizeActor(rawActors[actorKey]);
});
const rovers = {};
const rawRovers = raw && typeof raw.rovers === 'object' && raw.rovers ? raw.rovers : {};
Object.keys(rawRovers).forEach((key) => {
const roverId = String(key || '').trim();
if (!roverId) return;
const entry = rawRovers[roverId] || {};
rovers[roverId] = {
pets: clampCount(entry.pets),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : null,
};
});
return { actors, rovers };
}
function loadState() {
if (cache) return cache;
try {
cache = normalizeStore(JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')));
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalizeStore({});
}
return cache;
}
function persistState(next) {
const normalized = normalizeStore(next);
try {
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
} catch (err) {
// A failed write must not break the command that triggered it. The joke
// still lands; only the tally is lost.
logger.warn('Failed to persist fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalized;
return cache;
}
function getActorStats(actorKey) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
return { ...(loadState().actors[key] || normalizeActor({})) };
}
/*
`patch` is a map of counter name to increment. Unknown counter names are
ignored rather than stored so a typo in a handler cannot quietly create a
parallel counter that never shows up on the leaderboard.
*/
function bumpActorStats(actorKey, { label = null, ...patch } = {}) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
const state = loadState();
const current = state.actors[key] || normalizeActor({});
const next = { ...current };
const resolvedLabel = normalizeLabel(label);
if (resolvedLabel) next.label = resolvedLabel;
ACTOR_COUNTERS.forEach((counter) => {
const delta = Number(patch[counter]);
if (!Number.isFinite(delta) || delta === 0) return;
next[counter] = clampCount(current[counter] + delta);
});
next.updatedAt = Date.now();
persistState({ ...state, actors: { ...state.actors, [key]: next } });
return { ...next };
}
function listActorStats() {
const { actors } = loadState();
return Object.keys(actors).map((actorKey) => ({ actorKey, ...actors[actorKey] }));
}
function bumpRoverPets(roverId, by = 1) {
const id = String(roverId || '').trim();
if (!id) return 0;
const state = loadState();
const current = state.rovers[id] || { pets: 0, updatedAt: null };
const delta = Number(by);
const next = {
pets: clampCount(current.pets + (Number.isFinite(delta) ? delta : 0)),
updatedAt: Date.now(),
};
persistState({ ...state, rovers: { ...state.rovers, [id]: next } });
return next.pets;
}
function getRoverPets(roverId) {
const id = String(roverId || '').trim();
if (!id) return 0;
return loadState().rovers[id]?.pets || 0;
}
// Tests drive the store through a temporary SERVER_DATA_DIR, so they need a way
// to drop the module-level cache between cases.
function resetCacheForTests() {
cache = null;
}
module.exports = {
ACTOR_COUNTERS,
STORE_PATH,
getActorStats,
bumpActorStats,
listActorStats,
bumpRoverPets,
getRoverPets,
resetCacheForTests,
};
@@ -0,0 +1,131 @@
// Fun Stats Service Tests
// Purpose: Verifies counter persistence, clamping, and that a corrupt store degrades instead of throwing.
// Scope: Runs against a temporary SERVER_DATA_DIR so the real data directory is never touched.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fun-stats-test-'));
process.env.SERVER_DATA_DIR = dataDir;
const funStatsService = require('./index');
function reset() {
try {
fs.rmSync(funStatsService.STORE_PATH, { force: true });
} catch {
// A missing store is the normal starting state.
}
funStatsService.resetCacheForTests();
}
test('counters start at zero for an unknown actor', () => {
reset();
const stats = funStatsService.getActorStats('user:nobody');
assert.equal(stats.bonksGiven, 0);
assert.equal(stats.bonksTaken, 0);
assert.equal(stats.label, null);
});
test('bumping a counter accumulates and records the label', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
const stats = funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
assert.equal(stats.bonksGiven, 2);
assert.equal(stats.label, 'alice');
});
test('counters are independent of one another', () => {
reset();
funStatsService.bumpActorStats('user:alice', { bonksGiven: 3, hugsGiven: 1 });
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.bonksGiven, 3);
assert.equal(stats.hugsGiven, 1);
assert.equal(stats.slapsGiven, 0);
});
test('an unrecognized counter name is ignored rather than silently stored', () => {
reset();
funStatsService.bumpActorStats('user:alice', { notACounter: 5 });
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.notACounter, undefined);
});
test('state survives a cold read from disk', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 7 });
funStatsService.resetCacheForTests();
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 7);
});
test('an empty actor key is refused so anonymous bumps cannot share a bucket', () => {
reset();
funStatsService.bumpActorStats('', { bonksGiven: 1 });
assert.deepEqual(funStatsService.listActorStats(), []);
});
test('rover pets accumulate per rover', () => {
reset();
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 1);
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 2);
assert.equal(funStatsService.bumpRoverPets('rover-2', 1), 1);
assert.equal(funStatsService.getRoverPets('rover-1'), 2);
assert.equal(funStatsService.getRoverPets('unknown'), 0);
});
test('listActorStats returns every actor with their key', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
funStatsService.bumpActorStats('discord:4242', { label: 'dave', bonksGiven: 2 });
const keys = funStatsService.listActorStats().map((row) => row.actorKey).sort();
assert.deepEqual(keys, ['discord:4242', 'user:alice']);
});
test('negative and non-numeric deltas cannot drive a counter below zero', () => {
reset();
funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 });
funStatsService.bumpActorStats('user:alice', { bonksGiven: -50 });
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
funStatsService.bumpActorStats('user:alice', { bonksGiven: Number.NaN });
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
});
test('labels are trimmed and length capped', () => {
reset();
const stats = funStatsService.bumpActorStats('user:alice', { label: ` ${'x'.repeat(200)} `, bonksGiven: 1 });
assert.equal(stats.label.length, 64);
});
test('a corrupt store file degrades to empty instead of throwing', () => {
reset();
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
fs.writeFileSync(funStatsService.STORE_PATH, '{not json at all', 'utf8');
funStatsService.resetCacheForTests();
assert.deepEqual(funStatsService.listActorStats(), []);
// And it must still be writable afterwards.
assert.equal(funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 }).bonksGiven, 1);
});
test('a store with the wrong shape is normalized rather than trusted', () => {
reset();
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
fs.writeFileSync(
funStatsService.STORE_PATH,
JSON.stringify({ actors: { 'user:alice': { bonksGiven: 'lots', label: 42 } }, rovers: 'nope' }),
'utf8',
);
funStatsService.resetCacheForTests();
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.bonksGiven, 0);
assert.equal(stats.label, '42');
assert.equal(funStatsService.getRoverPets('rover-1'), 0);
});
test.after(() => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
+7
View File
@@ -4,7 +4,14 @@
const { httpServer } = require('../../globals/http'); const { httpServer } = require('../../globals/http');
const config = require('../../globals/config'); const config = require('../../globals/config');
const logger = require('../../globals/logger').child('httpServer'); const logger = require('../../globals/logger').child('httpServer');
const { startMediaMtx } = require('../mediaMtxService');
httpServer.listen(config.port, () => { httpServer.listen(config.port, () => {
logger.info(`Server listening on :${config.port}`); logger.info(`Server listening on :${config.port}`);
/*
MediaMTX immediately calls the server's HTTP authorization route when clients connect.
Starting it from the listen callback guarantees that endpoint is reachable before the
first publisher attempts to authenticate.
*/
startMediaMtx();
}); });
@@ -11,6 +11,7 @@ const {
removeUserSignal, removeUserSignal,
setVerified, setVerified,
setDeterrence, setDeterrence,
setMuted,
setFeatureState, setFeatureState,
deleteFeatureState, deleteFeatureState,
} = require('../identityService'); } = require('../identityService');
@@ -103,6 +104,14 @@ io.on('connection', (socket) => {
}).id), }).id),
})); }));
ackHandler(socket, 'identityAdmin:setMuted', ({ userId, enabled }) => ({
user: getUserForAdmin(setMuted(userId, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => { ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
const normalized = normalizeFeaturePayload(namespace, value); const normalized = normalizeFeaturePayload(namespace, value);
setFeatureState(userId, normalized.namespace, normalized.value); setFeatureState(userId, normalized.namespace, normalized.value);
+122 -3
View File
@@ -17,7 +17,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
const DB_PATH = resolveDataPath('identity.sqlite'); const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json'); const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json'); const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 1; const STORE_VERSION = 3;
const identityEvents = new EventEmitter(); const identityEvents = new EventEmitter();
let db = null; let db = null;
@@ -166,7 +166,13 @@ function ensureSchema(conn) {
deterrence_enabled integer not null default 0, deterrence_enabled integer not null default 0,
deterrence_reason text, deterrence_reason text,
deterrence_at integer, deterrence_at integer,
deterrence_by text deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text,
audio_gain_boost_enabled integer not null default 0,
audio_gain_boost_at integer,
audio_gain_boost_by text
); );
create table if not exists verification_requests ( create table if not exists verification_requests (
@@ -213,6 +219,34 @@ function ensureSchema(conn) {
pragma user_version = ${STORE_VERSION}; pragma user_version = ${STORE_VERSION};
`); `);
/*
SQLite's `create table if not exists` leaves an existing table untouched.
Add the mute columns explicitly for installations created before store
version 2, and the audio gain boost columns for those created before store
version 3. The column-name check keeps every later startup idempotent.
*/
const statusColumns = new Set(
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
);
if (!statusColumns.has('muted_enabled')) {
conn.exec('alter table user_status add column muted_enabled integer not null default 0');
}
if (!statusColumns.has('muted_at')) {
conn.exec('alter table user_status add column muted_at integer');
}
if (!statusColumns.has('muted_by')) {
conn.exec('alter table user_status add column muted_by text');
}
if (!statusColumns.has('audio_gain_boost_enabled')) {
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
}
if (!statusColumns.has('audio_gain_boost_at')) {
conn.exec('alter table user_status add column audio_gain_boost_at integer');
}
if (!statusColumns.has('audio_gain_boost_by')) {
conn.exec('alter table user_status add column audio_gain_boost_by text');
}
} }
function createUser(conn = getDb(), ts = nowMs()) { function createUser(conn = getDb(), ts = nowMs()) {
@@ -279,6 +313,20 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
where user_id = ? where user_id = ?
`).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId); `).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId);
} }
if (sourceStatus?.muted_enabled) {
/*
Identity merging must preserve the stricter moderation state. Otherwise
joining two signals could silently clear a mute merely because the
unmuted record happened to become the merge target.
*/
conn.prepare(`
update user_status
set muted_enabled = 1,
muted_at = coalesce(muted_at, ?),
muted_by = coalesce(muted_by, ?)
where user_id = ?
`).run(sourceStatus.muted_at || ts, sourceStatus.muted_by || null, targetUserId);
}
const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId); const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId);
sourceFeatures.forEach((feature) => { sourceFeatures.forEach((feature) => {
@@ -382,6 +430,8 @@ function setSocketIdentityState(socket, user, identity = {}) {
socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null; socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null;
socket.data.isDeterred = Boolean(user.deterrence?.enabled); socket.data.isDeterred = Boolean(user.deterrence?.enabled);
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null; socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
socket.data.isMuted = Boolean(user.deterrence?.muted);
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
} }
function identifySocket(socket, payload = {}) { function identifySocket(socket, payload = {}) {
@@ -422,6 +472,7 @@ function identifySocket(socket, payload = {}) {
fingerprintId: fingerprintId || null, fingerprintId: fingerprintId || null,
isVerified: Boolean(user.verified?.enabled), isVerified: Boolean(user.verified?.enabled),
isDeterred: Boolean(user.deterrence?.enabled), isDeterred: Boolean(user.deterrence?.enabled),
isMuted: Boolean(user.deterrence?.muted),
}; };
} }
@@ -468,6 +519,14 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
reason: status.deterrence_reason || null, reason: status.deterrence_reason || null,
at: status.deterrence_at || null, at: status.deterrence_at || null,
by: status.deterrence_by || null, by: status.deterrence_by || null,
muted: Boolean(status.muted_enabled),
mutedAt: status.muted_at || null,
mutedBy: status.muted_by || null,
},
audioGainBoost: {
enabled: Boolean(status.audio_gain_boost_enabled),
at: status.audio_gain_boost_at || null,
by: status.audio_gain_boost_by || null,
}, },
features, features,
}; };
@@ -673,20 +732,61 @@ function setDeterrence(userId, { enabled = true, reason = null, actor = null, at
return getUserById(id); return getUserById(id);
} }
function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
const id = String(userId || '').trim();
if (!id) throw new Error('userId required');
ensureUserStatus(getDb(), id);
getDb().prepare(`
update user_status
set muted_enabled = ?, muted_at = ?, muted_by = ?
where user_id = ?
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
identityEvents.emit('change', { reason: enabled ? 'muted' : 'unmuted', userId: id });
return getUserById(id);
}
/*
The audio gain boost flag lets a trusted VIP raise their personal horn/TTS/mic
gain ceiling past the global admin gain settings. It stays a status column
rather than feature state so it can be filtered in SQL alongside the other
moderation flags and copied onto the socket at identify time.
*/
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
const id = String(userId || '').trim();
if (!id) throw new Error('userId required');
ensureUserStatus(getDb(), id);
getDb().prepare(`
update user_status
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
where user_id = ?
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
identityEvents.emit('change', {
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
userId: id,
});
return getUserById(id);
}
function isVerified(socket) { function isVerified(socket) {
return Boolean(socket?.data?.isVerified); return Boolean(socket?.data?.isVerified);
} }
function hasAudioGainBoost(socket) {
return Boolean(socket?.data?.hasAudioGainBoost);
}
function isDeterred(socket) { function isDeterred(socket) {
return Boolean(socket?.data?.isDeterred); return Boolean(socket?.data?.isDeterred);
} }
function listUsers({ verified = null, deterred = null } = {}) { function listUsers({ verified = null, deterred = null, muted = null, audioGainBoost = null } = {}) {
const conn = getDb(); const conn = getDb();
let sql = 'select users.id from users join user_status on user_status.user_id = users.id'; let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
const where = []; const where = [];
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`); if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`); if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
if (audioGainBoost !== null) where.push(`user_status.audio_gain_boost_enabled = ${audioGainBoost ? 1 : 0}`);
if (where.length) sql += ` where ${where.join(' and ')}`; if (where.length) sql += ` where ${where.join(' and ')}`;
sql += ' order by users.updated_at desc'; sql += ' order by users.updated_at desc';
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false })); return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
@@ -705,6 +805,12 @@ function userToLegacyIdentityEntry(user) {
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
approvedBy: user.verified?.by || null, approvedBy: user.verified?.by || null,
reason: user.deterrence?.reason || null, reason: user.deterrence?.reason || null,
muted: Boolean(user.deterrence?.muted),
mutedAt: user.deterrence?.mutedAt || null,
mutedBy: user.deterrence?.mutedBy || null,
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
audioGainBoostAt: user.audioGainBoost?.at || null,
audioGainBoostBy: user.audioGainBoost?.by || null,
}; };
} }
@@ -716,6 +822,14 @@ function listDeterredUsers() {
return listUsers({ deterred: true }).map(userToLegacyIdentityEntry); return listUsers({ deterred: true }).map(userToLegacyIdentityEntry);
} }
function listMutedUsers() {
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
}
function listAudioGainBoostUsers() {
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
}
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) { function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
const value = String(selector || '').trim(); const value = String(selector || '').trim();
if (!value) return { error: 'selector_required' }; if (!value) return { error: 'selector_required' };
@@ -969,10 +1083,15 @@ module.exports = {
listFeatureStates, listFeatureStates,
setVerified, setVerified,
setDeterrence, setDeterrence,
setMuted,
setAudioGainBoost,
isVerified, isVerified,
isDeterred, isDeterred,
hasAudioGainBoost,
listVerifiedUsers, listVerifiedUsers,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
listAudioGainBoostUsers,
resolveUserBySelector, resolveUserBySelector,
userToLegacyIdentityEntry, userToLegacyIdentityEntry,
createJsonStore, createJsonStore,
@@ -0,0 +1,100 @@
// MediaMTX Config Builder
// Purpose: Converts the rover server's media settings into the complete MediaMTX runtime configuration.
// Scope: Keeps deployment-specific hosts in config.yaml while keeping protocol policy owned by the application.
const path = require('path');
function normalizeAdditionalHosts(rawHosts) {
if (rawHosts == null) return [];
if (!Array.isArray(rawHosts)) {
throw new Error('media.additionalHosts must be a list');
}
/*
MediaMTX accepts both IP addresses and DNS names here. Preserve that flexibility because
an installation can need a public candidate and a LAN candidate at the same time. Empty
entries and duplicates are removed so a harmless config typo does not create redundant
ICE candidates, while the values themselves remain entirely instance-owned.
*/
return [...new Set(rawHosts.map((value) => String(value || '').trim()).filter(Boolean))];
}
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
const media = config?.media || {};
let additionalHosts = normalizeAdditionalHosts(media.additionalHosts);
if (!additionalHosts.length && media.whepBaseUrl) {
try {
/*
Existing installations predate media.additionalHosts. Using the already-configured
WHEP hostname as a one-host migration default keeps them reachable on first restart;
administrators can still list every public and LAN candidate explicitly afterward.
*/
additionalHosts = [new URL(media.whepBaseUrl).hostname].filter(Boolean);
} catch {
throw new Error('media.whepBaseUrl must be a valid URL when media.additionalHosts is empty');
}
}
const authPort = Number(serverPort) || 8080;
return {
logLevel: 'info',
api: true,
apiAddress: '127.0.0.1:9997',
metrics: true,
metricsAddress: '127.0.0.1:9998',
pprof: false,
pprofAddress: '127.0.0.1:9999',
/*
Rover publishers and readers always request TCP explicitly. Declaring only TCP here
also prevents MediaMTX from opening the separate RTP/RTCP UDP listeners, which are not
useful for this local-network deployment and performed poorly in the measured tests.
*/
rtsp: true,
rtspAddress: ':8554',
rtspTransports: ['tcp'],
rtmp: false,
hls: false,
webrtc: true,
webrtcLocalUDPAddress: ':8189',
webrtcLocalTCPAddress: ':8189',
webrtcAdditionalHosts: additionalHosts,
webrtcICEServers2: [
{ url: 'stun:stun.l.google.com:19302' },
{ url: 'stun:stun1.l.google.com:19302' },
{ url: 'stun:stun2.l.google.com:19302' },
{ url: 'stun:stun3.l.google.com:19302' },
{ url: 'stun:stun4.l.google.com:19302' },
{ url: 'stun:stun.cloudflare.com:3478' },
],
/*
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot
writer. Rover media moves to RTSP, but removing this listener would break those independent
consumers, so both listeners remain deliberately enabled.
*/
srt: true,
srtAddress: ':9000',
authMethod: 'http',
authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`,
authHTTPExclude: [
{ action: 'api' },
{ action: 'metrics' },
{ action: 'pprof' },
],
paths: {
all: {
source: 'publisher',
sourceOnDemand: false,
runOnReady: path.resolve(snapshotWriterPath),
runOnReadyRestart: true,
},
},
};
}
module.exports = {
buildMediaMtxConfig,
normalizeAdditionalHosts,
};
@@ -0,0 +1,45 @@
// MediaMTX Config Builder Tests
// Purpose: Pins the generated protocol policy and instance-specific ICE host handling.
// Scope: Tests pure configuration output without starting listeners or leaving a child process running.
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config');
test('generates RTSP over TCP without deployment-specific hardcodes', () => {
const generated = buildMediaMtxConfig({
config: {
media: {
whepBaseUrl: 'http://media.internal:8889/video',
additionalHosts: ['public.example.com', '10.20.30.40'],
},
},
serverPort: 8123,
snapshotWriterPath: '/opt/multirover/rover-snapshot-writer.sh',
});
assert.equal(generated.rtsp, true);
assert.equal(generated.rtspAddress, ':8554');
assert.deepEqual(generated.rtspTransports, ['tcp']);
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
assert.equal(generated.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth');
});
test('uses the configured WHEP hostname while an older config has no additionalHosts', () => {
const generated = buildMediaMtxConfig({
config: { media: { whepBaseUrl: 'https://second-server.example/video' } },
serverPort: 8080,
snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh',
});
assert.deepEqual(generated.webrtcAdditionalHosts, ['second-server.example']);
});
test('normalizes duplicate and empty additional hosts', () => {
assert.deepEqual(
normalizeAdditionalHosts([' media.local ', '', 'media.local', null, 'public.example']),
['media.local', 'public.example'],
);
assert.throws(() => normalizeAdditionalHosts('media.local'), /must be a list/);
});
@@ -0,0 +1,46 @@
// MediaMTX Service
// Purpose: Composes server configuration, runtime paths, and child-process supervision.
// Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server.
const { loadConfig } = require('../../helpers/configLoader');
const globalConfig = require('../../globals/config');
const logger = require('../../globals/logger').child('mediamtx');
const { createMediaMtxSupervisor } = require('./supervisor');
const supervisor = createMediaMtxSupervisor({
config: loadConfig(),
serverPort: globalConfig.port,
logger,
});
function startMediaMtx() {
return supervisor.start();
}
/*
Other services already use process signal hooks for their own workers. This hook performs
only synchronous signal delivery; systemd's default control-group cleanup remains the final
guarantee if the parent is killed before the child finishes exiting.
*/
process.once('exit', () => supervisor.stop());
function stopForSignal(signal) {
let completed = false;
const finish = () => {
if (completed) return;
completed = true;
process.exit(signal === 'SIGINT' ? 130 : 143);
};
supervisor.stop(finish);
/*
A wedged child must not make systemd wait indefinitely. This timer is deliberately unref'd
so it never keeps an otherwise-finished process alive; it is only a bound on graceful exit.
*/
const forceExitTimer = setTimeout(finish, 5000);
forceExitTimer.unref?.();
}
process.once('SIGINT', () => stopForSignal('SIGINT'));
process.once('SIGTERM', () => stopForSignal('SIGTERM'));
module.exports = { startMediaMtx };
@@ -0,0 +1,103 @@
// MediaMTX Child Supervisor
// Purpose: Writes the generated runtime configuration and owns the MediaMTX child process lifecycle.
// Scope: Starts exactly one child, forwards its logs, and lets systemd restart the coherent server/media pair.
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const yaml = require('js-yaml');
const { buildMediaMtxConfig } = require('./config');
function createMediaMtxSupervisor(deps) {
const {
config,
serverPort,
logger,
mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx',
runtimeDir = process.env.MULTIROVER_RUNTIME_DIR || '/run/multirover',
snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
spawnProcess = spawn,
} = deps;
let child = null;
let stopping = false;
let stoppedCallback = null;
function forwardLines(stream, level) {
let pending = '';
stream.setEncoding('utf8');
stream.on('data', (chunk) => {
pending += chunk;
const lines = pending.split(/\r?\n/);
pending = lines.pop() || '';
lines.filter(Boolean).forEach((line) => logger[level](line));
});
stream.on('end', () => {
if (pending) logger[level](pending);
});
}
function start() {
if (child) return child;
const runtimeConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
const configPath = path.join(runtimeDir, 'mediamtx.yml');
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o750 });
fs.writeFileSync(configPath, yaml.dump(runtimeConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
logger.info(`Starting MediaMTX with generated config ${configPath}`);
child = spawnProcess(mediaMtxBin, [configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
forwardLines(child.stdout, 'info');
forwardLines(child.stderr, 'warn');
child.once('error', (err) => {
logger.error('Unable to start MediaMTX', err);
if (!stopping) {
/*
A spawn failure does not reliably emit the normal exit event on every platform.
Fail the parent here as well so the server can never stay nominally online without
its required media child and systemd gets the opportunity to repair the launch.
*/
process.exit(1);
}
});
child.once('exit', (code, signal) => {
child = null;
if (stopping) {
stoppedCallback?.();
stoppedCallback = null;
return;
}
/*
MediaMTX is required for every live media path. Exiting the parent is intentionally
simpler and safer than maintaining a second retry policy inside Node: systemd already
restarts multirover.service, producing one clean server/MediaMTX lifecycle.
*/
logger.error(`MediaMTX exited unexpectedly (code=${code ?? 'none'} signal=${signal || 'none'})`);
process.exit(1);
});
return child;
}
function stop(onStopped) {
stopping = true;
stoppedCallback = typeof onStopped === 'function' ? onStopped : null;
if (!child) {
stoppedCallback?.();
stoppedCallback = null;
return;
}
try {
child.kill('SIGTERM');
} catch (err) {
logger.warn('Unable to stop MediaMTX cleanly', err);
}
}
return { start, stop };
}
module.exports = { createMediaMtxSupervisor };
@@ -1,10 +1,45 @@
// Operator Deter Command // Operator Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins. // Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands. // Scope: Supports list, ban, and unban subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers'); const { mask, normalizeSearchText, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config'); const { getCommandConfig } = require('../../operatorCommandService/config');
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, sanitizeMentions, config }) { function findOnlineNicknameMatches(io, getNickname, selector) {
const normalizedSelector = normalizeSearchText(selector);
if (!normalizedSelector) return [];
const matchesByUserId = new Map();
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return [];
sockets.forEach((socket) => {
const nickname = getNickname(socket);
const userId = String(socket?.data?.userId || '').trim();
if (!userId || normalizeSearchText(nickname) !== normalizedSelector) return;
/*
One person may have multiple connected tabs or surfaces. Collapse those
sockets to the canonical user id so duplicate tabs do not manufacture an
ambiguous moderation target when they all represent the same identity.
*/
if (!matchesByUserId.has(userId)) {
matchesByUserId.set(userId, { userId, nickname });
}
});
return Array.from(matchesByUserId.values());
}
function uniqueIdentityRecords(records = []) {
const byIdentity = new Map();
records.forEach((record) => {
const key = record?.userId || record?.id || record?.cookieUserId || record?.fingerprintId;
if (key && !byIdentity.has(key)) byIdentity.set(key, record);
});
return Array.from(byIdentity.values());
}
function createDeterCommand({ io, getNickname, listDeterredUsers, listMutedUsers, listVerifiedUsers, deterUser, undeterUser, muteUser, unmuteUser, sanitizeMentions, config }) {
// Moderation usage errors use the same core prefix shown by organized help. // Moderation usage errors use the same core prefix shown by organized help.
const { prefix: commandPrefix } = getCommandConfig(config); const { prefix: commandPrefix } = getCommandConfig(config);
@@ -15,23 +50,58 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
} }
const action = (tokens.shift() || 'list').toLowerCase(); const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'list') { if (action === 'list') {
const users = listDeterredUsers(); const deterredUsers = listDeterredUsers().map((entry) => ({ ...entry, deterred: true }));
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } }); const mutedUsers = listMutedUsers().map((entry) => ({ ...entry, muted: true }));
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`); const usersById = new Map();
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } }); [...deterredUsers, ...mutedUsers].forEach((entry) => {
const userId = entry.userId || entry.id;
if (!userId) return;
const existing = usersById.get(userId) || {};
usersById.set(userId, {
...existing,
...entry,
deterred: Boolean(existing.deterred || entry.deterred),
muted: Boolean(existing.muted || entry.muted),
});
});
const users = Array.from(usersById.values());
if (!users.length) return message.reply({ content: 'No deterred or muted users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => {
const flags = [entry.deterred ? 'deterred' : '', entry.muted ? 'muted' : ''].filter(Boolean).join(', ');
return `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${flags} | ${mask(entry.cookieUserId)}`;
});
return message.reply({ content: ['Moderated users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
} }
if (action === 'ban') { if (action === 'ban') {
const selector = tokens.join(' ').trim(); const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} 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 { try {
const onlineMatches = findOnlineNicknameMatches(io, getNickname, selector);
if (onlineMatches.length > 1) {
/*
Identical live nicknames are genuinely ambiguous, so do not guess
for a destructive command. Unlike the old generic error, this
response exposes stable selectors that the administrator can copy
directly into a follow-up command.
*/
const choices = onlineMatches.map((match) => `${match.nickname} (${match.userId})`).join(', ');
return message.reply({
content: sanitizeMentions(`More than one online user is named ${selector}: ${choices}. Retry with \`${commandPrefix} deter ban <userId>\`.`),
allowedMentions: { parse: [], repliedUser: false },
});
}
let stableSelector = onlineMatches[0]?.userId || null;
if (!stableSelector) {
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false }); const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) { if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
} }
stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
}
// Ban reasons were deliberately removed from the command grammar. The // Ban reasons were deliberately removed from the command grammar. The
// full remaining text is now always the selector, which lets lockdown // full remaining text is now always the selector, which lets lockdown
// admins deter multi-word nicknames without quoting or delimiter rules. // 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.actor?.id || null }); const deterred = deterUser(stableSelector, { actor: message.actor?.id || null });
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) { } catch (err) {
@@ -50,8 +120,47 @@ 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: 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 } }); if (action === 'mute' || action === 'unmute') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ${action} <userId|cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
try {
const onlineMatches = findOnlineNicknameMatches(io, getNickname, selector);
if (onlineMatches.length > 1) {
const choices = onlineMatches.map((match) => `${match.nickname} (${match.userId})`).join(', ');
return message.reply({
content: sanitizeMentions(`More than one online user is named ${selector}: ${choices}. Retry with \`${commandPrefix} deter ${action} <userId>\`.`),
allowedMentions: { parse: [], repliedUser: false },
});
}
let stableSelector = onlineMatches[0]?.userId || null;
if (!stableSelector) {
const storedCandidates = uniqueIdentityRecords([...listVerifiedUsers(), ...listMutedUsers()]);
const storedMatch = resolveIdentitySelector(selector, storedCandidates, { includeId: true });
if (storedMatch.error && !/not found/i.test(storedMatch.error)) {
return message.reply({ content: sanitizeMentions(storedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
}
/*
Unverified users may not appear in the convenience candidate list.
Passing the original exact selector through lets verificationService
resolve any canonical identity without making fuzzy guesses here.
*/
stableSelector = storedMatch.record?.userId || storedMatch.record?.id || storedMatch.record?.cookieUserId || selector;
}
const updated = action === 'mute'
? muteUser(stableSelector, message.actor?.id || null)
: unmuteUser(stableSelector, message.actor?.id || null);
return message.reply({
content: sanitizeMentions(`${action === 'mute' ? 'Muted' : 'Unmuted'} ${updated.nickname || 'unknown'} (${mask(updated.cookieUserId)}).`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to ${action} user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, \`${commandPrefix} deter unban <selector>\`, \`${commandPrefix} deter mute <selector>\`, or \`${commandPrefix} deter unmute <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
}; };
} }
module.exports = { createDeterCommand }; module.exports = { createDeterCommand, findOnlineNicknameMatches, uniqueIdentityRecords };
@@ -0,0 +1,113 @@
// Operator Deter Command Tests
// Purpose: Verifies that live nickname identity takes precedence over ambiguous stored aliases.
// Scope: Exercises only command target resolution with in-memory socket and identity doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createDeterCommand } = require('./deter');
function createSocket(id, userId, nickname) {
return { id, data: { userId, nickname } };
}
function createHarness(sockets = [], verifiedUsers = []) {
const deterCalls = [];
const muteCalls = [];
const replies = [];
const handler = createDeterCommand({
io: { sockets: { sockets: new Map(sockets.map((socket) => [socket.id, socket])) } },
getNickname: (socket) => socket?.data?.nickname || '',
listDeterredUsers: () => [],
listMutedUsers: () => [],
listVerifiedUsers: () => verifiedUsers,
deterUser: (selector) => {
deterCalls.push(selector);
return { created: true, nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
undeterUser: () => null,
muteUser: (selector) => {
muteCalls.push({ action: 'mute', selector });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
unmuteUser: (selector) => {
muteCalls.push({ action: 'unmute', selector });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
const message = {
actor: { id: 'admin', isLockdownAdmin: true },
reply: async (payload) => {
replies.push(payload);
return payload;
},
};
return { handler, message, deterCalls, muteCalls, replies };
}
test('prefers the one online exact nickname over ambiguous stored records', async () => {
const verifiedUsers = [
{ userId: 'old-user', nickname: 'Croissant', cookieUserId: 'old-cookie' },
{ userId: 'live-user', nickname: 'Croissant', cookieUserId: 'live-cookie' },
];
const { handler, message, deterCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
], verifiedUsers);
await handler(message, ['ban', 'croissant']);
assert.deepEqual(deterCalls, ['live-user']);
});
test('collapses multiple sockets belonging to the same online identity', async () => {
const { handler, message, deterCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
createSocket('socket-2', 'live-user', 'croissant'),
]);
await handler(message, ['ban', 'Croissant']);
assert.deepEqual(deterCalls, ['live-user']);
});
test('returns usable user ids when different online identities share a nickname', async () => {
const { handler, message, deterCalls, replies } = createHarness([
createSocket('socket-1', 'user-one', 'Croissant'),
createSocket('socket-2', 'user-two', 'croissant'),
]);
await handler(message, ['ban', 'croissant']);
assert.deepEqual(deterCalls, []);
assert.match(replies[0].content, /user-one/);
assert.match(replies[0].content, /user-two/);
assert.match(replies[0].content, /rs deter ban <userId>/);
});
test('mute uses the same exact online nickname preference as ban', async () => {
const verifiedUsers = [
{ userId: 'old-user', nickname: 'Croissant', cookieUserId: 'old-cookie' },
{ userId: 'live-user', nickname: 'Croissant', cookieUserId: 'live-cookie' },
];
const { handler, message, muteCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
], verifiedUsers);
await handler(message, ['mute', 'croissant']);
assert.deepEqual(muteCalls, [{ action: 'mute', selector: 'live-user' }]);
});
test('unmute returns usable ids for genuinely duplicated online nicknames', async () => {
const { handler, message, muteCalls, replies } = createHarness([
createSocket('socket-1', 'user-one', 'Croissant'),
createSocket('socket-2', 'user-two', 'croissant'),
]);
await handler(message, ['unmute', 'Croissant']);
assert.deepEqual(muteCalls, []);
assert.match(replies[0].content, /user-one/);
assert.match(replies[0].content, /user-two/);
assert.match(replies[0].content, /rs deter unmute <userId>/);
});
@@ -0,0 +1,174 @@
// Operator Fun Command Helpers
// Purpose: Shared actor identity, target lookup, and deterministic randomness for the fun commands.
// Scope: No side effects; every function here is safe to call before permission checks pass.
const { normalizeSearchText, normalizeText, resolveRoverSelector } = require('./resolvers');
// Echoed user text is capped so a fun command cannot be used to shout a wall of
// text into every bridged Discord channel.
const MAX_ECHO_LENGTH = 180;
const PLAIN_MENTIONS = { parse: [], repliedUser: false };
/*
Fun counters have to survive across transports, so they are keyed by a stable
identity rather than a connection. Site chat resolves to the identity user id
that moderation already uses; Discord has no row in that database, so it gets
its own key space. An unidentified site socket falls back to its socket id,
which means its tally resets on reconnect acceptable for a joke counter, and
much better than crediting every anonymous visitor to one shared bucket.
*/
function buildActorKey(request) {
const transport = normalizeText(request?.transport) || 'unknown';
if (transport === 'discord') {
const discordId = normalizeText(request?.actor?.id);
return discordId ? `discord:${discordId}` : null;
}
const userId = normalizeText(request?.actor?.userId);
if (userId) return `user:${userId}`;
const socketId = normalizeText(request?.actor?.id);
return socketId ? `socket:${socketId}` : null;
}
function actorLabel(request) {
return normalizeText(request?.actor?.label) || 'someone';
}
/*
Collapses every connected socket for one person onto their canonical user id so
extra browser tabs cannot make a target look ambiguous. Mirrors the same
approach the deter command uses for moderation targets.
*/
function findOnlineUsers(io, getNickname, selector) {
const normalizedSelector = normalizeSearchText(selector);
if (!normalizedSelector) return [];
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return [];
const byUserId = new Map();
sockets.forEach((socket) => {
const nickname = getNickname?.(socket);
if (normalizeSearchText(nickname) !== normalizedSelector) return;
const userId = normalizeText(socket?.data?.userId);
const key = userId || `socket:${normalizeText(socket?.id)}`;
if (!key) return;
if (!byUserId.has(key)) {
byUserId.set(key, { userId: userId || null, nickname: normalizeText(nickname), socket });
}
});
return Array.from(byUserId.values());
}
/*
A fun command should still work when the target is not a real user bonking
"the dishwasher" is half the point. So an unmatched selector is not an error:
it becomes a plain label and simply credits nobody's tally. Ambiguity is
treated the same way, because guessing which of two identical nicknames took
the hit would be worse than crediting neither.
*/
function resolveFunTarget({ io, getNickname, selector }) {
const label = clampEcho(selector);
if (!label) return null;
const matches = findOnlineUsers(io, getNickname, selector);
if (matches.length === 1) {
const [match] = matches;
return {
label: match.nickname || label,
actorKey: match.userId ? `user:${match.userId}` : null,
socket: match.socket || null,
online: true,
};
}
return { label, actorKey: null, socket: null, online: false };
}
/*
Rover-scoped fun commands accept an explicit rover name and otherwise fall back
to whichever rover the caller is already attached to. Discord has no socket
behind it, so the fallback simply is not available there and the caller is asked
to name a rover rather than having one chosen for them.
*/
function createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix = 'rs' }) {
return function resolveTargetRover(selector, action = 'pet') {
const query = normalizeText(selector);
if (query) {
const resolved = resolveRoverSelector(query, rovers);
if (resolved.error) return { error: resolved.error };
return { id: resolved.id, name: resolved.label || resolved.id, record: resolved.record };
}
const socket = getActorSocket?.() || null;
if (!socket) return { error: `Name a rover: \`${commandPrefix} ${action} <rover>\`` };
// getPrimaryRoverForSocket takes a socket id and returns a rover id string.
const roverId = normalizeText(roverManager?.getPrimaryRoverForSocket?.(socket.id));
if (!roverId) return { error: 'You are not on a rover right now. Name one instead.' };
const record = rovers.get(roverId) || null;
return { id: roverId, name: record?.meta?.name || roverId, record, socket };
};
}
function clampEcho(value) {
const text = normalizeText(value).replace(/\s+/g, ' ');
if (!text) return '';
if (text.length <= MAX_ECHO_LENGTH) return text;
return `${text.slice(0, MAX_ECHO_LENGTH - 1)}`;
}
/*
FNV-1a. Fun commands that judge something `ship`, `rate`, `8ball` use a
hash of the input instead of Math.random so the same question always gets the
same answer. Re-rolling until you like the verdict is not funny; a server that
stubbornly insists your ship rating is 4% is.
*/
function hashSeed(value) {
const text = normalizeSearchText(value);
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
function pickBySeed(list, seed) {
const items = Array.isArray(list) ? list : [];
if (!items.length) return null;
return items[seed % items.length];
}
// Order-independent so `rs ship a b` and `rs ship b a` agree with each other.
function pairSeed(left, right) {
const pair = [normalizeSearchText(left), normalizeSearchText(right)].sort();
return hashSeed(pair.join(' '));
}
function percentFromSeed(seed) {
return seed % 101;
}
function ordinal(count) {
const value = Number(count) || 0;
const mod100 = value % 100;
if (mod100 >= 11 && mod100 <= 13) return `${value}th`;
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[value % 10] || 'th';
return `${value}${suffix}`;
}
module.exports = {
MAX_ECHO_LENGTH,
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
clampEcho,
createRoverResolver,
findOnlineUsers,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
};
@@ -0,0 +1,162 @@
// Operator Fun Helper Tests
// Purpose: Locks down actor identity, target resolution, and the deterministic seeding the fun commands depend on.
// Scope: Pure helpers plus in-memory socket doubles; nothing here touches the fun stats store.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildActorKey,
clampEcho,
createRoverResolver,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
MAX_ECHO_LENGTH,
} = require('./funHelpers');
function socket(id, userId, nickname) {
return { id, data: { userId, nickname } };
}
function harness(sockets = []) {
return {
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
getNickname: (entry) => entry?.data?.nickname || '',
};
}
test('site chat keys on the identity user id, not the socket', () => {
assert.equal(
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1', userId: 'u-alice' } }),
'user:u-alice',
);
});
test('an unidentified site socket falls back to its socket id', () => {
assert.equal(
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1' } }),
'socket:socket-1',
);
});
test('discord actors get their own key space so ids cannot collide with identity ids', () => {
assert.equal(buildActorKey({ transport: 'discord', actor: { id: '4242' } }), 'discord:4242');
});
test('an actor with no usable id at all is rejected rather than sharing a bucket', () => {
assert.equal(buildActorKey({ transport: 'web-chat', actor: {} }), null);
assert.equal(buildActorKey({ transport: 'discord', actor: {} }), null);
});
test('a single online nickname resolves to that user and credits their tally', () => {
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'BOB' });
assert.equal(resolved.label, 'bob');
assert.equal(resolved.actorKey, 'user:u-bob');
assert.equal(resolved.online, true);
});
test('multiple tabs for one person do not make the target ambiguous', () => {
const { io, getNickname } = harness([
socket('s1', 'u-bob', 'bob'),
socket('s2', 'u-bob', 'bob'),
]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
assert.equal(resolved.actorKey, 'user:u-bob');
});
test('an unmatched selector still works but credits nobody', () => {
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'the dishwasher' });
assert.equal(resolved.label, 'the dishwasher');
assert.equal(resolved.actorKey, null);
assert.equal(resolved.online, false);
});
test('two different people sharing a nickname credit neither', () => {
const { io, getNickname } = harness([
socket('s1', 'u-bob', 'bob'),
socket('s2', 'u-other', 'bob'),
]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
assert.equal(resolved.actorKey, null);
});
test('echoed text is length capped so a fun command cannot shout a wall of text', () => {
const long = 'a'.repeat(500);
const clamped = clampEcho(long);
assert.equal(clamped.length, MAX_ECHO_LENGTH);
assert.ok(clamped.endsWith('…'));
});
test('ship is order independent so both spellings agree', () => {
assert.equal(pairSeed('alice', 'bob'), pairSeed('bob', 'alice'));
});
test('seeded verdicts are stable, so a rating cannot be rerolled by asking again', () => {
const first = percentFromSeed(pairSeed('alice', 'bob'));
const second = percentFromSeed(pairSeed('alice', 'bob'));
assert.equal(first, second);
assert.ok(first >= 0 && first <= 100);
});
test('hashSeed ignores case and surrounding whitespace', () => {
assert.equal(hashSeed(' Will It Dock '), hashSeed('will it dock'));
});
test('pickBySeed stays in range and tolerates an empty list', () => {
const list = ['a', 'b', 'c'];
for (let seed = 0; seed < 20; seed += 1) {
assert.ok(list.includes(pickBySeed(list, seed)));
}
assert.equal(pickBySeed([], 5), null);
});
test('ordinal handles the teens correctly', () => {
assert.equal(ordinal(1), '1st');
assert.equal(ordinal(2), '2nd');
assert.equal(ordinal(3), '3rd');
assert.equal(ordinal(4), '4th');
assert.equal(ordinal(11), '11th');
assert.equal(ordinal(12), '12th');
assert.equal(ordinal(13), '13th');
assert.equal(ordinal(21), '21st');
assert.equal(ordinal(111), '111th');
});
test('an explicit rover name wins over whatever the caller is attached to', () => {
const rovers = new Map([
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
]);
const resolve = createRoverResolver({
rovers,
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
getActorSocket: () => ({ id: 's1' }),
});
assert.equal(resolve('Roomba Two').id, 'rover-2');
});
test('with no rover named the caller\'s current rover is used', () => {
const rovers = new Map([['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }]]);
const resolve = createRoverResolver({
rovers,
roverManager: { getPrimaryRoverForSocket: (socketId) => (socketId === 's1' ? 'rover-1' : null) },
getActorSocket: () => ({ id: 's1' }),
});
const resolved = resolve('');
assert.equal(resolved.id, 'rover-1');
assert.equal(resolved.name, 'Roomba One');
});
test('without a socket the caller is asked to name a rover instead of one being chosen', () => {
const resolve = createRoverResolver({
rovers: new Map(),
roverManager: {},
getActorSocket: () => null,
commandPrefix: 'rs',
});
assert.match(resolve('', 'pet').error, /Name a rover/);
});
@@ -0,0 +1,285 @@
// Operator Fun Rover Commands
// Purpose: Implements the fun commands that actually make the fleet or the room do something (honk, boo, disco, spin, vibecheck).
// Scope: Every handler here re-checks control and feature gating itself, because issueCommand bypasses the socket command guards.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const {
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
createRoverResolver,
hashSeed,
pickBySeed,
resolveFunTarget,
} = require('./funHelpers');
// Durations are deliberately short and are also bounded rover-side: roverd
// enforces its own horn MaxDuration, so a lost stop command cannot leave a horn
// sounding forever.
const HONK_MS = 600;
const HONK_FREQ_HZ = 440;
const SPIN_MS = 1200;
const SPIN_SPEED = 120;
const DISCO_MS = 12 * 1000;
const DISCO_TICK_MS = 750;
const HONK_ACTOR_COOLDOWN_MS = 20 * 1000;
const HONK_ROVER_COOLDOWN_MS = 8 * 1000;
const BOO_COOLDOWN_MS = 30 * 1000;
const SPIN_COOLDOWN_MS = 25 * 1000;
const DISCO_COOLDOWN_MS = 2 * 60 * 1000;
const VIBECHECK_COOLDOWN_MS = 5 * 1000;
/*
Taunts are a fixed list rather than caller-supplied text on purpose. `boo` puts
audio out of a speaker in a room full of people, so letting it read arbitrary
input would turn a joke command into an unmoderated TTS channel aimed at
whoever is nearest the rover.
*/
const BOO_TAUNTS = [
'Boo.', 'Your driving is being reviewed.', 'That was a choice.',
'The wall was right there.', 'Someone in chat is laughing at you.',
'I have seen better parking from the Neato.', 'Boo. Respectfully.',
'This is a citizen\'s arrest.', 'Turn left. No, the other left.',
];
const VIBE_VERDICTS = [
'immaculate', 'acceptable', 'questionable', 'concerning', 'dire', 'unwell',
];
function describeBattery(batteryState) {
const display = Number(batteryState?.percentDisplay);
if (Number.isFinite(display)) return `${Math.round(display)}%`;
const percent = Number(batteryState?.percent);
if (Number.isFinite(percent)) return `${Math.round(percent * 100)}%`;
return 'unknown';
}
function createFunRoverCommands({
io,
rovers,
roverManager,
getNickname,
getActiveDrivers,
getActorSocket,
issueCommand,
homeAssistantService,
isFeatureEnabled,
sanitizeMentions,
cooldowns,
logger,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
/*
issueCommand is the raw rover transport: it performs none of the ownership,
deterrence, or private-safety checks that the socket `command` handler applies.
Any fun command that moves hardware therefore has to prove control here, which
also means these commands are inherently site-chat only a Discord message has
no socket and so can never satisfy canDrive.
*/
function requireDriveControl(action, selector) {
const socket = getActorSocket?.() || null;
if (!socket) {
return { error: `\`${commandPrefix} ${action}\` only works from site chat, where you can actually be driving.` };
}
const rover = resolveTargetRover(selector, action);
if (rover.error) return { error: rover.error };
if (!roverManager?.canDrive?.(rover.id, socket)) {
return { error: `You need control of ${rover.name} to do that.` };
}
return { rover, socket };
}
function safeIssue(roverId, payload, context) {
try {
issueCommand(roverId, payload);
return true;
} catch (err) {
// Deferred stop commands routinely land after a rover drops off. That is
// expected, not an incident, so it is logged at debug volume and swallowed.
logger?.warn?.('Fun command could not reach rover', { roverId, context, error: err.message });
return false;
}
}
async function handleHonk(message, tokens = []) {
const control = requireDriveControl('honk', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover } = control;
if (rover.record?.meta?.horn?.enabled === false) {
return reply(message, `${rover.name} has no horn fitted.`);
}
const gated = gate(message, 'honk', HONK_ACTOR_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
// A second, rover-scoped window stops a group of drivers taking turns to
// honk the same rover continuously while each stays inside their own limit.
const roverWait = cooldowns.consume(`honk:rover:${rover.id}`, HONK_ROVER_COOLDOWN_MS);
if (roverWait > 0) {
return reply(message, `${rover.name} was just honked. Give it ${describeWait(roverWait)}.`);
}
if (!safeIssue(rover.id, { type: 'horn', horn: { action: 'start', waveform: 'sine', freqs: [HONK_FREQ_HZ] } }, 'honk:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'horn', horn: { action: 'stop' } }, 'honk:stop'), HONK_MS);
return reply(message, `📢 HONK. (${rover.name})`);
}
async function handleSpin(message, tokens = []) {
const control = requireDriveControl('spin', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover, socket } = control;
const gated = gate(message, 'spin', SPIN_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const maxWheelSpeed = Number(rover.record?.meta?.maxWheelSpeed);
const speed = Math.max(1, Math.min(SPIN_SPEED, Number.isFinite(maxWheelSpeed) && maxWheelSpeed > 0 ? maxWheelSpeed : SPIN_SPEED));
let driveDirect = { left: speed, right: -speed };
/*
Private rovers can carry a reduced speed ceiling that the socket path would
normally apply. Applying it explicitly keeps a fun command from being the one
way to exceed a limit an admin set for a specific rover.
*/
const safeDrive = roverManager?.applyPrivateDriveSafety?.(rover.id, socket, driveDirect);
if (safeDrive) driveDirect = safeDrive;
if (!safeIssue(rover.id, { type: 'drive', driveDirect }, 'spin:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'drive', driveDirect: { left: 0, right: 0 } }, 'spin:stop'), SPIN_MS);
return reply(message, `🌀 ${rover.name} is doing a spin.`);
}
async function handleBoo(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
const gated = gate(message, 'boo', BOO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const resolved = resolveFunTarget({ io, getNickname, selector });
if (!resolved) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
if (!resolved.online || !resolved.socket) {
return reply(message, `${resolved.label} is not here to be booed.`);
}
// Boo lands on the rover the target is actually driving, so it needs the
// active-driver map rather than merely which rovers they are watching.
const drivers = getActiveDrivers?.() || {};
const roverId = Object.keys(drivers).find((id) => drivers[id] === resolved.socket.id) || null;
if (!roverId) return reply(message, `${resolved.label} is not driving anything right now.`);
const record = rovers.get(String(roverId));
const roverName = record?.meta?.name || roverId;
if (record?.meta?.audio?.ttsEnabled === false) {
return reply(message, `${roverName} cannot speak.`);
}
const taunt = pickBySeed(BOO_TAUNTS, hashSeed(`${gated.actorKey}:${resolved.label}`));
if (!safeIssue(roverId, { type: 'tts', tts: { text: taunt, speak: true, engine: 'chromegtts' } }, 'boo')) {
return reply(message, `${roverName} is offline.`);
}
return reply(message, `👻 Booed ${resolved.label} through ${roverName}.`);
}
async function handleDisco(message) {
if (!homeAssistantService || !isFeatureEnabled?.('homeAssistant')) {
return reply(message, 'Room light controls are unavailable.');
}
// An admin lock on the room lights is a policy boundary. Disco is a scene
// change like `rs lights on`, so it must not be the one command that ignores it.
const lightPolicy = homeAssistantService.getLightPolicyState?.() || {};
if (lightPolicy.locked) {
return reply(message, 'Room lights are locked. No disco.');
}
const gated = gate(message, 'disco', DISCO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const setAll = homeAssistantService.setAllControllableEntitiesState;
if (typeof setAll !== 'function') {
return reply(message, 'Room light controls are unavailable.');
}
const endsAt = Date.now() + DISCO_MS;
let on = false;
/*
Held in a local interval rather than the rewards effect store because a
disco is short and disposable. Nothing needs to survive a restart, and the
final tick always restores the lights to on.
*/
const timer = setInterval(() => {
if (Date.now() >= endsAt) {
clearInterval(timer);
Promise.resolve(setAll('on')).catch((err) => {
logger?.warn?.('Disco could not restore lights', { error: err.message });
});
return;
}
on = !on;
Promise.resolve(setAll(on ? 'on' : 'off')).catch((err) => {
logger?.warn?.('Disco tick failed', { error: err.message });
});
}, DISCO_TICK_MS);
return reply(message, `🪩 Disco for ${Math.round(DISCO_MS / 1000)} seconds. Started by ${gated.label}.`);
}
async function handleVibecheck(message, tokens = []) {
const gated = gate(message, 'vibecheck', VIBECHECK_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'vibecheck');
if (rover.error) return reply(message, rover.error);
const record = rover.record || rovers.get(rover.id) || null;
const battery = describeBattery(record?.batteryState);
const offline = !record?.ws;
const locked = Boolean(record?.locked);
const urgent = Boolean(record?.batteryState?.urgentActive);
const warn = Boolean(record?.batteryState?.warnActive);
let verdict;
if (offline) verdict = 'nonexistent — it is offline';
else if (urgent) verdict = 'dying';
else if (warn) verdict = 'running low';
else if (locked) verdict = 'locked out and sulking';
else verdict = pickBySeed(VIBE_VERDICTS, hashSeed(`${rover.id}:${battery}`));
return reply(message, `🔍 ${rover.name}: vibes are **${verdict}**. Battery ${battery}.`);
}
return {
honk: handleHonk,
boo: handleBoo,
disco: handleDisco,
spin: handleSpin,
vibecheck: handleVibecheck,
};
}
module.exports = { createFunRoverCommands, describeBattery };
@@ -0,0 +1,297 @@
// Operator Fun Rover Command Tests
// Purpose: Verifies the control, feature, and lock checks the hardware-backed fun commands must make themselves.
// Scope: issueCommand, roverManager, and Home Assistant are all doubles; no real rover or timer is involved.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunRoverCommands, describeBattery } = require('./funRover');
const { createCooldownGate } = require('../cooldowns');
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
function createHarness({
canDrive = true,
socket = ALICE,
hornEnabled = true,
ttsEnabled = true,
online = true,
maxWheelSpeed = 300,
privateSafetyDrive = null,
activeDrivers = {},
homeAssistantService = null,
featureEnabled = false,
sockets = [ALICE, BOB],
} = {}) {
const issued = [];
const record = {
id: 'rover-1',
ws: online ? {} : null,
locked: false,
meta: {
name: 'Roomba One',
maxWheelSpeed,
horn: { enabled: hornEnabled },
audio: { ttsEnabled },
},
batteryState: { percentDisplay: 74, warnActive: false, urgentActive: false },
};
const rovers = new Map([['rover-1', record]]);
const handlers = createFunRoverCommands({
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
rovers,
roverManager: {
canDrive: () => canDrive,
getPrimaryRoverForSocket: () => 'rover-1',
applyPrivateDriveSafety: () => privateSafetyDrive,
},
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
getActorSocket: () => socket,
issueCommand: (roverId, payload) => {
if (!record.ws) throw new Error('Rover offline');
issued.push({ roverId, ...payload });
return 'cmd-1';
},
homeAssistantService,
isFeatureEnabled: () => featureEnabled,
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
cooldowns: createCooldownGate(),
logger: { warn: () => {} },
config: { commands: { prefix: 'rs' } },
});
return { handlers, issued, record, rovers };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
test('honk starts the horn and schedules a stop', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { handlers, issued } = createHarness();
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /HONK/);
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start']);
// The stop is deferred, so nothing has released the horn yet.
t.mock.timers.tick(1000);
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start', 'stop']);
});
test('honk is refused without drive control', async () => {
const { handlers, issued } = createHarness({ canDrive: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /need control of Roomba One/);
assert.equal(issued.length, 0);
});
test('honk is refused from a transport with no socket, so Discord cannot drive hardware', async () => {
const { handlers, issued } = createHarness({ socket: null });
const msg = message({ id: '4242', label: 'DiscordUser' });
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /only works from site chat/);
assert.equal(issued.length, 0);
});
test('honk is refused on a rover with no horn fitted', async () => {
const { handlers, issued } = createHarness({ hornEnabled: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /no horn fitted/);
assert.equal(issued.length, 0);
});
test('a second driver cannot bypass the rover cooldown with their own fresh actor window', async () => {
const { handlers, issued } = createHarness();
await handlers.honk(message(), []);
const other = message({ id: 's2', userId: 'u-bob', label: 'bob' });
await handlers.honk(other, []);
assert.match(other.replies[0].content, /was just honked/);
// Only the first honk reached the rover.
assert.equal(issued.filter((entry) => entry.horn?.action === 'start').length, 1);
});
test('an offline rover reports offline instead of claiming a honk happened', async () => {
const { handlers } = createHarness({ online: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /is offline/);
});
test('spin clamps to the rover wheel speed ceiling and always stops itself', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { handlers, issued } = createHarness({ maxWheelSpeed: 50 });
const msg = message();
await handlers.spin(msg, []);
assert.equal(issued[0].driveDirect.left, 50);
assert.equal(issued[0].driveDirect.right, -50);
t.mock.timers.tick(2000);
assert.deepEqual(issued[1].driveDirect, { left: 0, right: 0 });
});
test('spin honours a private rover safety override rather than bypassing it', async () => {
const { handlers, issued } = createHarness({ privateSafetyDrive: { left: 20, right: -20 } });
await handlers.spin(message(), []);
assert.deepEqual(issued[0].driveDirect, { left: 20, right: -20 });
});
test('spin is refused without drive control', async () => {
const { handlers, issued } = createHarness({ canDrive: false });
const msg = message();
await handlers.spin(msg, []);
assert.match(msg.replies[0].content, /need control/);
assert.equal(issued.length, 0);
});
test('boo speaks a canned taunt rather than any caller supplied text', async () => {
const { handlers, issued } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.equal(issued.length, 1);
assert.equal(issued[0].type, 'tts');
// The spoken text must not contain anything the caller typed.
assert.doesNotMatch(issued[0].tts.text, /bob/i);
assert.ok(issued[0].tts.text.length > 0);
});
test('boo is refused when the target is not driving anything', async () => {
const { handlers, issued } = createHarness({ activeDrivers: {} });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.match(msg.replies[0].content, /not driving anything/);
assert.equal(issued.length, 0);
});
test('boo is refused when the target is not online at all', async () => {
const { handlers, issued } = createHarness({ sockets: [ALICE] });
const msg = message();
await handlers.boo(msg, ['nobody-here']);
assert.match(msg.replies[0].content, /not here to be booed/);
assert.equal(issued.length, 0);
});
test('boo is refused on a rover that cannot speak', async () => {
const { handlers, issued } = createHarness({ ttsEnabled: false, activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.match(msg.replies[0].content, /cannot speak/);
assert.equal(issued.length, 0);
});
test('disco is unavailable when the Home Assistant feature is off', async () => {
const calls = [];
const { handlers } = createHarness({
featureEnabled: false,
homeAssistantService: {
getLightPolicyState: () => ({}),
setAllControllableEntitiesState: (state) => calls.push(state),
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /unavailable/);
assert.equal(calls.length, 0);
});
test('disco obeys the room light lock', async () => {
const calls = [];
const { handlers } = createHarness({
featureEnabled: true,
homeAssistantService: {
getLightPolicyState: () => ({ locked: true, lockState: 'on' }),
setAllControllableEntitiesState: (state) => calls.push(state),
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /locked/);
assert.equal(calls.length, 0);
});
test('disco strobes while unlocked and restores the lights on when it ends', async (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'] });
const calls = [];
const { handlers } = createHarness({
featureEnabled: true,
homeAssistantService: {
getLightPolicyState: () => ({ locked: false }),
setAllControllableEntitiesState: (state) => {
calls.push(state);
return Promise.resolve();
},
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /Disco/);
t.mock.timers.tick(3000);
assert.ok(calls.length >= 2, `expected several ticks, saw ${calls.length}`);
assert.ok(calls.includes('on') && calls.includes('off'));
// Past the end of the window the lights must be put back on and left alone.
t.mock.timers.tick(20 * 1000);
assert.equal(calls[calls.length - 1], 'on');
const settled = calls.length;
t.mock.timers.tick(20 * 1000);
assert.equal(calls.length, settled);
});
test('vibecheck reports the battery and never issues a command', async () => {
const { handlers, issued } = createHarness();
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /Roomba One/);
assert.match(msg.replies[0].content, /Battery 74%/);
assert.equal(issued.length, 0);
});
test('vibecheck leads with the real problem when the battery is urgent', async () => {
const { handlers, record } = createHarness();
record.batteryState = { percentDisplay: 4, warnActive: true, urgentActive: true };
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /dying/);
});
test('vibecheck reports an offline rover as offline', async () => {
const { handlers, record } = createHarness();
record.ws = null;
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /offline/);
});
test('describeBattery falls back through the available fields', () => {
assert.equal(describeBattery({ percentDisplay: 55.4 }), '55%');
assert.equal(describeBattery({ percent: 0.42 }), '42%');
assert.equal(describeBattery({}), 'unknown');
assert.equal(describeBattery(null), 'unknown');
});
@@ -0,0 +1,118 @@
// Operator Fun Stats Commands
// Purpose: Implements the fun commands that read or extend persistent counters (bonkboard, pet, snitch).
// Scope: Reads the roster and the fun stats store; issues no rover commands.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const { PLAIN_MENTIONS, actorLabel, buildActorKey, createRoverResolver } = require('./funHelpers');
const PET_COOLDOWN_MS = 10 * 1000;
const READ_COOLDOWN_MS = 5 * 1000;
const LEADERBOARD_SIZE = 10;
function formatLeaderboard(title, rows, counter) {
const ranked = rows
.filter((row) => Number(row[counter]) > 0)
.sort((left, right) => Number(right[counter]) - Number(left[counter]))
.slice(0, LEADERBOARD_SIZE);
if (!ranked.length) return null;
const lines = ranked.map((row, index) => `${index + 1}. ${row.label || 'unknown'}${row[counter]}`);
return [`**${title}**`, ...lines].join('\n');
}
function createFunStatsCommands({
io,
rovers,
getNickname,
getActiveDrivers,
getActorSocket,
roverManager,
sanitizeMentions,
funStatsService,
cooldowns,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
async function handleBonkboard(message) {
const gated = gate(message, 'bonkboard', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rows = funStatsService.listActorStats();
const sections = [
formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'),
formatLeaderboard('Most bonks taken', rows, 'bonksTaken'),
formatLeaderboard('Most hugs given', rows, 'hugsGiven'),
].filter(Boolean);
if (!sections.length) {
return reply(message, `Nobody has been bonked yet. Fix that with \`${commandPrefix} bonk <user>\`.`);
}
return reply(message, sections.join('\n\n').slice(0, 1900));
}
async function handlePet(message, tokens = []) {
const gated = gate(message, 'pet', PET_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'pet');
if (rover.error) return reply(message, rover.error);
const pets = funStatsService.bumpRoverPets(rover.id, 1);
return reply(message, `🤖 ${gated.label} pets ${rover.name}. It has now been petted ${pets} time${pets === 1 ? '' : 's'}.`);
}
/*
Reads the same active-driver map the turn system uses, so it reports real
control rather than who merely has the page open. Rovers with nobody driving
are listed too an empty fleet is exactly what a snitch should report.
*/
async function handleSnitch(message) {
const gated = gate(message, 'snitch', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const drivers = getActiveDrivers?.() || {};
const sockets = io?.sockets?.sockets;
const lines = [];
rovers.forEach((record, roverId) => {
const id = String(roverId);
const name = record?.meta?.name || id;
const socketId = drivers[id];
const socket = socketId && sockets?.get ? sockets.get(socketId) : null;
const nickname = socket ? getNickname?.(socket) : null;
if (nickname) {
lines.push(`${name}${nickname}`);
} else if (socketId) {
lines.push(`${name} — someone who will not say their name`);
} else {
lines.push(`${name} — nobody`);
}
});
if (!lines.length) return reply(message, 'No rovers are online to snitch about.');
return reply(message, ['🕵️ Currently driving:', ...lines].join('\n').slice(0, 1900));
}
return {
bonkboard: handleBonkboard,
pet: handlePet,
snitch: handleSnitch,
};
}
module.exports = { createFunStatsCommands, formatLeaderboard };
@@ -0,0 +1,166 @@
// Operator Fun Stats Command Tests
// Purpose: Verifies the leaderboard ordering, rover pet counting, and what snitch reports.
// Scope: In-memory stats and roster doubles only.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunStatsCommands, formatLeaderboard } = require('./funStats');
const { createCooldownGate } = require('../cooldowns');
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
function createHarness({
actorRows = [],
activeDrivers = {},
socket = ALICE,
rovers = new Map([
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
]),
} = {}) {
const pets = new Map();
const handlers = createFunStatsCommands({
io: { sockets: { sockets: new Map([[ALICE.id, ALICE], [BOB.id, BOB]]) } },
rovers,
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
getActorSocket: () => socket,
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
funStatsService: {
listActorStats: () => actorRows,
bumpRoverPets: (roverId, by) => {
const next = (pets.get(roverId) || 0) + by;
pets.set(roverId, next);
return next;
},
},
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
return { handlers, pets };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
test('the leaderboard sorts descending and drops zero scores', () => {
const rows = [
{ label: 'alice', bonksGiven: 2 },
{ label: 'bob', bonksGiven: 9 },
{ label: 'carol', bonksGiven: 0 },
];
const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven');
const lines = rendered.split('\n');
assert.equal(lines[1], '1. bob — 9');
assert.equal(lines[2], '2. alice — 2');
assert.equal(lines.length, 3, 'carol should not appear with a zero score');
});
test('the leaderboard is capped at ten entries', () => {
const rows = Array.from({ length: 25 }, (_, index) => ({ label: `user${index}`, bonksGiven: index + 1 }));
const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven');
assert.equal(rendered.split('\n').length - 1, 10);
});
test('an all-zero counter renders no section at all', () => {
assert.equal(formatLeaderboard('Most bonks dealt', [{ label: 'alice', bonksGiven: 0 }], 'bonksGiven'), null);
});
test('bonkboard says so when nothing has happened yet', async () => {
const { handlers } = createHarness({ actorRows: [] });
const msg = message();
await handlers.bonkboard(msg, []);
assert.match(msg.replies[0].content, /Nobody has been bonked yet/);
});
test('bonkboard renders each populated section', async () => {
const { handlers } = createHarness({
actorRows: [
{ label: 'alice', bonksGiven: 3, bonksTaken: 0, hugsGiven: 1 },
{ label: 'bob', bonksGiven: 0, bonksTaken: 3, hugsGiven: 0 },
],
});
const msg = message();
await handlers.bonkboard(msg, []);
assert.match(msg.replies[0].content, /Most bonks dealt/);
assert.match(msg.replies[0].content, /Most bonks taken/);
assert.match(msg.replies[0].content, /Most hugs given/);
});
test('bonkboard sanitizes stored labels, so a hostile nickname cannot ping a guild', async () => {
const { handlers } = createHarness({ actorRows: [{ label: '@everyone', bonksGiven: 1 }] });
const msg = message();
await handlers.bonkboard(msg, []);
assert.doesNotMatch(msg.replies[0].content, /@everyone/);
});
test('pet counts against the rover the caller is on when none is named', async () => {
const { handlers, pets } = createHarness();
const msg = message();
await handlers.pet(msg, []);
assert.match(msg.replies[0].content, /pets Roomba One/);
assert.match(msg.replies[0].content, /petted 1 time\./);
assert.equal(pets.get('rover-1'), 1);
});
test('pet accepts an explicit rover and keeps a separate count per rover', async () => {
const { handlers, pets } = createHarness();
await handlers.pet(message(), ['Roomba Two']);
await handlers.pet(message({ id: 's2', userId: 'u-bob', label: 'bob' }), ['Roomba Two']);
assert.equal(pets.get('rover-2'), 2);
assert.equal(pets.get('rover-1'), undefined);
});
test('pet pluralizes the running total', async () => {
const { handlers } = createHarness();
await handlers.pet(message(), []);
const second = message({ id: 's2', userId: 'u-bob', label: 'bob' });
await handlers.pet(second, []);
assert.match(second.replies[0].content, /petted 2 times\./);
});
test('pet from a transport with no socket asks for a rover name', async () => {
const { handlers, pets } = createHarness({ socket: null });
const msg = message({ id: '4242', label: 'DiscordUser' });
await handlers.pet(msg, []);
assert.match(msg.replies[0].content, /Name a rover/);
assert.equal(pets.size, 0);
});
test('snitch names the active driver and reports idle rovers as nobody', async () => {
const { handlers } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /Roomba One — bob/);
assert.match(msg.replies[0].content, /Roomba Two — nobody/);
});
test('snitch handles a driver socket that has already gone away', async () => {
const { handlers } = createHarness({ activeDrivers: { 'rover-1': 'ghost-socket' } });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /Roomba One — someone who will not say their name/);
});
test('snitch reports an empty fleet rather than an empty message', async () => {
const { handlers } = createHarness({ rovers: new Map() });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /No rovers are online/);
});
@@ -0,0 +1,361 @@
// Operator Fun Text Commands
// Purpose: Implements the social, text-only `rs` commands (bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted).
// Scope: Text and counters only; nothing here touches rover hardware.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const {
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
clampEcho,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
} = require('./funHelpers');
const TEXT_COOLDOWN_MS = 4 * 1000;
/*
The bonk sound gets its own, much longer rover-scoped window. Playing it
interrupts whatever that rover is forwarding including a live microphone so
the audio must not be spammable even though the text bonk stays snappy, and a
group of people bonking one driver cannot chain it either.
*/
const BONK_SOUND_ROVER_COOLDOWN_MS = 20 * 1000;
const SLAP_ITEMS = [
'a large trout', 'a rolled-up service manual', 'a dead AA battery', 'a docking station',
'a suspiciously warm power brick', 'half a roll of duct tape', 'a decommissioned brush guard',
'a bag of loose screws', 'an unlabelled USB cable', 'a soggy floor sensor',
'the heaviest available wrench', 'a stack of unread pull requests',
];
const EIGHT_BALL_ANSWERS = [
'Yes.', 'No.', 'Absolutely.', 'Absolutely not.', 'Ask again once the battery is charged.',
'Signs point to the docking station.', 'The overseer says no.', 'Almost certainly.',
'Not while anyone is watching.', 'Outlook cloudy, sensors dirty.', 'Try it and find out.',
'That is a maintenance window problem.', 'Only on a Tuesday.', 'The rover has already decided.',
];
const HUG_FLAVOURS = [
'gently', 'aggressively', 'with both brush guards', 'at full wheel speed',
'for slightly too long', 'while beeping softly', 'without asking first',
];
const WANTED_CRIMES = [
'reckless docking', 'driving with the brush on indoors', 'excessive honking',
'unauthorised carpet donuts', 'battery hoarding', 'ignoring the global objective',
'parking in the doorway', 'nine consecutive turn skips', 'talking to the Neato',
'strobing the room lights for fun', 'stealing another rover\'s charger',
];
const RATE_SUFFIXES = [
'No further questions.', 'I stand by this.', 'Do not appeal.', 'Take it or leave it.',
'The sensors agree.', 'This rating is final.',
];
/*
A dice roll is one of the few places a fun command should be genuinely random:
the whole point is that nobody can predict it. Everything that passes judgement
on a thing (`ship`, `rate`, `8ball`, `wanted`) is seeded from the input instead,
so re-running it cannot reroll a verdict somebody disliked.
*/
function rollDice(count, sides) {
let total = 0;
const rolls = [];
for (let index = 0; index < count; index += 1) {
const value = 1 + Math.floor(Math.random() * sides);
rolls.push(value);
total += value;
}
return { rolls, total };
}
function parseDiceSpec(spec) {
const text = String(spec || '').trim().toLowerCase() || '1d6';
const match = /^(\d*)d(\d+)$/.exec(text);
if (!match) {
// A bare number is read as a single die with that many sides so `rs roll 20`
// does the obvious thing instead of erroring.
const bare = /^(\d+)$/.exec(text);
if (!bare) return { error: 'Roll format is `NdN`, for example `2d6`.' };
const sides = Number(bare[1]);
if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' };
return { count: 1, sides };
}
const count = match[1] === '' ? 1 : Number(match[1]);
const sides = Number(match[2]);
if (count < 1 || count > 20) return { error: 'Roll between 1 and 20 dice.' };
if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' };
return { count, sides };
}
function uwuify(text) {
return String(text || '')
.replace(/[rl]/g, 'w')
.replace(/[RL]/g, 'W')
.replace(/n([aeiou])/g, 'ny$1')
.replace(/N([aeiou])/g, 'Ny$1')
.replace(/ove/g, 'uv')
.replace(/!+/g, ' !!');
}
function createFunTextCommands({
io,
getNickname,
getActiveDrivers,
publishEvent,
sanitizeMentions,
funStatsService,
cooldowns,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
/*
Announces the bonk so audioForwardService can play the sound on the rover the
target is driving. Published as an event rather than calling the audio pipeline
directly, matching how the charging-complete cue is wired: the command layer
stays unaware of ffmpeg, and a server without the sound installed simply has
nothing listening that can do anything.
*/
function announceBonk(targetSocket, targetLabel, actorLabelText) {
if (!targetSocket || typeof publishEvent !== 'function') return;
const drivers = getActiveDrivers?.() || {};
const roverId = Object.keys(drivers).find((id) => drivers[id] === targetSocket.id) || null;
if (!roverId) return;
if (cooldowns.consume(`bonk:sound:${roverId}`, BONK_SOUND_ROVER_COOLDOWN_MS) > 0) return;
publishEvent({
source: 'funCommands',
type: 'fun.bonked',
payload: { roverId, targetLabel, actor: actorLabelText },
});
}
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
/*
Every fun command runs through one gate so the cooldown, the actor identity,
and the "who am I talking about" resolution cannot drift apart between
commands. `needsTarget` commands reply with their own usage line when the
selector is missing rather than silently acting on nothing.
*/
function gate(message, action, { windowMs = TEXT_COOLDOWN_MS } = {}) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
function target(selector) {
return resolveFunTarget({ io, getNickname, selector });
}
/*
Shared shape for the three "do a thing to someone" commands. Only the verb,
the counter names, and the flavour text differ, and keeping them in one place
means a fix to self-targeting or tally credit applies to all of them.
*/
function createInteraction({ action, counterGiven, counterTaken, selfReply, render, onApplied }) {
return async function handleInteraction(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) {
return reply(message, `Usage: \`${commandPrefix} ${action} <user>\``);
}
const gated = gate(message, action);
if (gated.error) return reply(message, gated.error);
const resolved = target(selector);
if (!resolved) return reply(message, `Usage: \`${commandPrefix} ${action} <user>\``);
if (resolved.actorKey && resolved.actorKey === gated.actorKey) {
return reply(message, selfReply(gated.label));
}
funStatsService.bumpActorStats(gated.actorKey, { label: gated.label, [counterGiven]: 1 });
const targetStats = resolved.actorKey
? funStatsService.bumpActorStats(resolved.actorKey, { label: resolved.label, [counterTaken]: 1 })
: null;
// Side effects run after the tallies so a failure in an optional extra (the
// bonk sound) cannot cost the user their recorded bonk.
onApplied?.({ actor: gated.label, resolved });
return reply(message, render({
actor: gated.label,
actorKey: gated.actorKey,
targetLabel: resolved.label,
targetOnline: resolved.online,
takenCount: targetStats ? targetStats[counterTaken] : null,
}));
};
}
const handleBonk = createInteraction({
action: 'bonk',
counterGiven: 'bonksGiven',
counterTaken: 'bonksTaken',
selfReply: (label) => `${label} bonked themselves. That is between you and the rover.`,
onApplied: ({ actor, resolved }) => announceBonk(resolved.socket, resolved.label, actor),
render: ({ targetLabel, takenCount }) => {
const tally = takenCount ? ` That is their ${ordinal(takenCount)} bonk.` : '';
return `🔨 Bonked ${targetLabel}.${tally}`;
},
});
const handleHug = createInteraction({
action: 'hug',
counterGiven: 'hugsGiven',
counterTaken: 'hugsTaken',
selfReply: (label) => `${label} hugged themselves. Genuinely fine. No notes.`,
render: ({ actor, targetLabel, takenCount }) => {
const flavour = pickBySeed(HUG_FLAVOURS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
const tally = takenCount ? ` (${takenCount} total)` : '';
return `🫂 ${actor} hugs ${targetLabel} ${flavour}.${tally}`;
},
});
const handleSlap = createInteraction({
action: 'slap',
counterGiven: 'slapsGiven',
counterTaken: 'slapsTaken',
selfReply: (label) => `${label} slapped themselves with a large trout. Bold.`,
render: ({ actor, targetLabel, takenCount }) => {
// Seeding on the running count means the weapon changes every time without
// being unpredictable for the same repeat number.
const item = pickBySeed(SLAP_ITEMS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
return `🐟 ${actor} slaps ${targetLabel} around a bit with ${item}.`;
},
});
async function handleEightBall(message, tokens = []) {
const question = clampEcho(tokens.join(' '));
if (!question) return reply(message, `Usage: \`${commandPrefix} 8ball <question>\``);
const gated = gate(message, '8ball');
if (gated.error) return reply(message, gated.error);
const answer = pickBySeed(EIGHT_BALL_ANSWERS, hashSeed(question));
return reply(message, `🎱 ${question}\n${answer}`);
}
async function handleRoll(message, tokens = []) {
const gated = gate(message, 'roll');
if (gated.error) return reply(message, gated.error);
const spec = parseDiceSpec(tokens.join(''));
if (spec.error) return reply(message, spec.error);
const { rolls, total } = rollDice(spec.count, spec.sides);
const detail = rolls.length > 1 ? ` (${rolls.join(' + ')})` : '';
return reply(message, `🎲 ${gated.label} rolled ${spec.count}d${spec.sides}: **${total}**${detail}`);
}
async function handleCoin(message) {
const gated = gate(message, 'coin');
if (gated.error) return reply(message, gated.error);
const side = Math.random() < 0.5 ? 'Heads' : 'Tails';
return reply(message, `🪙 ${side}.`);
}
async function handleShip(message, tokens = []) {
const parts = tokens.join(' ').split(/\s+(?:and|\+|&)\s+|\s*,\s*/i).map((part) => clampEcho(part)).filter(Boolean);
const [left, right] = parts.length >= 2 ? parts : [parts[0], null];
if (!left || !right) {
return reply(message, `Usage: \`${commandPrefix} ship <a> and <b>\``);
}
const gated = gate(message, 'ship');
if (gated.error) return reply(message, gated.error);
const score = percentFromSeed(pairSeed(left, right));
const verdict = score >= 90 ? 'Get them a shared charging dock.'
: score >= 65 ? 'Promising.'
: score >= 35 ? 'Needs work.'
: score >= 10 ? 'The sensors are not hopeful.'
: 'Absolutely not.';
return reply(message, `💞 ${left} + ${right} = **${score}%**. ${verdict}`);
}
async function handleRate(message, tokens = []) {
const thing = clampEcho(tokens.join(' '));
if (!thing) return reply(message, `Usage: \`${commandPrefix} rate <thing>\``);
const gated = gate(message, 'rate');
if (gated.error) return reply(message, gated.error);
const seed = hashSeed(thing);
const score = seed % 11;
const suffix = pickBySeed(RATE_SUFFIXES, seed);
return reply(message, `📊 I rate ${thing} **${score}/10**. ${suffix}`);
}
async function handleUwu(message, tokens = []) {
const text = clampEcho(tokens.join(' '));
if (!text) return reply(message, `Usage: \`${commandPrefix} uwu <text>\``);
const gated = gate(message, 'uwu');
if (gated.error) return reply(message, gated.error);
return reply(message, uwuify(text));
}
async function handleWanted(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} wanted <user>\``);
const gated = gate(message, 'wanted');
if (gated.error) return reply(message, gated.error);
const resolved = target(selector);
if (!resolved) return reply(message, `Usage: \`${commandPrefix} wanted <user>\``);
const seed = hashSeed(resolved.label);
const crime = pickBySeed(WANTED_CRIMES, seed);
// Bounty is seeded so a given name always carries the same price. Somebody
// being permanently worth 12 credits is funnier than a fresh number each time.
const bounty = 25 + (seed % 4776);
const stats = resolved.actorKey ? funStatsService.getActorStats(resolved.actorKey) : null;
const priors = stats && stats.bonksTaken ? `\nPrior bonks on record: ${stats.bonksTaken}.` : '';
return reply(message, [
'```',
' WANTED',
` ${resolved.label}`,
` for ${crime}`,
` reward: ${bounty} credits`,
'```',
].join('\n') + priors);
}
return {
bonk: handleBonk,
hug: handleHug,
slap: handleSlap,
'8ball': handleEightBall,
roll: handleRoll,
coin: handleCoin,
ship: handleShip,
rate: handleRate,
uwu: handleUwu,
wanted: handleWanted,
};
}
module.exports = {
createFunTextCommands,
// Exported for tests: the parsing and transform rules are the parts most
// likely to regress, and they are pure.
parseDiceSpec,
uwuify,
};
@@ -0,0 +1,304 @@
// Operator Fun Text Command Tests
// Purpose: Verifies tally credit, self-targeting, cooldown refusal, mention sanitizing, and dice parsing.
// Scope: Uses an in-memory stats double so no test touches the fun stats file.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunTextCommands, parseDiceSpec, uwuify } = require('./funText');
const { createCooldownGate } = require('../cooldowns');
function createStatsDouble() {
const store = new Map();
return {
calls: [],
bumpActorStats(actorKey, { label = null, ...patch } = {}) {
this.calls.push({ actorKey, label, patch });
const current = store.get(actorKey) || {};
const next = { ...current, label: label || current.label };
Object.keys(patch).forEach((key) => {
next[key] = (Number(current[key]) || 0) + Number(patch[key] || 0);
});
store.set(actorKey, next);
return next;
},
getActorStats(actorKey) {
return store.get(actorKey) || {};
},
listActorStats() {
return Array.from(store.entries()).map(([actorKey, value]) => ({ actorKey, ...value }));
},
};
}
function createHarness({ sockets = [], activeDrivers = {} } = {}) {
const stats = createStatsDouble();
const events = [];
const handlers = createFunTextCommands({
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
publishEvent: (event) => events.push(event),
// Matches the real sanitizer so tests exercise the actual escaping rules.
sanitizeMentions: (text) => String(text || '')
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
.replace(/@everyone/gi, '[everyone]')
.replace(/@here/gi, '[here]'),
funStatsService: stats,
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
return { handlers, stats, events };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
const bob = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
test('bonk credits both sides and reports the running tally', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message();
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Bonked bob\./);
assert.match(msg.replies[0].content, /1st bonk/);
assert.deepEqual(
stats.calls.map((call) => [call.actorKey, Object.keys(call.patch)[0]]),
[['user:u-alice', 'bonksGiven'], ['user:u-bob', 'bonksTaken']],
);
});
test('the tally ordinal advances across repeat bonks', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
// A second actor avoids the first actor's cooldown while still hitting bob.
const second = message({ id: 's3', userId: 'u-carol', label: 'carol' });
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /2nd bonk/);
});
test('bonking an offline name still replies but credits nobody', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message();
await handlers.bonk(msg, ['the', 'dishwasher']);
assert.match(msg.replies[0].content, /Bonked the dishwasher\./);
assert.doesNotMatch(msg.replies[0].content, /bonk\b.*\dst|\dnd|\drd|\dth/);
assert.deepEqual(stats.calls.map((call) => call.actorKey), ['user:u-alice']);
});
test('self-bonking is a special case and records nothing', async () => {
const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const { handlers, stats } = createHarness({ sockets: [alice] });
const msg = message();
await handlers.bonk(msg, ['alice']);
assert.match(msg.replies[0].content, /themselves/);
assert.equal(stats.calls.length, 0);
});
test('a repeat inside the cooldown window is refused and records nothing extra', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
const countAfterFirst = stats.calls.length;
const second = message();
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /Slow down/);
assert.equal(stats.calls.length, countAfterFirst);
});
test('cooldowns are per command, so a bonk does not block a hug', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
const hug = message();
await handlers.hug(hug, ['bob']);
assert.doesNotMatch(hug.replies[0].content, /Slow down/);
});
test('a missing target replies with usage and does not burn the cooldown', async () => {
const { handlers } = createHarness({ sockets: [bob] });
const first = message();
await handlers.bonk(first, []);
assert.match(first.replies[0].content, /Usage: `rs bonk <user>`/);
const second = message();
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /Bonked bob/);
});
test('every reply is sanitized so a fun command cannot ping a whole guild', async () => {
const { handlers } = createHarness();
const msg = message();
await handlers.bonk(msg, ['@everyone']);
assert.doesNotMatch(msg.replies[0].content, /@everyone/);
assert.match(msg.replies[0].content, /\[everyone\]/);
const roleMsg = message({ id: 's9', userId: 'u-dave', label: 'dave' });
await handlers.slap(roleMsg, ['<@&123456>']);
assert.match(roleMsg.replies[0].content, /\[ping removed\]/);
});
test('an actor with no identity at all is refused rather than sharing a tally', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message({ label: 'ghost' });
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Could not identify you/);
assert.equal(stats.calls.length, 0);
});
test('ship agrees with itself regardless of argument order', async () => {
const { handlers } = createHarness();
const forward = message();
await handlers.ship(forward, ['alice', 'and', 'bob']);
const { handlers: other } = createHarness();
const backward = message();
await other.ship(backward, ['bob', 'and', 'alice']);
const score = (text) => /\*\*(\d+)%\*\*/.exec(text)[1];
assert.equal(score(forward.replies[0].content), score(backward.replies[0].content));
});
test('ship needs two sides', async () => {
const { handlers } = createHarness();
const msg = message();
await handlers.ship(msg, ['alice']);
assert.match(msg.replies[0].content, /Usage: `rs ship/);
});
test('8ball gives the same answer to the same question', async () => {
const first = createHarness();
const a = message();
await first.handlers['8ball'](a, ['will', 'it', 'dock']);
const second = createHarness();
const b = message();
await second.handlers['8ball'](b, ['WILL', 'IT', 'DOCK']);
assert.equal(a.replies[0].content.split('\n')[1], b.replies[0].content.split('\n')[1]);
});
test('rate stays inside 0 to 10', async () => {
for (const thing of ['carpet', 'the dock', 'a', 'zzzzzz', 'rover 3']) {
const { handlers } = createHarness();
const msg = message();
await handlers.rate(msg, [thing]);
const score = Number(/\*\*(\d+)\/10\*\*/.exec(msg.replies[0].content)[1]);
assert.ok(score >= 0 && score <= 10, `${thing} scored ${score}`);
}
});
test('dice specs parse the accepted forms and reject the rest', () => {
assert.deepEqual(parseDiceSpec('2d6'), { count: 2, sides: 6 });
assert.deepEqual(parseDiceSpec('d20'), { count: 1, sides: 20 });
assert.deepEqual(parseDiceSpec(''), { count: 1, sides: 6 });
// A bare number is read as one die of that many sides.
assert.deepEqual(parseDiceSpec('20'), { count: 1, sides: 20 });
assert.match(parseDiceSpec('21d6').error, /between 1 and 20 dice/);
assert.match(parseDiceSpec('1d1').error, /2 and 1000 sides/);
assert.match(parseDiceSpec('1d2000').error, /2 and 1000 sides/);
assert.match(parseDiceSpec('banana').error, /NdN/);
});
test('roll totals stay within the possible range for the spec', async () => {
for (let attempt = 0; attempt < 25; attempt += 1) {
const { handlers } = createHarness();
const msg = message();
await handlers.roll(msg, ['3d6']);
const total = Number(/\*\*(\d+)\*\*/.exec(msg.replies[0].content)[1]);
assert.ok(total >= 3 && total <= 18, `rolled ${total}`);
}
});
test('uwu transforms text without dropping it', () => {
assert.equal(uwuify('hello world'), 'hewwo wowwd');
assert.equal(uwuify('love'), 'wuv');
assert.equal(uwuify('nice'), 'nyice');
});
test('bonking someone who is driving announces the sound for their rover', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['bob']);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'fun.bonked');
assert.equal(events[0].payload.roverId, 'rover-1');
assert.equal(events[0].payload.targetLabel, 'bob');
});
test('bonking someone who is not driving announces nothing', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: {} });
const msg = message();
await handlers.bonk(msg, ['bob']);
// The text bonk still lands and is still tallied; only the sound is skipped.
assert.match(msg.replies[0].content, /Bonked bob/);
assert.equal(events.length, 0);
});
test('bonking a name that is not a real user announces nothing', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['the dishwasher']);
assert.equal(events.length, 0);
});
test('the bonk sound is rate limited per rover so it cannot interrupt a mic repeatedly', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['bob']);
// A different actor has their own text cooldown but must not get a second sound.
await handlers.bonk(message({ id: 's3', userId: 'u-carol', label: 'carol' }), ['bob']);
await handlers.bonk(message({ id: 's4', userId: 'u-erin', label: 'erin' }), ['bob']);
assert.equal(events.length, 1);
});
test('a self-bonk never announces a sound', async () => {
const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const { handlers, events } = createHarness({ sockets: [alice], activeDrivers: { 'rover-1': 's1' } });
await handlers.bonk(message(), ['alice']);
assert.equal(events.length, 0);
});
test('hug and slap do not announce a bonk sound', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.hug(message(), ['bob']);
await handlers.slap(message(), ['bob']);
assert.equal(events.length, 0);
});
test('a transport with no publishEvent still bonks normally', async () => {
const stats = createStatsDouble();
const handlers = createFunTextCommands({
io: { sockets: { sockets: new Map([[bob.id, bob]]) } },
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => ({ 'rover-1': 's2' }),
publishEvent: undefined,
sanitizeMentions: (text) => String(text || ''),
funStatsService: stats,
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
const msg = message();
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Bonked bob/);
});
test('wanted includes prior bonks when the target has any on record', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
const msg = message({ id: 's4', userId: 'u-erin', label: 'erin' });
await handlers.wanted(msg, ['bob']);
assert.match(msg.replies[0].content, /WANTED/);
assert.match(msg.replies[0].content, /Prior bonks on record: 1/);
});
@@ -0,0 +1,180 @@
// Operator Gain Command
// Purpose: Handles the audio gain boost permission for VIPs.
// Scope: Supports list, grant, and revoke subcommands; resolution stays VIP-only.
const { mask, normalizeSearchText, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
/*
Every connected socket's canonical user id. One person can hold several sockets
across tabs, so this is a set of identities rather than a count of connections.
*/
function collectOnlineUserIds(io) {
const online = new Set();
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return online;
sockets.forEach((socket) => {
const userId = String(socket?.data?.userId || '').trim();
if (userId) online.add(userId);
});
return online;
}
/*
Candidates whose identity fields equal the selector outright. Nicknames are not
unique the same person re-verifying from a new browser produces a second
verified record with the same name so an exact nickname match can legitimately
return several records.
*/
function findExactMatches(selector, candidates) {
const needle = normalizeSearchText(selector);
if (!needle) return [];
return (Array.isArray(candidates) ? candidates : []).filter((record) => (
normalizeSearchText(record?.nickname) === needle
|| normalizeSearchText(record?.userId) === needle
|| normalizeSearchText(record?.id) === needle
|| normalizeSearchText(record?.cookieUserId) === needle
|| normalizeSearchText(record?.fingerprintId) === needle
));
}
function createGainCommand({
io,
listVerifiedUsers,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
sanitizeMentions,
config,
}) {
// Usage text comes from the same core prefix that both transports parse.
const { prefix: commandPrefix } = getCommandConfig(config);
const plain = { parse: [], repliedUser: false };
function usage(subcommand) {
return `Usage: \`${commandPrefix} gain ${subcommand} <nickname|userId|cookieUserId>\``;
}
/*
Duplicate nicknames used to make `gain grant <name>` unusable: the shared
resolver refuses on ambiguity, which is right for destructive commands like
deter and kick but wrong here. Granting a volume ceiling to the wrong one of
two accounts belonging to the same person is recoverable, so this command
picks one and says which.
The online account wins, because that is who the admin is reacting to. With
nobody online the first stored record is used. The shared fuzzy resolver still
handles the no-exact-match case so typo tolerance and its error text are
unchanged.
*/
function resolveBoostTarget(selector, candidates) {
const exact = findExactMatches(selector, candidates);
if (exact.length === 1) return { record: exact[0] };
if (exact.length > 1) {
const onlineUserIds = collectOnlineUserIds(io);
const onlineMatches = exact.filter((record) => {
const userId = String(record?.userId || '').trim();
return userId && onlineUserIds.has(userId);
});
if (onlineMatches.length) {
return { record: onlineMatches[0], duplicates: exact.length, picked: 'online' };
}
return { record: exact[0], duplicates: exact.length, picked: 'first' };
}
return resolveIdentitySelector(selector, candidates, { includeId: false });
}
function describePick(resolved) {
if (!resolved.duplicates) return '';
if (resolved.picked === 'online') {
return ` ${resolved.duplicates} accounts share that name; picked the one that is online.`;
}
return ` ${resolved.duplicates} accounts share that name and none are online; picked the first.`;
}
function helpText() {
return [
'**Audio gain boost**',
'Raises a user\'s volume ceiling past the global gains, still bounded by the hard caps.',
'',
`\`${commandPrefix} gain list\` — show everyone who holds the boost.`,
`\`${commandPrefix} gain grant <vip>\` — give the boost to a verified user.`,
`\`${commandPrefix} gain revoke <vip>\` — take the boost away.`,
`\`${commandPrefix} gain help\` — show this.`,
'',
'A user can be named by nickname, userId, or cookieUserId. Only verified (VIP)',
'users can be granted the boost. If several accounts share a nickname, the one',
'that is currently online is used.',
].join('\n');
}
/*
The boost is a VIP-only permission, so candidate matching runs against the
verified list rather than every known identity. A nickname that only belongs
to an unverified visitor therefore reports "not found" instead of resolving
to someone who cannot hold the flag anyway.
*/
async function applyBoost(message, tokens, enabled) {
const selector = tokens.join(' ').trim();
if (!selector) {
return message.reply({ content: usage(enabled ? 'grant' : 'revoke'), allowedMentions: plain });
}
const candidates = enabled ? listVerifiedUsers() : listAudioGainBoostUsers();
const resolved = resolveBoostTarget(selector, candidates);
if (resolved.error) {
return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: plain });
}
const target = resolved.record.userId || resolved.record.id || resolved.record.cookieUserId;
try {
const actor = message.actor?.id || null;
const user = enabled ? grantAudioGainBoost(target, actor) : revokeAudioGainBoost(target, actor);
const verb = enabled ? 'Granted' : 'Revoked';
return message.reply({
content: sanitizeMentions(`${verb} audio gain boost for ${user.nickname || 'unknown'} (${mask(user.cookieUserId)}).${describePick(resolved)}`),
allowedMentions: plain,
});
} catch (err) {
return message.reply({
content: sanitizeMentions(`Failed to update audio gain boost: ${err.message}`),
allowedMentions: plain,
});
}
}
return async function handleGainCommand(message, tokens) {
if (!message.actor?.isAdmin) {
await message.reply({ content: 'Only admins can manage audio gain boosts.', allowedMentions: plain });
return;
}
const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'help') {
return message.reply({ content: helpText(), allowedMentions: plain });
}
if (action === 'list') {
const users = listAudioGainBoostUsers();
if (!users.length) {
return message.reply({ content: 'No users hold an audio gain boost.', allowedMentions: plain });
}
const lines = users.map((entry, idx) => (
`${idx + 1}. ${entry.nickname || 'unknown'} | ${entry.userId || entry.id} | ${mask(entry.cookieUserId)}`
));
return message.reply({
content: sanitizeMentions(['Audio gain boost holders:', ...lines].join('\n').slice(0, 1900)),
allowedMentions: plain,
});
}
if (action === 'grant') return applyBoost(message, tokens, true);
if (action === 'revoke') return applyBoost(message, tokens, false);
return message.reply({
content: `Unknown gain command.\n${helpText()}`,
allowedMentions: plain,
});
};
}
module.exports = { createGainCommand };
@@ -0,0 +1,258 @@
// Operator Gain Command Tests
// Purpose: Verifies the audio gain boost command stays admin-only and VIP-only.
// Scope: Exercises command target resolution with in-memory identity doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createGainCommand } = require('./gain');
const VIPS = [
{ userId: 'usr-vip', nickname: 'Croissant', cookieUserId: 'cookie-croissant' },
{ userId: 'usr-other', nickname: 'Baguette', cookieUserId: 'cookie-baguette' },
];
// Two verified records sharing one nickname. This is the real shape behind the
// "matched multiple records" failure: one person re-verifying from a new browser
// produces a second record with the same name and a different cookie id.
const DUPLICATE_SAULS = [
{ userId: 'usr-saul-a', nickname: 'Saul', cookieUserId: 'cu_a28ffffffff33ab5c' },
{ userId: 'usr-saul-b', nickname: 'Saul', cookieUserId: 'cu_5a5ffffffffb6add3' },
];
function createSocketRegistry(onlineUserIds = []) {
const sockets = new Map();
onlineUserIds.forEach((userId, index) => {
// Two sockets per identity, so the resolver must dedupe rather than count
// connections.
sockets.set(`s${index}a`, { id: `s${index}a`, data: { userId } });
sockets.set(`s${index}b`, { id: `s${index}b`, data: { userId } });
});
return { sockets: { sockets } };
}
function createHarness({ verified = VIPS, boosted = [], isAdmin = true, online = [] } = {}) {
const calls = [];
const replies = [];
const handler = createGainCommand({
io: createSocketRegistry(online),
listVerifiedUsers: () => verified,
listAudioGainBoostUsers: () => boosted,
grantAudioGainBoost: (selector, actor) => {
calls.push({ action: 'grant', selector, actor });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
revokeAudioGainBoost: (selector, actor) => {
calls.push({ action: 'revoke', selector, actor });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
const message = {
actor: { id: 'admin', isAdmin },
reply: async (payload) => {
replies.push(payload);
return payload;
},
};
return { handler, message, calls, replies };
}
test('non-admins cannot manage the boost', async () => {
const { handler, message, calls, replies } = createHarness({ isAdmin: false });
await handler(message, ['grant', 'Croissant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /Only admins/);
});
test('grant resolves a VIP nickname to its stable user id', async () => {
const { handler, message, calls } = createHarness();
await handler(message, ['grant', 'croissant']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-vip', actor: 'admin' }]);
});
test('grant refuses a nickname that belongs to no VIP', async () => {
const { handler, message, calls, replies } = createHarness({ verified: [] });
await handler(message, ['grant', 'Stranger']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /not found/i);
});
test('revoke only matches users who currently hold the boost', async () => {
const { handler, message, calls, replies } = createHarness({ boosted: [] });
await handler(message, ['revoke', 'Croissant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /not found/i);
});
test('revoke resolves against the boosted list', async () => {
const { handler, message, calls } = createHarness({ boosted: [VIPS[0]] });
await handler(message, ['revoke', 'Croissant']);
assert.deepEqual(calls, [{ action: 'revoke', selector: 'usr-vip', actor: 'admin' }]);
});
test('grant without a target prints usage instead of acting', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['grant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /rs gain grant/);
});
test('list defaults when no subcommand is given', async () => {
const { handler, message, replies } = createHarness({ boosted: [VIPS[0]] });
await handler(message, []);
assert.match(replies[0].content, /Croissant/);
assert.match(replies[0].content, /usr-vip/);
});
test('list reports an empty holder set', async () => {
const { handler, message, replies } = createHarness({ boosted: [] });
await handler(message, ['list']);
assert.match(replies[0].content, /No users hold/);
});
test('duplicate nicknames resolve to the account that is online', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-saul-b'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-b', actor: 'admin' }]);
assert.doesNotMatch(replies[0].content, /matched multiple records/i);
assert.match(replies[0].content, /2 accounts share that name; picked the one that is online/);
});
test('duplicate nicknames fall back to the first record when nobody is online', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: [],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
assert.match(replies[0].content, /none are online; picked the first/);
});
test('an unrelated online user does not influence the pick', async () => {
const { handler, message, calls } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-somebody-else'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
});
test('several duplicates online pick one deterministically rather than refusing', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-saul-a', 'usr-saul-b'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
assert.match(replies[0].content, /picked the one that is online/);
});
test('revoke disambiguates the same way against the boosted list', async () => {
const { handler, message, calls } = createHarness({
boosted: DUPLICATE_SAULS,
online: ['usr-saul-b'],
});
await handler(message, ['revoke', 'Saul']);
assert.deepEqual(calls, [{ action: 'revoke', selector: 'usr-saul-b', actor: 'admin' }]);
});
test('a unique nickname reports no disambiguation note', async () => {
const { handler, message, replies } = createHarness();
await handler(message, ['grant', 'Croissant']);
assert.doesNotMatch(replies[0].content, /accounts share that name/);
});
test('an exact cookieUserId still selects one record out of a duplicate pair', async () => {
const { handler, message, calls } = createHarness({ verified: DUPLICATE_SAULS });
await handler(message, ['grant', 'cu_5a5ffffffffb6add3']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-b', actor: 'admin' }]);
});
test('a typo still resolves through the fuzzy matcher', async () => {
const { handler, message, calls } = createHarness();
await handler(message, ['grant', 'Croissnat']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-vip', actor: 'admin' }]);
});
test('help lists every subcommand', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['help']);
assert.deepEqual(calls, [], 'help must not change anything');
for (const fragment of ['rs gain list', 'rs gain grant <vip>', 'rs gain revoke <vip>', 'rs gain help']) {
assert.ok(replies[0].content.includes(fragment), `help should mention ${fragment}`);
}
});
test('an unknown subcommand falls back to the same help text', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['sideways']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /Unknown gain command/);
assert.ok(replies[0].content.includes('rs gain grant <vip>'));
});
test('help stays admin-only like the rest of the command', async () => {
const { handler, message, replies } = createHarness({ isAdmin: false });
await handler(message, ['help']);
assert.match(replies[0].content, /Only admins/);
});
test('a service rejection is surfaced instead of thrown', async () => {
const { handler, message, replies } = createHarness();
const failing = createGainCommand({
io: createSocketRegistry([]),
listVerifiedUsers: () => VIPS,
listAudioGainBoostUsers: () => [],
grantAudioGainBoost: () => {
throw new Error('Only verified VIPs can be granted an audio gain boost.');
},
revokeAudioGainBoost: () => null,
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
await failing(message, ['grant', 'Croissant']);
assert.match(replies[0].content, /Only verified VIPs/);
});
@@ -146,7 +146,7 @@ function resolveIdentitySelector(selector, records = [], options = {}) {
], ],
}); });
const results = fuse.search(query); const results = fuse.search(query);
if (!results.length) return { error: buildResultError('not_found', 'Selector', candidates) }; if (!results.length) return { error: buildResultError('not_found', 'User', candidates) };
const first = results[0]; const first = results[0];
const second = results[1]; const second = results[1];
@@ -0,0 +1,70 @@
// Operator Command Cooldowns
// Purpose: Rate limits individual commands per actor without coupling to a transport.
// Scope: In-memory only; a restart clears every cooldown by design.
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000;
/*
Fun commands are reachable from both site chat and Discord, and site chat's own
rate limit only bounds messages per socket rather than a specific command. A
per-actor, per-command gate is what stops one person turning `rs honk` into a
siren, and it is deliberately in-memory: a cooldown that survives a restart
would be a moderation feature, not a spam guard.
*/
function createCooldownGate({ sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS } = {}) {
const expiries = new Map();
let lastSweep = 0;
function sweep(now) {
if (now - lastSweep < sweepIntervalMs) return;
lastSweep = now;
expiries.forEach((expiresAt, key) => {
if (expiresAt <= now) expiries.delete(key);
});
}
function remaining(key, now = Date.now()) {
const expiresAt = expiries.get(String(key));
if (!expiresAt) return 0;
return Math.max(0, expiresAt - now);
}
/*
Returns the remaining wait when the gate is closed, or 0 after arming the
next window. Callers therefore treat any non-zero result as a refusal, and a
refused call never extends the existing window.
*/
function consume(key, windowMs, now = Date.now()) {
const normalizedKey = String(key || '').trim();
if (!normalizedKey) return 0;
const window = Number(windowMs);
if (!Number.isFinite(window) || window <= 0) return 0;
sweep(now);
const wait = remaining(normalizedKey, now);
if (wait > 0) return wait;
expiries.set(normalizedKey, now + window);
return 0;
}
function clear(key) {
expiries.delete(String(key || '').trim());
}
function reset() {
expiries.clear();
lastSweep = 0;
}
return { consume, remaining, clear, reset };
}
function describeWait(waitMs) {
const seconds = Math.ceil(Number(waitMs || 0) / 1000);
if (seconds <= 1) return '1s';
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
}
module.exports = { createCooldownGate, describeWait };
@@ -0,0 +1,53 @@
// Operator Command Cooldown Tests
// Purpose: Verifies the per-actor rate limit opens and closes on the boundaries callers rely on.
// Scope: Pure; the gate takes an injected clock so no test needs to sleep.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createCooldownGate, describeWait } = require('./cooldowns');
test('first use passes and an immediate repeat is refused', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('bonk:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 1000, 0), 1000);
assert.equal(gate.consume('bonk:alice', 1000, 400), 600);
});
test('the window reopens exactly when it expires', () => {
const gate = createCooldownGate();
gate.consume('honk:alice', 1000, 0);
assert.equal(gate.consume('honk:alice', 1000, 999), 1);
assert.equal(gate.consume('honk:alice', 1000, 1000), 0);
});
test('a refused call does not extend the existing window', () => {
const gate = createCooldownGate();
gate.consume('honk:alice', 1000, 0);
// Hammering the gate at t=500 must not push the reopen time out to t=1500.
gate.consume('honk:alice', 1000, 500);
gate.consume('honk:alice', 1000, 900);
assert.equal(gate.consume('honk:alice', 1000, 1000), 0);
});
test('cooldowns are scoped per key so different actors and commands do not collide', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('bonk:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:bob', 1000, 0), 0);
assert.equal(gate.consume('hug:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 1000, 0), 1000);
});
test('a missing key or non-positive window never gates', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 0, 0), 0);
assert.equal(gate.consume('bonk:alice', -5, 0), 0);
// None of the above should have armed anything.
assert.equal(gate.remaining('bonk:alice', 0), 0);
});
test('describeWait rounds up and switches to minutes', () => {
assert.equal(describeWait(1), '1s');
assert.equal(describeWait(4200), '5s');
assert.equal(describeWait(60 * 1000), '1m');
assert.equal(describeWait(95 * 1000), '1m 35s');
});
@@ -25,7 +25,7 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = ''
const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic; const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic;
const categoryNames = requestedCategory && CATEGORIES[requestedCategory] const categoryNames = requestedCategory && CATEGORIES[requestedCategory]
? [requestedCategory] ? [requestedCategory]
: ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])]; : ['system', 'admin', 'features', 'fun', ...(includeDiscord ? ['discord'] : [])];
const output = ['**Rover Bot Commands**']; const output = ['**Rover Bot Commands**'];
for (const categoryName of categoryNames) { for (const categoryName of categoryNames) {
@@ -8,13 +8,27 @@ const { createReasonCommand } = require('./commands/reason');
const { createGoalCommand } = require('./commands/goal'); const { createGoalCommand } = require('./commands/goal');
const { createVerifyCommand } = require('./commands/verify'); const { createVerifyCommand } = require('./commands/verify');
const { createDeterCommand } = require('./commands/deter'); const { createDeterCommand } = require('./commands/deter');
const { createGainCommand } = require('./commands/gain');
const { createLightsCommand } = require('./commands/lights'); const { createLightsCommand } = require('./commands/lights');
const { createKickCommand } = require('./commands/kick'); const { createKickCommand } = require('./commands/kick');
const { createLiftCommand } = require('./commands/lift'); const { createLiftCommand } = require('./commands/lift');
const { createNeatoCommand } = require('./commands/neato'); const { createNeatoCommand } = require('./commands/neato');
const { createFunTextCommands } = require('./commands/funText');
const { createFunStatsCommands } = require('./commands/funStats');
const { createFunRoverCommands } = require('./commands/funRover');
const { createCooldownGate } = require('./cooldowns');
const { getCommandConfig } = require('./config'); const { getCommandConfig } = require('./config');
const { buildCommandRegistry } = require('./registry'); const { buildCommandRegistry } = require('./registry');
/*
Commands that enforce their own permissions inside their handler rather than at
the dispatcher. `goal` and `reason` are readable by anyone but only writable by
an admin; `verify` and `deter` reject non-lockdown-admins themselves so they can
explain which role is missing. Listing them here preserves that behavior now
that the general non-admin gate is driven by registry metadata.
*/
const SELF_GATED_ACTIONS = new Set(['', 'status', 'help', 'replay', 'bridge', 'goal', 'reason', 'verify', 'deter']);
function createCommandHandlers(deps) { function createCommandHandlers(deps) {
const { const {
getMode, getMode,
@@ -47,6 +61,7 @@ function createCommandHandlers(deps) {
const handleGoalCommand = createGoalCommand(deps); const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps); const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps); const handleDeterCommand = createDeterCommand(deps);
const handleGainCommand = createGainCommand(deps);
const handleBridgeCommand = transportHandlers.bridge; const handleBridgeCommand = transportHandlers.bridge;
const handleTimeStatusCommand = transportHandlers.timeStatus; const handleTimeStatusCommand = transportHandlers.timeStatus;
const handleLightsCommand = createLightsCommand(deps); const handleLightsCommand = createLightsCommand(deps);
@@ -54,6 +69,20 @@ function createCommandHandlers(deps) {
const handleLiftCommand = createLiftCommand(deps); const handleLiftCommand = createLiftCommand(deps);
const handleNeatoCommand = createNeatoCommand(deps); const handleNeatoCommand = createNeatoCommand(deps);
/*
Fun commands share one cooldown gate. Web chat rebuilds this router per
message, so an injected gate is what makes the cooldowns actually shared
across a user's messages; a gate created here would be discarded every time
and rate limit nothing. Falling back to a local gate keeps the router usable
on its own (and in tests) without making every caller supply one.
*/
const cooldowns = deps.commandCooldowns || createCooldownGate();
const funHandlers = {
...createFunTextCommands({ ...deps, cooldowns }),
...createFunStatsCommands({ ...deps, cooldowns }),
...createFunRoverCommands({ ...deps, cooldowns }),
};
function stripCommandPrefix(content) { function stripCommandPrefix(content) {
const trimmed = String(content || '').trim(); const trimmed = String(content || '').trim();
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
@@ -97,8 +126,10 @@ function createCommandHandlers(deps) {
// is included because its lock/unlock subcommands change room policy. Its // is included because its lock/unlock subcommands change room policy. Its
// ordinary on/off/color actions are also intentionally restricted to a // ordinary on/off/color actions are also intentionally restricted to a
// lockdown admin while the entire server is in lockdown. // lockdown admin while the entire server is in lockdown.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']); const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'gain', 'lights', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode'; const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
const isPublicCommand = commandDefinition?.permission === 'public';
const isFunCommand = commandDefinition?.category === 'fun';
// Feature commands are public activities while access is open or managed // Feature commands are public activities while access is open or managed
// by turns. In admin mode they follow the same admin-only boundary as rover // by turns. In admin mode they follow the same admin-only boundary as rover
@@ -110,12 +141,17 @@ function createCommandHandlers(deps) {
return; return;
} }
if (!isAccessModeCommand && !isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') { if (!isAccessModeCommand && !isPublicCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) {
await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) { /*
Lockdown exists to make the server quiet and controlled, so the whole fun
category is suspended alongside the moderation-sensitive actions rather than
leaving a horn command reachable by anyone while the fleet is locked down.
*/
if (mode === MODES.LOCKDOWN && (moderationActions.has(action) || isFunCommand) && !isLockdownAdmin) {
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
@@ -153,7 +189,10 @@ function createCommandHandlers(deps) {
return handleVerifyCommand(request, tokens); return handleVerifyCommand(request, tokens);
case 'deter': case 'deter':
return handleDeterCommand(request, tokens); return handleDeterCommand(request, tokens);
case 'gain':
return handleGainCommand(request, tokens);
default: default:
if (funHandlers[action]) return funHandlers[action](request, tokens);
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled })); return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
} }
} }
@@ -0,0 +1,181 @@
// Operator Command Dispatcher Tests
// Purpose: Pins the permission, mode, and prefix policy that the registry-driven gate replaced a hardcoded action list with.
// Scope: Exercises the router with doubles; individual command behavior is covered by each command's own tests.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createCommandHandlers } = require('./index');
const MODES = { OPEN: 'open', TURNS: 'turns', ADMIN: 'admin', LOCKDOWN: 'lockdown' };
const ADMIN_DENIAL = /Only admins can run that command/;
const LOCKDOWN_DENIAL = /Lockdown mode: only lockdown admins/;
const FEATURE_DENIAL = /Admin mode: only admins can run feature commands/;
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
const rovers = new Map([['rover-1', {
id: 'rover-1',
ws: {},
meta: { name: 'Roomba One', horn: { enabled: true } },
batteryState: { percentDisplay: 50 },
}]]);
const { handleCommand } = createCommandHandlers({
logger: { warn: () => {}, info: () => {} },
io: { sockets: { sockets: new Map([[ALICE.id, ALICE]]) } },
rovers,
roverManager: {
canDrive: () => true,
getPrimaryRoverForSocket: () => 'rover-1',
applyPrivateDriveSafety: () => null,
},
getMode: () => mode,
MODES,
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => ({}),
getActorSocket: () => ALICE,
issueCommand: () => 'cmd-1',
isFeatureEnabled: () => featureEnabled,
sanitizeMentions: (text) => String(text || ''),
funStatsService: {
bumpActorStats: () => ({}),
getActorStats: () => ({}),
listActorStats: () => [],
bumpRoverPets: () => 1,
getRoverPets: () => 0,
},
homeAssistantService: { getLightPolicyState: () => ({}), setAllControllableEntitiesState: () => Promise.resolve() },
liftService: null,
neatoService: null,
listVerifiedUsers: () => [],
listDeterredUsers: () => [],
listMutedUsers: () => [],
getGlobalObjective: () => null,
getAdminReason: () => null,
config: { commands: { prefix: 'rs' } },
transportHandlers: {
status: async (request) => request.reply({ content: '[status handler]' }),
bridge: async (request) => request.reply({ content: '[bridge handler]' }),
},
});
return async function run(text, actor) {
const replies = [];
await handleCommand({
content: text,
transport: 'web-chat',
actor,
reply: async (payload) => {
replies.push(typeof payload === 'string' ? payload : payload?.content);
},
});
return replies.join('\n');
};
}
const nonAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: false, isLockdownAdmin: false };
const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: false };
const lockdownAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: true };
test('a non-admin can run fun commands', async () => {
const run = createRouter();
for (const command of ['rs coin', 'rs rate carpet', 'rs uwu hi', 'rs bonkboard', 'rs vibecheck', 'rs snitch']) {
const reply = await run(command, nonAdmin);
assert.doesNotMatch(reply, ADMIN_DENIAL, `${command} should be public`);
assert.ok(reply.length > 0, `${command} should reply`);
}
});
test('admin-only commands stay admin-only for a non-admin', async () => {
const run = createRouter();
for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs kick alice']) {
assert.match(await run(command, nonAdmin), ADMIN_DENIAL, `${command} must stay admin-only`);
}
});
test('commands that police themselves still reach their handler as a non-admin', async () => {
const run = createRouter();
// These reply with their own role-specific message, so the dispatcher must not
// short-circuit them with the generic admin denial.
for (const command of ['rs goal', 'rs reason', 'rs verify list', 'rs deter list']) {
assert.doesNotMatch(await run(command, nonAdmin), ADMIN_DENIAL, `${command} enforces its own permission`);
}
});
test('system commands remain reachable by anyone', async () => {
const run = createRouter();
assert.match(await run('rs status', nonAdmin), /\[status handler\]/);
assert.match(await run('rs', nonAdmin), /\[status handler\]/);
assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/);
});
test('the fun category appears in help', async () => {
const run = createRouter();
const help = await run('rs help fun', nonAdmin);
assert.match(help, /\*\*Fun\*\*/);
for (const command of ['bonk', 'honk', 'disco', 'vibecheck', 'bonkboard']) {
assert.match(help, new RegExp(`rs ${command}`), `help should list ${command}`);
}
});
test('admin mode restricts access-mode feature commands but not the public fun ones', async () => {
const run = createRouter({ mode: MODES.ADMIN });
assert.match(await run('rs lights on', nonAdmin), FEATURE_DENIAL);
assert.match(await run('rs disco', nonAdmin), FEATURE_DENIAL);
assert.doesNotMatch(await run('rs coin', nonAdmin), FEATURE_DENIAL);
});
test('lockdown suspends the whole fun category for anyone but a lockdown admin', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
for (const command of ['rs coin', 'rs bonk bob', 'rs honk', 'rs disco', 'rs vibecheck']) {
assert.match(await run(command, nonAdmin), LOCKDOWN_DENIAL, `${command} should be suspended in lockdown`);
}
// A plain admin is not enough during lockdown.
assert.match(await run('rs coin', admin), LOCKDOWN_DENIAL);
assert.doesNotMatch(await run('rs coin', lockdownAdmin), LOCKDOWN_DENIAL);
});
test('lockdown still suspends the pre-existing moderation-sensitive commands', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
for (const command of ['rs lock rover-1', 'rs mode open', 'rs lights on', 'rs goal', 'rs kick alice']) {
assert.match(await run(command, admin), LOCKDOWN_DENIAL, `${command} should stay lockdown-gated`);
}
});
test('status and help survive lockdown', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
assert.match(await run('rs status', nonAdmin), /\[status handler\]/);
assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/);
});
test('a disabled required feature is reported before any permission check', async () => {
const run = createRouter({ featureEnabled: false });
assert.match(await run('rs disco', nonAdmin), /Home Assistant feature is not configured/);
assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is not configured/);
});
test('ordinary words that merely start with the prefix are not commands', async () => {
const run = createRouter();
assert.equal(await run('rsvp', nonAdmin), '');
assert.equal(await run('rspecial delivery', nonAdmin), '');
assert.equal(await run('hello there', nonAdmin), '');
});
test('an unknown command is not treated as public', async () => {
const run = createRouter();
// Unknown actions carry no registry entry, so they must fall through to the
// same admin denial they did before the permission refactor.
assert.match(await run('rs notacommand', nonAdmin), ADMIN_DENIAL);
assert.match(await run('rs notacommand', admin), /Rover Bot Commands/);
});
test('bot actors are ignored entirely', async () => {
const run = createRouter();
assert.equal(await run('rs coin', { ...nonAdmin, bot: true }), '');
});
test('command matching is case insensitive', async () => {
const run = createRouter();
assert.doesNotMatch(await run('RS COIN', nonAdmin), ADMIN_DENIAL);
assert.match(await run('Rs Status', nonAdmin), /\[status handler\]/);
});
@@ -3,8 +3,15 @@
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations. // Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
const CATEGORIES = { const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] }, system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter'] }, admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'gain'] },
features: { title: 'Features', names: ['lights', 'lift', 'neato'] }, features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
fun: {
title: 'Fun',
names: [
'bonk', 'hug', 'slap', 'bonkboard', '8ball', 'roll', 'coin', 'ship', 'rate', 'uwu',
'wanted', 'pet', 'snitch', 'honk', 'boo', 'spin', 'disco', 'vibecheck',
],
},
discord: { title: 'Discord', names: ['bridge'] }, discord: { title: 'Discord', names: ['bridge'] },
}; };
@@ -33,10 +40,68 @@ function buildCommandRegistry(prefix, timeCommand) {
}, },
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' }, kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' }, verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' }, deter: {
category: 'admin',
summary: 'Manage identity deterrence and mute status.',
usage: [
`${prefix} deter list`,
`${prefix} deter ban <identity>`,
`${prefix} deter unban <identity>`,
`${prefix} deter mute <identity>`,
`${prefix} deter unmute <identity>`,
],
access: 'Lockdown admin',
permission: 'lockdown-admin',
},
gain: {
category: 'admin',
summary: 'Manage the VIP audio gain boost that raises a user\'s volume ceiling past the global gains.',
usage: [
`${prefix} gain list`,
`${prefix} gain grant <vip>`,
`${prefix} gain revoke <vip>`,
`${prefix} gain help`,
],
access: 'Admin',
permission: 'admin',
},
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' }, lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' }, neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' }, bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
/*
Fun commands are the first entries to use `permission: 'public'`. Before
they existed, every non-admin-reachable command was named in a hardcoded
allowlist in the dispatcher; declaring the permission here instead means a
new fun command does not need a dispatcher edit to be usable.
*/
bonk: { category: 'fun', summary: 'Bonk someone. Keeps a running tally.', usage: [`${prefix} bonk <user>`], access: 'Public', permission: 'public' },
hug: { category: 'fun', summary: 'Hug someone.', usage: [`${prefix} hug <user>`], access: 'Public', permission: 'public' },
slap: { category: 'fun', summary: 'Slap someone with a random object.', usage: [`${prefix} slap <user>`], access: 'Public', permission: 'public' },
bonkboard: { category: 'fun', summary: 'Show the bonk and hug leaderboards.', usage: [`${prefix} bonkboard`], access: 'Public', permission: 'public' },
'8ball': { category: 'fun', summary: 'Ask the magic 8 ball. Same question always gets the same answer.', usage: [`${prefix} 8ball <question>`], access: 'Public', permission: 'public' },
roll: { category: 'fun', summary: 'Roll dice.', usage: [`${prefix} roll [NdN]`], access: 'Public', permission: 'public' },
coin: { category: 'fun', summary: 'Flip a coin.', usage: [`${prefix} coin`], access: 'Public', permission: 'public' },
ship: { category: 'fun', summary: 'Rate a pairing out of 100.', usage: [`${prefix} ship <a> and <b>`], access: 'Public', permission: 'public' },
rate: { category: 'fun', summary: 'Rate anything out of 10.', usage: [`${prefix} rate <thing>`], access: 'Public', permission: 'public' },
uwu: { category: 'fun', summary: 'Ruin some text.', usage: [`${prefix} uwu <text>`], access: 'Public', permission: 'public' },
wanted: { category: 'fun', summary: 'Issue a wanted poster.', usage: [`${prefix} wanted <user>`], access: 'Public', permission: 'public' },
pet: { category: 'fun', summary: 'Pet a rover. Each rover keeps its own count.', usage: [`${prefix} pet [rover]`], access: 'Public', permission: 'public' },
snitch: { category: 'fun', summary: 'Report who is driving what.', usage: [`${prefix} snitch`], access: 'Public', permission: 'public' },
// honk and spin move hardware, so their handlers additionally require that the
// caller actually holds control of the rover they name.
honk: { category: 'fun', summary: 'Sound a short horn toot on a rover you control.', usage: [`${prefix} honk [rover]`], access: 'Public; requires control of the rover', permission: 'public' },
boo: { category: 'fun', summary: 'Speak a taunt through the rover someone is driving.', usage: [`${prefix} boo <user>`], access: 'Public', permission: 'public' },
spin: { category: 'fun', summary: 'Make a rover you control do a spin.', usage: [`${prefix} spin [rover]`], access: 'Public; requires control of the rover', permission: 'public' },
vibecheck: { category: 'fun', summary: 'Judge a rover\'s vibes and report its battery.', usage: [`${prefix} vibecheck [rover]`], access: 'Public', permission: 'public' },
disco: {
category: 'fun',
summary: 'Strobe the room lights briefly.',
usage: [`${prefix} disco`],
access: 'Public unless server access is restricted; obeys the room-light lock',
permission: 'access-mode',
requiredFeature: 'homeAssistant',
unavailableLabel: 'Home Assistant',
},
}; };
} }
@@ -45,8 +45,9 @@ function describeSource(source) {
} }
function sanitizeReplayTitleForFilename(title) { function sanitizeReplayTitleForFilename(title) {
// Discord attachment names should be readable and filesystem-safe. // Replay filenames are exposed by both Discord and the server-hosted fallback.
// The title may originate from chat/web input, so strip separators and cap length before upload. // The title may originate from chat/web input, so strip separators and cap its
// length before it is used in an attachment name, URL, or local filesystem path.
const cleaned = String(title || '') const cleaned = String(title || '')
.replace(/[\\/:*?"<>|]+/g, ' ') .replace(/[\\/:*?"<>|]+/g, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@@ -55,6 +56,21 @@ function sanitizeReplayTitleForFilename(title) {
return cleaned || 'replay'; return cleaned || 'replay';
} }
function buildReplayFilename(job = {}) {
const title = sanitizeReplayTitleForFilename(job.title);
const createdAt = Number(job.createdAt);
// The job creation time is shared by every delivery path. Using it instead
// of the later upload/hosting time guarantees that a Discord upload and its
// local fallback describe the same replay with the same readable filename.
// Date.now() is retained only as a defensive fallback for older callers that
// construct a minimal job object without going through createReplayJob().
const timestamp = Number.isFinite(createdAt) && createdAt > 0
? Math.trunc(createdAt)
: Date.now();
return `${title} ${timestamp}.mp4`;
}
function buildReplayTitle({ explicitTitle = '', sources = [] } = {}) { function buildReplayTitle({ explicitTitle = '', sources = [] } = {}) {
const trimmed = normalizeText(explicitTitle).slice(0, 120); const trimmed = normalizeText(explicitTitle).slice(0, 120);
if (trimmed) return trimmed; if (trimmed) return trimmed;
@@ -379,6 +395,7 @@ module.exports = {
createReplayCaptionBuilder, createReplayCaptionBuilder,
startDiscordTypingLoop, startDiscordTypingLoop,
sanitizeReplayTitleForFilename, sanitizeReplayTitleForFilename,
buildReplayFilename,
firstAttachmentFromMessage, firstAttachmentFromMessage,
buildDiscordReplayMediaPayload, buildDiscordReplayMediaPayload,
buildAcceptedMessage, buildAcceptedMessage,
@@ -1,18 +1,29 @@
// Replay Media Service // Replay Media Service
// Purpose: Stores and serves completed replay videos when Discord delivery is unavailable. // Purpose: Stores and serves completed replay videos when Discord delivery is unavailable.
// Scope: Owns only final hosted MP4 files; replay frame caches and active video builds remain outside this service. // Scope: Owns only final hosted MP4 files; replay frame caches and active video builds remain outside this service.
const crypto = require('crypto');
const fsp = require('fs/promises'); const fsp = require('fs/promises');
const path = require('path'); const path = require('path');
const logger = require('../../globals/logger').child('replayMedia'); const logger = require('../../globals/logger').child('replayMedia');
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const { resolveDataDir } = require('../../helpers/dataPaths'); const { resolveDataDir } = require('../../helpers/dataPaths');
const { buildReplayFilename } = require('../replayDeliveryService/workflow');
const REPLAY_DIR = path.join(resolveDataDir(), 'replays'); const REPLAY_DIR = path.join(resolveDataDir(), 'replays');
const MAX_AGE_MS = 6 * 60 * 60 * 1000; const MAX_AGE_MS = 6 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 30 * 60 * 1000; const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
const MAX_TOTAL_BYTES = 1024 * 1024 * 1024; const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
const PUBLIC_FILE_PATTERN = /^[a-f0-9]{32}\.mp4$/; const PUBLIC_FILE_PATTERN = /^.+ \d{13}\.mp4$/u;
function buildContentDisposition(filename) {
// The ASCII fallback keeps Node's response header valid for titles containing
// Unicode, while filename* preserves the full readable name in browsers that
// support the standard UTF-8 Content-Disposition form.
const asciiFilename = filename.replace(/[^\x20-\x7E]/g, '_');
const encodedFilename = encodeURIComponent(filename).replace(/[!'()*]/g, (character) => (
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
));
return `inline; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`;
}
async function listCompletedFiles() { async function listCompletedFiles() {
await fsp.mkdir(REPLAY_DIR, { recursive: true }); await fsp.mkdir(REPLAY_DIR, { recursive: true });
@@ -64,7 +75,7 @@ async function cleanup() {
async function hostReplay({ buffer, job }) { async function hostReplay({ buffer, job }) {
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Replay output was empty'); if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Replay output was empty');
await fsp.mkdir(REPLAY_DIR, { recursive: true }); await fsp.mkdir(REPLAY_DIR, { recursive: true });
const filename = `${crypto.randomBytes(16).toString('hex')}.mp4`; const filename = buildReplayFilename(job);
const finalPath = path.join(REPLAY_DIR, filename); const finalPath = path.join(REPLAY_DIR, filename);
const temporaryPath = `${finalPath}.${process.pid}.tmp`; const temporaryPath = `${finalPath}.${process.pid}.tmp`;
@@ -84,7 +95,11 @@ async function hostReplay({ buffer, job }) {
title: job.title, title: job.title,
requester: job.requester, requester: job.requester,
requestedBy: job.requestedBy || null, requestedBy: job.requestedBy || null,
url: `/media/replays/${filename}`, // The readable filename intentionally contains spaces, but the media URL is
// also pasted into Discord messages where an unescaped space terminates the
// detected link. Encode only the path segment; Express decodes the route
// parameter back to the exact on-disk filename before the file is served.
url: `/media/replays/${encodeURIComponent(filename)}`,
proxyUrl: null, proxyUrl: null,
messageUrl: null, messageUrl: null,
filename, filename,
@@ -102,7 +117,14 @@ app.get('/media/replays/:filename', (req, res, next) => {
// Express sendFile supports byte-range requests, which preserves seeking in // Express sendFile supports byte-range requests, which preserves seeking in
// the existing browser video players without implementing a second streamer. // the existing browser video players without implementing a second streamer.
res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cache-Control', 'private, max-age=3600');
return res.sendFile(filePath, { headers: { 'Content-Type': 'video/mp4' } }, (err) => { return res.sendFile(filePath, {
headers: {
'Content-Type': 'video/mp4',
// Supplying the readable filename explicitly ensures that saving a video
// keeps its replay title even though it was delivered through an HTTP route.
'Content-Disposition': buildContentDisposition(filename),
},
}, (err) => {
if (!err || res.headersSent) return; if (!err || res.headersSent) return;
if (err.code === 'ENOENT') return res.status(404).end(); if (err.code === 'ENOENT') return res.status(404).end();
return next(err); return next(err);
@@ -9,6 +9,20 @@ const discordInvite = config.discord?.invite || null;
const kofiLink = config.kofi?.link || null; const kofiLink = config.kofi?.link || null;
const serverTimezone = config.timezone || null; const serverTimezone = config.timezone || null;
const configuredSocials = getConfiguredSocials(config); const configuredSocials = getConfiguredSocials(config);
/*
The driver ad is trusted deployment content supplied by the server operator.
Normalize both values at the server boundary so every browser receives a
predictable string-only contract, even when the YAML keys are absent or were
accidentally configured with another scalar type.
Keep the title and markup together because they describe one optional card.
An empty HTML string disables the card; the title alone must never leave an
empty panel at the bottom of the driver layout.
*/
const driverAd = {
title: typeof config.driverAd?.title === 'string' ? config.driverAd.title.trim() : '',
html: typeof config.driverAd?.html === 'string' ? config.driverAd.html.trim() : '',
};
const ACTIVITY_SYNC_COOLDOWN_MS = 3000; const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000; const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000;
@@ -19,6 +33,7 @@ module.exports = {
kofiLink, kofiLink,
serverTimezone, serverTimezone,
configuredSocials, configuredSocials,
driverAd,
ACTIVITY_SYNC_COOLDOWN_MS, ACTIVITY_SYNC_COOLDOWN_MS,
GPIO_TOGGLE_SYNC_COOLDOWN_MS, GPIO_TOGGLE_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS, PERIODIC_SYNC_MS,
+22 -2
View File
@@ -53,7 +53,7 @@ const {
getUserIdForSocket, getUserIdForSocket,
} = require('../identityService'); } = require('../identityService');
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService'); const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService'); const { getAudioLevels, getAudioGainStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
const { getButtonBoxState } = require('../buttonBoxService'); const { getButtonBoxState } = require('../buttonBoxService');
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService'); const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
const { const {
@@ -61,6 +61,7 @@ const {
kofiLink, kofiLink,
serverTimezone, serverTimezone,
configuredSocials, configuredSocials,
driverAd,
ACTIVITY_SYNC_COOLDOWN_MS, ACTIVITY_SYNC_COOLDOWN_MS,
GPIO_TOGGLE_SYNC_COOLDOWN_MS, GPIO_TOGGLE_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS, PERIODIC_SYNC_MS,
@@ -209,6 +210,13 @@ function buildSession(socket) {
adminReason: getAdminReason(), adminReason: getAdminReason(),
users, users,
socials, socials,
/*
This is intentionally raw, operator-authored HTML. The browser only
mounts it on the desktop driver page, but keeping it in the ordinary
session payload makes the server configuration the single source of
truth and avoids a separate endpoint for one small optional card.
*/
driverAd,
discord: { discord: {
invite: discordInvite, invite: discordInvite,
}, },
@@ -223,6 +231,7 @@ function buildSession(socket) {
isVerified: Boolean(socket?.data?.isVerified), isVerified: Boolean(socket?.data?.isVerified),
audioForward: getAudioForwardState(), audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(), audioLevels: getAudioLevels(),
audioGains: getAudioGainStateForSocket(socket),
buttonBox: getButtonBoxState(), buttonBox: getButtonBoxState(),
/* /*
Inter-instance state is a read-only directory snapshot. It is included in Inter-instance state is a read-only directory snapshot. It is included in
@@ -454,7 +463,18 @@ audioForwardEvents.on('change', () => {
syncAll(); syncAll();
}); });
audioLevelsEvents.on('change', () => { audioLevelsEvents.on('change', ({ scope, userId } = {}) => {
/*
A user dragging their own volume slider only changes their own payload, so
it resyncs just that user's tabs. Admin gain and cap edits still change
everyone's ceiling and keep the full broadcast.
*/
if (scope === 'user' && userId) {
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) === userId) syncSocket(socket);
});
return;
}
syncAll(); syncAll();
}); });
+67 -5
View File
@@ -112,6 +112,7 @@ function driverAdded(roverId, socketId, options = {}) {
const { force, pauseQueue } = normalizeDriverAddOptions(options); const { force, pauseQueue } = normalizeDriverAddOptions(options);
const queue = ensureQueue(roverId); const queue = ensureQueue(roverId);
const alreadyQueued = queue.queue.includes(socketId); const alreadyQueued = queue.queue.includes(socketId);
const previousQueueLength = queue.queue.length;
if (pauseQueue) { if (pauseQueue) {
applyAdminQueuePause(roverId, socketId, queue); applyAdminQueuePause(roverId, socketId, queue);
@@ -141,6 +142,30 @@ function driverAdded(roverId, socketId, options = {}) {
if (!queue.current || force) { if (!queue.current || force) {
queue.current = socketId; queue.current = socketId;
} }
/*
A normal join changes who will receive a future turn; it does not begin a
new turn for the person who is already driving. Preserve both deadlines and
the current driver's activity state while an already-rotating queue grows.
The one-to-two transition is intentionally different. Before the second
driver arrives there is no reason to time the sole driver, so this is the
moment the first real rotating turn begins. A forced grant also changes the
active driver immediately and therefore starts that driver's fresh turn.
*/
const turnsAreRotating = getMode() === MODES.TURNS && queue.queue.length > 1;
const beginsRotatingTurns = turnsAreRotating && previousQueueLength <= 1;
if (turnsAreRotating && (force || beginsRotatingTurns)) {
startCurrentTurn(roverId, queue);
return;
}
if (turnsAreRotating) {
setActiveDriver(roverId, queue.current);
turnEvents.emit('queue', { roverId, reason: 'driver-joined' });
return;
}
syncState(roverId); syncState(roverId);
} }
@@ -165,13 +190,32 @@ function driverRemoved(roverId, socketId) {
idleSkips.delete(roverId); idleSkips.delete(roverId);
} }
} }
idleDisarmed.delete(roverId);
if (queue.current === socketId) { if (queue.current === socketId) {
/*
Removing the active driver is a real handoff. advanceTurn() owns clearing
the old turn state and creating the next driver's deadlines, keeping this
path identical for explicit release, disconnect, and stale-socket reap.
*/
stopRover(roverId); stopRover(roverId);
advanceTurn(roverId); advanceTurn(roverId);
} else { return;
scheduleIdleTimer(roverId);
} }
/*
A waiting driver leaving must not re-arm idle detection or extend the
unrelated active driver's turn. If this removal ends rotation entirely,
syncState() clears both deadlines immediately; otherwise only the public
queue membership changes.
*/
if (getMode() !== MODES.TURNS || queue.queue.length <= 1) {
syncState(roverId);
return;
}
if (isQueuePaused(queue)) {
turnEvents.emit('queue', { roverId, reason: 'driver-left-admin-pause' });
return;
}
turnEvents.emit('queue', { roverId, reason: 'driver-left' });
} }
function cleanupRover(roverId) { function cleanupRover(roverId) {
@@ -242,11 +286,24 @@ function syncState(roverId) {
if (!queue.current) { if (!queue.current) {
queue.current = queue.queue[0]; queue.current = queue.queue[0];
} }
/*
syncState() is used when entering turns mode and when a previously untimed
queue becomes rotatable. Both cases begin a genuine turn rather than merely
publishing a membership update.
*/
startCurrentTurn(roverId, queue);
}
function startCurrentTurn(roverId, queue) {
/*
This is the sole normal entry point for a fresh timed turn. Keeping active
ownership, the full-turn deadline, and the activity grace window together
prevents callers from resetting only part of the lifecycle.
*/
setActiveDriver(roverId, queue.current); setActiveDriver(roverId, queue.current);
idleDisarmed.set(roverId, false); idleDisarmed.set(roverId, false);
scheduleNextTurn(roverId); scheduleNextTurn(roverId);
scheduleIdleTimer(roverId); scheduleIdleTimer(roverId);
turnEvents.emit('queue', { roverId });
} }
function scheduleNextTurn(roverId) { function scheduleNextTurn(roverId) {
@@ -423,7 +480,12 @@ function reapStaleDrivers() {
}); });
} }
setInterval(reapStaleDrivers, STALE_REAPER_MS); /*
Stale cleanup should run for the lifetime of the server, but it should not by
itself keep short-lived tools or the Node test runner alive after their work
has completed.
*/
setInterval(reapStaleDrivers, STALE_REAPER_MS).unref();
module.exports = { module.exports = {
driverAdded, driverAdded,
@@ -0,0 +1,96 @@
// Turn Service Tests
// Purpose: Protects timed-turn state from unrelated queue membership changes.
// Scope: Exercises public turn operations and inspects only their published queue model; no HTTP server is started.
const assert = require('node:assert/strict');
const test = require('node:test');
const { MODES, setMode } = require('../modeManager');
const turnService = require('./index');
function enterTurnsMode() {
/*
Mode authorization is irrelevant to this state-machine test. The forced
transition uses the same production mode event, ensuring existing queues
are reconciled exactly as they would be after an administrator mode change.
*/
setMode(MODES.TURNS, null, { force: true });
}
function resetRover(roverId) {
turnService.cleanupRover(roverId);
}
test('joining an active rotation preserves the current turn and completed activity grace', () => {
const roverId = 'turn-test-join';
enterTurnsMode();
turnService.driverAdded(roverId, 'driver-a');
turnService.driverAdded(roverId, 'driver-b');
const started = turnService.getTurnQueues()[roverId];
assert.ok(started.deadline);
assert.ok(started.idleDeadline);
turnService.recordActivity(roverId, 'driver-a');
const active = turnService.getTurnQueues()[roverId];
assert.equal(active.idleDeadline, null);
turnService.driverAdded(roverId, 'driver-c');
const joined = turnService.getTurnQueues()[roverId];
assert.equal(joined.current, 'driver-a');
assert.equal(joined.deadline, active.deadline);
assert.equal(joined.idleDeadline, null);
resetRover(roverId);
});
test('a waiting driver leaving preserves both active deadlines', () => {
const roverId = 'turn-test-waiting-leave';
enterTurnsMode();
turnService.driverAdded(roverId, 'driver-a');
turnService.driverAdded(roverId, 'driver-b');
turnService.driverAdded(roverId, 'driver-c');
const before = turnService.getTurnQueues()[roverId];
turnService.driverRemoved(roverId, 'driver-c');
const after = turnService.getTurnQueues()[roverId];
assert.equal(after.current, 'driver-a');
assert.equal(after.deadline, before.deadline);
assert.equal(after.idleDeadline, before.idleDeadline);
resetRover(roverId);
});
test('dropping to one driver clears turn and idle deadlines immediately', () => {
const roverId = 'turn-test-single-driver';
enterTurnsMode();
turnService.driverAdded(roverId, 'driver-a');
turnService.driverAdded(roverId, 'driver-b');
assert.ok(turnService.getTurnQueues()[roverId].deadline);
turnService.driverRemoved(roverId, 'driver-b');
const single = turnService.getTurnQueues()[roverId];
assert.equal(single.current, 'driver-a');
assert.equal(single.deadline, null);
assert.equal(single.idleDeadline, null);
resetRover(roverId);
});
test('turn deadlines are absent outside turns mode during queue grants', () => {
const roverId = 'turn-test-admin-mode';
setMode(MODES.ADMIN, null, { force: true });
turnService.driverAdded(roverId, 'driver-a');
turnService.driverAdded(roverId, 'driver-b', { force: true });
const queue = turnService.getTurnQueues()[roverId];
/*
In non-turn modes syncState keeps the first regular queued driver current.
Explicit administrator takeover uses the separate pauseQueue path; this
assertion is concerned only with ensuring neither grant creates timers.
*/
assert.equal(queue.current, 'driver-a');
assert.equal(queue.deadline, null);
assert.equal(queue.idleDeadline, null);
resetRover(roverId);
});

Some files were not shown because too many files have changed in this diff Show More