From d82964444806a7e6eabb026a70d8e3b93a60e9ab Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 16 Aug 2026 10:14:28 -0600 Subject: [PATCH 1/3] fix(reticulum): exclude RF from announce rebroadcast enqueue Transport-mode mesh announce rebroadcasts were filling the BLE RNode host TX queue; skip RF sinks in broadcast_announce_on_interfaces while keeping local announces on RNode. --- reticulum-sidecar/patches/README.md | 25 ++++++++++ ...ulum-announce-rebroadcast-exclude-rf.patch | 47 +++++++++++++++++++ ...ticulum-announce-rebroadcast-exclude-rf.sh | 40 ++++++++++++++++ scripts/lib/ratspeak-overlay-apply-list.sh | 1 + scripts/update.sh | 1 + 5 files changed, 114 insertions(+) create mode 100644 reticulum-sidecar/patches/rsReticulum-announce-rebroadcast-exclude-rf.patch create mode 100755 scripts/apply-rsReticulum-announce-rebroadcast-exclude-rf.sh diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index 8de4330e6..957596764 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -542,6 +542,31 @@ Listed in `scripts/lib/ratspeak-overlay-apply-list.sh` and `RATSPEAK_PATCH_ENTRI When ratspeak/rsReticulum matches Python pathless-LINK attached-only (or equivalent) on floated `origin/main`, remove this patch and the apply step. +## rsReticulum-announce-rebroadcast-exclude-rf.patch + +With `enable_transport`, rsReticulum enqueues rebroadcasted mesh announces onto every eligible OUT iface, including flow-controlled BLE RNodes. Host TX mpsc fills while FC drains slowly (overnight / busy TCP mesh). Skip RF sinks (`iface_is_pathless_link_rf_sink` from pathless-link overlay) in `broadcast_announce_on_interfaces` only. Local discovery announces still use `broadcast_local_announce_on_interfaces` (RNode included). + +| Field | Value | +| ----- | ----- | +| **Base commit** | floated `origin/main` after pathless-link-exclude-rf (regenerate; record short SHA in PR) | +| **Upstream PR** | none yet (mesh-client-local) | +| **Depends on** | `rsReticulum-pathless-link-exclude-rf.patch` | + +**Touches:** `crates/rns-transport/src/actor/mod.rs` + +### Apply locally + +```bash +./scripts/apply-rsReticulum-pathless-link-exclude-rf.sh +./scripts/apply-rsReticulum-announce-rebroadcast-exclude-rf.sh +``` + +Listed in `scripts/lib/ratspeak-overlay-apply-list.sh` and `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh`. + +### Sunset + +When ratspeak/rsReticulum rate-limits or excludes RF for announce rebroadcast equivalently on floated `origin/main`, remove this patch and the apply step. + ## rsLXMF-propagation-client-abort-transfer.patch Adds `PropagationClient::abort_transfer` so Cancel / mid-transfer abort leaves the client **Idle**. Without it, a cancelled Sync can leave `/get` stuck busy and the next Sync returns `PROPAGATION_RETRIEVE_BUSY` forever (or Auto falsely concludes there are no PNs). diff --git a/reticulum-sidecar/patches/rsReticulum-announce-rebroadcast-exclude-rf.patch b/reticulum-sidecar/patches/rsReticulum-announce-rebroadcast-exclude-rf.patch new file mode 100644 index 000000000..5be9a92c3 --- /dev/null +++ b/reticulum-sidecar/patches/rsReticulum-announce-rebroadcast-exclude-rf.patch @@ -0,0 +1,47 @@ +From: mesh-client +Subject: Exclude RF sinks from announce rebroadcast enqueue + +With enable_transport, rebroadcasted mesh announces were enqueued onto every +eligible OUT iface including flow-controlled BLE RNodes. Host TX mpsc filled +while FC drained slowly. Local announces still use +broadcast_local_announce_on_interfaces (unchanged). Requires +iface_is_pathless_link_rf_sink from pathless-link-exclude-rf overlay. + +--- a/crates/rns-transport/src/actor/mod.rs ++++ b/crates/rns-transport/src/actor/mod.rs +@@ -1577,6 +1577,11 @@ + /// optionally one). Eligibility mirrors Python's AP/roaming/boundary mode + /// gates. Enqueueing lets `process_announce_queues` apply ANNOUNCE_CAP + /// spacing and hop priority. ++ /// ++ /// Flow-controlled RF sinks (RNode / low-bitrate) are skipped for ++ /// *rebroadcast* — transport-enabled leaves otherwise enqueue every mesh ++ /// announce onto BLE RNodes and fill the host TX queue while FC drains ++ /// slowly. Local announces still use [`broadcast_local_announce_on_interfaces`]. + fn broadcast_announce_on_interfaces(&mut self, raw: &[u8], except: Option) { + let destination_hash = rns_wire::header::PacketHeader::unpack(raw) + .ok() +@@ -1588,10 +1593,20 @@ + // shared Arc for free. + let shared = Bytes::copy_from_slice(raw); + +- let ids: Vec = self ++ // Collect candidates before interface_allows_announce borrows self. ++ let candidate_ids: Vec = self + .interfaces +- .keys() +- .copied() ++ .iter() ++ .filter_map(|(&id, entry)| { ++ if except == Some(id) || iface_is_pathless_link_rf_sink(entry) { ++ None ++ } else { ++ Some(id) ++ } ++ }) ++ .collect(); ++ let ids: Vec = candidate_ids ++ .into_iter() + .filter(|id| self.interface_allows_announce(*id, &destination_hash, except)) + .collect(); + diff --git a/scripts/apply-rsReticulum-announce-rebroadcast-exclude-rf.sh b/scripts/apply-rsReticulum-announce-rebroadcast-exclude-rf.sh new file mode 100755 index 000000000..cf65ca438 --- /dev/null +++ b/scripts/apply-rsReticulum-announce-rebroadcast-exclude-rf.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Apply mesh-client rsReticulum announce-rebroadcast RF exclusion overlay. +# Transport-mode announce rebroadcast must not enqueue onto flow-controlled RNodes. +# Requires pathless-link-exclude-rf (iface_is_pathless_link_rf_sink) applied first. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" +PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsReticulum-announce-rebroadcast-exclude-rf.patch" +RNS_DIR="${RS_RETICULUM_DIR:-${REPO_ROOT}/.rsstack/rsReticulum}" +MOD_RS="${RNS_DIR}/crates/rns-transport/src/actor/mod.rs" + +if ! git -C "${RNS_DIR}" rev-parse --is-inside-work-tree > /dev/null 2>&1; then + echo "error: rsReticulum not found at ${RNS_DIR}" >&2 + echo "Clone: git clone https://github.com/ratspeak/rsReticulum.git ${RNS_DIR}" >&2 + exit 1 +fi + +if [[ ! -f "${PATCH_FILE}" ]]; then + echo "error: patch not found at ${PATCH_FILE}" >&2 + exit 1 +fi + +if [[ ! -f "${MOD_RS}" ]] || ! grep -q 'iface_is_pathless_link_rf_sink' "${MOD_RS}"; then + echo "error: pathless-link-exclude-rf overlay required first" >&2 + echo "Run: ./scripts/apply-rsReticulum-pathless-link-exclude-rf.sh" >&2 + exit 1 +fi + +if grep -q 'Flow-controlled RF sinks (RNode / low-bitrate) are skipped for' "${MOD_RS}"; then + echo "announce-rebroadcast-exclude-rf overlay already applied on rsReticulum @ $(git -C "${RNS_DIR}" rev-parse --short HEAD)" + exit 0 +fi + +if apply_ratspeak_overlay_or_die "${RNS_DIR}" "${PATCH_FILE}" "announce-rebroadcast-exclude-rf"; then + exit 0 +fi +exit 1 diff --git a/scripts/lib/ratspeak-overlay-apply-list.sh b/scripts/lib/ratspeak-overlay-apply-list.sh index a68079fe2..27849183c 100644 --- a/scripts/lib/ratspeak-overlay-apply-list.sh +++ b/scripts/lib/ratspeak-overlay-apply-list.sh @@ -15,6 +15,7 @@ RS_RETICULUM_APPLY_SCRIPTS=( apply-rsReticulum-inbound-raw-saturation-log.sh apply-rsReticulum-interface-tx-queue-stats.sh apply-rsReticulum-pathless-link-exclude-rf.sh + apply-rsReticulum-announce-rebroadcast-exclude-rf.sh ) RS_LXMF_APPLY_SCRIPTS=( diff --git a/scripts/update.sh b/scripts/update.sh index 7c1508dde..3165fe30f 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -221,6 +221,7 @@ check_ratspeak_patches() { 'rsReticulum-inbound-raw-saturation-log.patch|ratspeak/rsReticulum||rsReticulum inbound-raw saturation log|' 'rsReticulum-interface-tx-queue-stats.patch|ratspeak/rsReticulum||rsReticulum interface TX queue stats|' 'rsReticulum-pathless-link-exclude-rf.patch|ratspeak/rsReticulum||rsReticulum pathless Link exclude RF sinks|' + 'rsReticulum-announce-rebroadcast-exclude-rf.patch|ratspeak/rsReticulum||rsReticulum announce rebroadcast exclude RF sinks|' 'rsLXMF-propagation-sync-peering.patch|ratspeak/rsLXMF|4|rsLXMF propagation sync peering|https://github.com/ratspeak/rsLXMF/pull/4' 'rsLXMF-propagation-node-policy-setters.patch|ratspeak/rsLXMF|6|rsLXMF PropagationNode policy setters|https://github.com/ratspeak/rsLXMF/pull/6' 'rsLXMF-propagation-node-deferred-messagestore-load.patch|ratspeak/rsLXMF||rsLXMF PropagationNode deferred messagestore load|' From 2e47ea5287223df103ae9eec110a263fc19854dd Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 16 Aug 2026 10:21:30 -0600 Subject: [PATCH 2/3] fix(rrc): expand slash commands and show hub replies in-room rrcd expects room-first moderation syntax; inject the focused room for IRC-style /op /topic /mode /who and surface empty-K_ROOM NOTICE/ERROR in the active room so command replies are visible. --- src/renderer/components/RrcPanel.test.tsx | 77 +++++++++++++++- src/renderer/components/RrcPanel.tsx | 25 ++++-- src/renderer/lib/rrcMessageDisplay.test.ts | 20 +++++ src/renderer/lib/rrcMessageDisplay.ts | 17 ++++ src/renderer/lib/rrcSlashCommands.test.ts | 48 +++++++++- src/renderer/lib/rrcSlashCommands.ts | 89 +++++++++++++++++++ src/renderer/locales/en/translation.json | 8 +- .../runtime/useReticulumRuntime.rrc.test.ts | 10 +++ src/renderer/runtime/useReticulumRuntime.ts | 12 +++ 9 files changed, 291 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/RrcPanel.test.tsx b/src/renderer/components/RrcPanel.test.tsx index 2fdafc550..03af7348d 100644 --- a/src/renderer/components/RrcPanel.test.tsx +++ b/src/renderer/components/RrcPanel.test.tsx @@ -617,9 +617,9 @@ describe('RrcPanel', () => { expect(window.electronAPI.reticulum.rrc.setNickname).toHaveBeenCalled(); }); await waitFor(() => { - expect(whoSendCalls().some((args) => (args[0] as { body?: string }).body === '/who')).toBe( - true, - ); + expect( + whoSendCalls().some((args) => (args[0] as { body?: string }).body === '/who general'), + ).toBe(true); }); }); @@ -749,6 +749,77 @@ describe('RrcPanel', () => { ).toBe(false); }); + it('expands IRC-style /op into rrcd room-first body before send', async () => { + const user = userEvent.setup(); + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general', [ + { identity_hash: 'cccccccccccccccccccccccccccccccc', nickname: 'Alice' }, + ]); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockResolvedValue({ ok: true }); + + render(); + await waitFor(() => { + expect(whoSendCalls().length).toBeGreaterThan(0); + }); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + + const composer = screen.getByRole('textbox', { name: /Message or \/command/i }); + await user.clear(composer); + await user.type(composer, '/op alice'); + await user.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + expect(vi.mocked(window.electronAPI.reticulum.rrc.send)).toHaveBeenCalledWith( + expect.objectContaining({ + hub_dest_hash: hubA, + room: 'general', + body: '/op general alice', + type: 'msg', + }), + ); + }); + }); + + it('expands bare /who to include the focused room and omits K_ROOM', async () => { + const user = userEvent.setup(); + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general', [ + { identity_hash: 'cccccccccccccccccccccccccccccccc', nickname: 'Alice' }, + ]); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockResolvedValue({ ok: true }); + + render(); + await waitFor(() => { + expect(whoSendCalls().length).toBeGreaterThan(0); + }); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + + const composer = screen.getByRole('textbox', { name: /Message or \/command/i }); + await user.clear(composer); + await user.type(composer, '/who'); + await user.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + expect(vi.mocked(window.electronAPI.reticulum.rrc.send)).toHaveBeenCalledWith( + expect.objectContaining({ + hub_dest_hash: hubA, + body: '/who general', + type: 'msg', + }), + ); + }); + const call = vi.mocked(window.electronAPI.reticulum.rrc.send).mock.calls.at(-1)?.[0] as { + room?: string; + }; + expect(call.room).toBeUndefined(); + }); + it('sends one /who per hub when switching focus', async () => { const store = useRrcSessionStore.getState(); store.applyStatus('active', hubA, 'Hub A'); diff --git a/src/renderer/components/RrcPanel.tsx b/src/renderer/components/RrcPanel.tsx index bb5b4f23d..ac3fbbb7b 100644 --- a/src/renderer/components/RrcPanel.tsx +++ b/src/renderer/components/RrcPanel.tsx @@ -43,6 +43,7 @@ import { toggleRrcRoomFavourite, } from '@/renderer/lib/rrcRoomPrefs'; import { + expandRrcHubSlashBody, parseRrcSlashInput, resolveRrcMsgTarget, RRC_HELP_I18N_KEYS, @@ -247,13 +248,18 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: const sendHubCommand = useCallback( async (body: string) => { if (status !== 'active' || !hubDestHash) return; - const isWho = /^\s*\/who(?:\s|$)/i.test(body); + const expanded = expandRrcHubSlashBody(body, activeRoom); + const isWho = /^\s*\/(?:who|names)(?:\s|$)/i.test(expanded); const hubRoom = !isWho && activeRoom && !activeRoom.startsWith('[') && !isRrcDmRoom(activeRoom) ? activeRoom : undefined; const whoForceRoom = isWho - ? resolveRrcWhoTranscriptForceRoom(body, activeRoom, rooms.keys()) + ? resolveRrcWhoTranscriptForceRoom( + expanded.replace(/^\s*\/names\b/i, '/who'), + activeRoom, + rooms.keys(), + ) : null; if (whoForceRoom) { useRrcSessionStore.getState().reserveWhoTranscriptForce(whoForceRoom, hubDestHash); @@ -262,7 +268,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: const res = await rrcSendBounded({ hub_dest_hash: hubDestHash, room: hubRoom, - body, + body: expanded, type: 'msg', }); if (!res.ok && whoForceRoom) { @@ -830,9 +836,14 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(t('rrc.sendFailed')); return; } - const isWho = /^\s*\/who(?:\s|$)/i.test(parsed.body); + const expanded = expandRrcHubSlashBody(parsed.body, activeRoom); + const isWho = /^\s*\/(?:who|names)(?:\s|$)/i.test(expanded); const whoForceRoom = isWho - ? resolveRrcWhoTranscriptForceRoom(parsed.body, activeRoom, rooms.keys()) + ? resolveRrcWhoTranscriptForceRoom( + expanded.replace(/^\s*\/names\b/i, '/who'), + activeRoom, + rooms.keys(), + ) : null; if (whoForceRoom) { useRrcSessionStore.getState().reserveWhoTranscriptForce(whoForceRoom, hubDestHash); @@ -845,7 +856,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: !isWho && activeRoom && !activeRoom.startsWith('[') && !isRrcDmRoom(activeRoom) ? activeRoom : undefined, - body: parsed.body, + body: expanded, type: 'msg', }); } catch (e) { @@ -861,7 +872,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(res.error ?? t('rrc.sendFailed')); return; } - appendSystemLines([t('rrc.slash.commandSent', { cmd: parsed.body })]); + appendSystemLines([t('rrc.slash.commandSent', { cmd: expanded })]); setDraft(''); return; } diff --git a/src/renderer/lib/rrcMessageDisplay.test.ts b/src/renderer/lib/rrcMessageDisplay.test.ts index 131739cca..3c62c618e 100644 --- a/src/renderer/lib/rrcMessageDisplay.test.ts +++ b/src/renderer/lib/rrcMessageDisplay.test.ts @@ -4,6 +4,7 @@ import { RRC_HUB_STREAM_ROOM } from '@/renderer/stores/rrcSessionStore'; import { parseRrcWhisperEcho, + resolveRrcHubScopedNoticeRoom, resolveRrcInboundChatRoom, shouldDisplayRrcChatMessage, shouldDropEmptyRrcInbound, @@ -40,6 +41,25 @@ describe('resolveRrcInboundChatRoom', () => { }); }); +describe('resolveRrcHubScopedNoticeRoom', () => { + it('keeps non-empty K_ROOM unchanged', () => { + expect(resolveRrcHubScopedNoticeRoom('lobby', 'general')).toBe('lobby'); + }); + + it('surfaces empty K_ROOM into the focused real room', () => { + expect(resolveRrcHubScopedNoticeRoom('', 'general')).toBe('general'); + expect(resolveRrcHubScopedNoticeRoom(undefined, '#Lobby')).toBe('#Lobby'); + }); + + it('keeps [hub] when focus is synthetic or a DM', () => { + expect(resolveRrcHubScopedNoticeRoom('', '[hub]')).toBe(RRC_HUB_STREAM_ROOM); + expect(resolveRrcHubScopedNoticeRoom('', '@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).toBe( + RRC_HUB_STREAM_ROOM, + ); + expect(resolveRrcHubScopedNoticeRoom('', null)).toBe(RRC_HUB_STREAM_ROOM); + }); +}); + describe('shouldShowRrcWhoTranscript', () => { it('allows the first /who snapshot per room and hides later ones', () => { const shown = new Set(); diff --git a/src/renderer/lib/rrcMessageDisplay.ts b/src/renderer/lib/rrcMessageDisplay.ts index c04ca36ab..f22310f8b 100644 --- a/src/renderer/lib/rrcMessageDisplay.ts +++ b/src/renderer/lib/rrcMessageDisplay.ts @@ -1,3 +1,4 @@ +import { isRrcDmRoom } from '@/renderer/lib/rrcDmRoom'; import { RRC_HUB_STREAM_ROOM, rrcRoomMatchKey } from '@/renderer/lib/rrcRoomName'; import type { RrcChatMessage } from '@/shared/rrc-types'; @@ -29,6 +30,22 @@ export function resolveRrcInboundChatRoom(wireRoom: string | null | undefined): return room || RRC_HUB_STREAM_ROOM; } +/** + * Hub-global NOTICE/ERROR often arrive with empty K_ROOM. While the user is focused + * on a real joined room, surface those replies there so slash-command output is visible + * without switching to `[hub]`. Synthetic / DM focus keeps `[hub]`. + */ +export function resolveRrcHubScopedNoticeRoom( + wireRoom: string | null | undefined, + activeRoom: string | null | undefined, +): string { + const resolved = resolveRrcInboundChatRoom(wireRoom); + if (resolved !== RRC_HUB_STREAM_ROOM) return resolved; + const focused = activeRoom?.trim() ?? ''; + if (!focused || focused.startsWith('[') || isRrcDmRoom(focused)) return resolved; + return focused; +} + /** * First `/who` NOTICE per room join may appear in chat; later snapshots update the * nicklist only. `shownMatchKeys` holds `rrcRoomMatchKey` values already shown. diff --git a/src/renderer/lib/rrcSlashCommands.test.ts b/src/renderer/lib/rrcSlashCommands.test.ts index 66c2dea7d..36e06553f 100644 --- a/src/renderer/lib/rrcSlashCommands.test.ts +++ b/src/renderer/lib/rrcSlashCommands.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { normalizeRrcRoomName, parseRrcSlashInput, resolveRrcMsgTarget } from './rrcSlashCommands'; +import { + expandRrcHubSlashBody, + isRrcSlashExpandableRoom, + normalizeRrcRoomName, + parseRrcSlashInput, + resolveRrcMsgTarget, +} from './rrcSlashCommands'; describe('parseRrcSlashInput', () => { it('returns chat for plain text', () => { @@ -56,6 +62,46 @@ describe('parseRrcSlashInput', () => { }); }); +describe('expandRrcHubSlashBody', () => { + it('rejects synthetic and DM rooms', () => { + expect(isRrcSlashExpandableRoom('[hub]')).toBe(false); + expect(isRrcSlashExpandableRoom('@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).toBe(false); + expect(expandRrcHubSlashBody('/op alice', '[hub]')).toBe('/op alice'); + }); + + it('injects focused room for IRC-style moderation commands', () => { + expect(expandRrcHubSlashBody('/op alice', 'general')).toBe('/op general alice'); + expect(expandRrcHubSlashBody('/topic hi there', 'general')).toBe('/topic general hi there'); + expect(expandRrcHubSlashBody('/mode +m', 'general')).toBe('/mode general +m'); + expect(expandRrcHubSlashBody('/kick bob', 'general')).toBe('/kick general bob'); + expect(expandRrcHubSlashBody('/ban add aabb', 'general')).toBe('/ban general add aabb'); + expect(expandRrcHubSlashBody('/invite list', 'lobby')).toBe('/invite lobby list'); + expect(expandRrcHubSlashBody('/register', 'general')).toBe('/register general'); + expect(expandRrcHubSlashBody('/who', 'general')).toBe('/who general'); + expect(expandRrcHubSlashBody('/names', '#Lobby')).toBe('/names #lobby'); + }); + + it('does not double-inject when room is already the first arg', () => { + expect(expandRrcHubSlashBody('/op general alice', 'general')).toBe('/op general alice'); + expect(expandRrcHubSlashBody('/op #General alice', 'general')).toBe('/op #General alice'); + expect(expandRrcHubSlashBody('/topic general hello', 'general')).toBe('/topic general hello'); + expect(expandRrcHubSlashBody('/mode lobby +m', 'lobby')).toBe('/mode lobby +m'); + expect(expandRrcHubSlashBody('/ban general add aabb', 'general')).toBe('/ban general add aabb'); + expect(expandRrcHubSlashBody('/who general', 'general')).toBe('/who general'); + }); + + it('leaves hub-global commands unchanged', () => { + expect(expandRrcHubSlashBody('/list', 'general')).toBe('/list'); + expect(expandRrcHubSlashBody('/stats', 'general')).toBe('/stats'); + expect(expandRrcHubSlashBody('/reload', 'general')).toBe('/reload'); + expect(expandRrcHubSlashBody('/kline list', 'general')).toBe('/kline list'); + }); + + it('preserves joined wire spelling including leading #', () => { + expect(expandRrcHubSlashBody('/op alice', '#Lobby')).toBe('/op #lobby alice'); + }); +}); + describe('resolveRrcMsgTarget', () => { const members = [ { identity_hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', nickname: 'Alice' }, diff --git a/src/renderer/lib/rrcSlashCommands.ts b/src/renderer/lib/rrcSlashCommands.ts index d96d6f3c2..f5314728a 100644 --- a/src/renderer/lib/rrcSlashCommands.ts +++ b/src/renderer/lib/rrcSlashCommands.ts @@ -1,9 +1,15 @@ /** * IRC-style slash routing for RRC (rrc-tui / rrcd compatible). * Client-local commands are handled in-app; everything else is hub pass-through MSG. + * + * rrcd moderation commands take the room as the first argument (`/op `), + * despite README/EX1 IRC-style docs. {@link expandRrcHubSlashBody} inserts the focused + * room when the user omits it. */ +import { isRrcDmRoom } from './rrcDmRoom'; import { stripRrcMsgTargetAt } from './rrcMention'; +import { normalizeRrcRoomName, rrcRoomsMatch, rrcWhoCommandToken } from './rrcRoomName'; export type RrcSlashResult = | { kind: 'local'; command: 'help' } @@ -20,6 +26,89 @@ export type RrcSlashResult = export { normalizeRrcRoomName } from './rrcRoomName'; +/** rrcd room-first moderation / registry commands (room is parts[1]). */ +const RRC_ROOM_FIRST_CMDS = new Set([ + 'topic', + 'mode', + 'op', + 'deop', + 'voice', + 'devoice', + 'kick', + 'ban', + 'invite', + 'register', + 'unregister', +]); + +/** True when the focused room can be injected into a hub slash body. */ +export function isRrcSlashExpandableRoom(room: string | null | undefined): boolean { + const t = (room ?? '').trim(); + if (!t || t.startsWith('[') || isRrcDmRoom(t)) return false; + return rrcWhoCommandToken(t) != null; +} + +function looksLikeRrcModeFlag(token: string): boolean { + return /^[+-][mitnkprovr]$/i.test(token.trim()) || /^[+-][kv]$/i.test(token.trim()); +} + +function looksLikeRrcBanInviteOp(token: string): boolean { + const t = token.trim().toLowerCase(); + return t === 'add' || t === 'del' || t === 'list'; +} + +/** + * Rewrite IRC-style hub slash bodies to rrcd room-first form using the focused + * joined wire room. No-op when room is synthetic/DM or the body already names it. + */ +export function expandRrcHubSlashBody(body: string, activeRoom: string | null | undefined): string { + const text = body.trim(); + if (!text.startsWith('/') || !isRrcSlashExpandableRoom(activeRoom)) return text; + + const wireRoom = normalizeRrcRoomName(activeRoom!); + const parts = text.split(/\s+/).filter(Boolean); + if (parts.length === 0) return text; + + const cmdToken = (parts[0] ?? '').toLowerCase(); + if (!cmdToken.startsWith('/')) return text; + const cmd = cmdToken.slice(1); + + if (cmd === 'who' || cmd === 'names') { + if (parts.length === 1) return `${cmdToken} ${wireRoom}`; + const arg = parts[1] ?? ''; + if (rrcRoomsMatch(arg, wireRoom)) return text; + // Bare-ish: only inject when there is no room arg yet (single-token who is handled above). + return text; + } + + if (!RRC_ROOM_FIRST_CMDS.has(cmd)) return text; + + const firstArg = parts[1]; + if (firstArg && rrcRoomsMatch(firstArg, wireRoom)) return text; + + if (cmd === 'mode') { + if (!firstArg || looksLikeRrcModeFlag(firstArg)) { + return [cmdToken, wireRoom, ...parts.slice(1)].join(' '); + } + return text; + } + + if (cmd === 'ban' || cmd === 'invite') { + if (!firstArg || looksLikeRrcBanInviteOp(firstArg)) { + return [cmdToken, wireRoom, ...parts.slice(1)].join(' '); + } + return text; + } + + if (cmd === 'register' || cmd === 'unregister') { + if (!firstArg) return `${cmdToken} ${wireRoom}`; + return text; + } + + // topic / op / deop / voice / devoice / kick — insert room before remaining args + return [cmdToken, wireRoom, ...parts.slice(1)].join(' '); +} + /** Parse composer input. Empty/whitespace returns null (caller ignores). */ export function parseRrcSlashInput(raw: string): RrcSlashResult | null { const text = raw.trim(); diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index d19080efd..d2f2e2cd3 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -3382,11 +3382,11 @@ "helpMsg": "/msg NICK|HASH text — direct NOTICE whisper (rrcd)", "helpClear": "/clear — clear local transcript for this room", "helpQuit": "/quit — disconnect from hub", - "helpHub": "Hub commands (rrcd; sent as MSG):", + "helpHub": "Hub commands (rrcd; sent as MSG). Current room is inserted when omitted:", "helpList": "/list — list registered public rooms", - "helpWho": "/who [room] — list members", - "helpTopic": "/topic, /mode, /kick, /op, … — room moderation (ops)", - "helpNote": "Other hubs may not support hub slash commands.", + "helpWho": "/who [room] — list members (bare /who uses the focused room)", + "helpTopic": "/topic [text], /mode +m, /op NICK, /kick NICK, … — room moderation (ops; rrcd wants /cmd ROOM …)", + "helpNote": "Replies with no room land in the focused room. Other hubs may not support these commands.", "usageNick": "Usage: /nick NAME", "usageJoin": "Usage: /join ROOM [key]", "usageMe": "Usage: /me ACTION", diff --git a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts index 66f0121a9..5ba3b20d0 100644 --- a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts @@ -47,6 +47,16 @@ describe('useReticulumRuntime RRC event routing (regression)', () => { expect(SOURCE).toMatch(/whoResult\.action === 'transcript'[\s\S]*?room = whoResult\.room/); }); + it('surfaces empty-K_ROOM NOTICE/ERROR into the focused room via resolveRrcHubScopedNoticeRoom', () => { + expect(SOURCE).toContain('resolveRrcHubScopedNoticeRoom'); + expect(SOURCE).toMatch( + /whoResult\.action === 'transcript'[\s\S]*?else if \(!isDirect\)[\s\S]*?resolveRrcHubScopedNoticeRoom/, + ); + expect(SOURCE).toMatch( + /\(kind === 'error' \|\| kind === 'system'\) && !isDirect[\s\S]*?resolveRrcHubScopedNoticeRoom/, + ); + }); + it('routes direct NOTICE into per-peer @hash DMs via applyRrcDirectMessageRoom', () => { expect(SOURCE).toContain('applyRrcDirectMessageRoom'); expect(SOURCE).toMatch(/applyRrcDirectMessageRoom\(\{[\s\S]*?openDm:/); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 00addd318..88dc67633 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -134,6 +134,7 @@ import { consumeRncpReceiveDestSharePending } from '@/renderer/lib/rncpReceiveDe import { applyRrcDirectMessageRoom } from '@/renderer/lib/rrcDirectMessageRoute'; import { isRrcRoomMuted, resolveRrcAlertType } from '@/renderer/lib/rrcMention'; import { + resolveRrcHubScopedNoticeRoom, resolveRrcInboundChatRoom, shouldDropEmptyRrcInbound, } from '@/renderer/lib/rrcMessageDisplay'; @@ -1212,7 +1213,18 @@ export function useReticulumRuntime(): ProtocolRuntime { } if (whoResult.action === 'transcript') { room = whoResult.room; + } else if (!isDirect) { + // Hub-global slash replies (/list, usage, not authorized) use empty K_ROOM. + room = resolveRrcHubScopedNoticeRoom( + typeof p.room === 'string' ? p.room : undefined, + view.activeRoom, + ); } + } else if ((kind === 'error' || kind === 'system') && !isDirect) { + room = resolveRrcHubScopedNoticeRoom( + typeof p.room === 'string' ? p.room : undefined, + view.activeRoom, + ); } // Opportunistic nicklist: room chat reveals senders even before `/who`. From d0c47af9c24c4d602d912cf1a1462b84b8ed82e0 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 16 Aug 2026 10:36:51 -0600 Subject: [PATCH 3/3] fix(rrc): hub-scoped error routing, combined /mode flags, behavioral tests Route rrc.error through resolveRrcHubScopedNoticeRoom so DM focus does not swallow hub errors; accept multi-character /mode flags (+im/-ov); replace empty-K_ROOM source-contract asserts with event-dispatch coverage. --- src/renderer/lib/rrcSlashCommands.test.ts | 2 + src/renderer/lib/rrcSlashCommands.ts | 3 +- .../runtime/useReticulumRuntime.rrc.test.ts | 194 ++++++++++++++++-- src/renderer/runtime/useReticulumRuntime.ts | 2 +- 4 files changed, 186 insertions(+), 15 deletions(-) diff --git a/src/renderer/lib/rrcSlashCommands.test.ts b/src/renderer/lib/rrcSlashCommands.test.ts index 36e06553f..358bb52bf 100644 --- a/src/renderer/lib/rrcSlashCommands.test.ts +++ b/src/renderer/lib/rrcSlashCommands.test.ts @@ -73,6 +73,8 @@ describe('expandRrcHubSlashBody', () => { expect(expandRrcHubSlashBody('/op alice', 'general')).toBe('/op general alice'); expect(expandRrcHubSlashBody('/topic hi there', 'general')).toBe('/topic general hi there'); expect(expandRrcHubSlashBody('/mode +m', 'general')).toBe('/mode general +m'); + expect(expandRrcHubSlashBody('/mode +im', 'general')).toBe('/mode general +im'); + expect(expandRrcHubSlashBody('/mode -ov', 'general')).toBe('/mode general -ov'); expect(expandRrcHubSlashBody('/kick bob', 'general')).toBe('/kick general bob'); expect(expandRrcHubSlashBody('/ban add aabb', 'general')).toBe('/ban general add aabb'); expect(expandRrcHubSlashBody('/invite list', 'lobby')).toBe('/invite lobby list'); diff --git a/src/renderer/lib/rrcSlashCommands.ts b/src/renderer/lib/rrcSlashCommands.ts index f5314728a..272634dfb 100644 --- a/src/renderer/lib/rrcSlashCommands.ts +++ b/src/renderer/lib/rrcSlashCommands.ts @@ -49,7 +49,8 @@ export function isRrcSlashExpandableRoom(room: string | null | undefined): boole } function looksLikeRrcModeFlag(token: string): boolean { - return /^[+-][mitnkprovr]$/i.test(token.trim()) || /^[+-][kv]$/i.test(token.trim()); + // Single or combined IRC-style flags (+m, +im, -ov, …). + return /^[+-][mitnkprovr]+$/i.test(token.trim()); } function looksLikeRrcBanInviteOp(token: string): boolean { diff --git a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts index 5ba3b20d0..1886bcb08 100644 --- a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts @@ -1,13 +1,43 @@ // @vitest-environment jsdom /** - * Source contract tests for RRC multi-hub WebSocket event routing. + * Source contract + executable ingest tests for RRC multi-hub WebSocket event routing. */ -import { describe, expect, it } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; +import { resetReticulumManualStackStopSuppressForTests } from '@/renderer/lib/reticulum/reticulumManualStackStopSuppress'; +import { rrcDmRoomKey } from '@/renderer/lib/rrcDmRoom'; +import { RRC_HUB_STREAM_ROOM } from '@/renderer/lib/rrcRoomName'; +import { loadRuntimeSource } from '@/renderer/lib/sourceContractTestHelpers'; +import { useReticulumRuntime } from '@/renderer/runtime/useReticulumRuntime'; +import { useRrcSessionStore } from '@/renderer/stores/rrcSessionStore'; +import type { ReticulumSidecarEvent } from '@/shared/reticulum-types'; + +vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ + fetchRecentInboundLxmf: vi.fn().mockResolvedValue([]), + fetchRecentInboundLxmfDetailed: vi.fn().mockResolvedValue({ messages: [], ringLen: 0 }), +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumNobleBleYieldWatcher', () => ({ + useReticulumNobleBleYieldWatcher: () => {}, +})); + +vi.mock('@/renderer/lib/reticulum/useReticulumPropagationAutoSync', () => ({ + useReticulumPropagationAutoSync: () => {}, +})); + +vi.mock('@/renderer/components/Toast', () => ({ + pushAppToast: vi.fn(), + useToast: () => ({ addToast: vi.fn() }), +})); const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); +const HUB = '28c7c1a68c735693aa8e6b8193ed44b2'; +const PEER = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const SELF = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const DM_ROOM = rrcDmRoomKey(PEER); + describe('useReticulumRuntime RRC event routing (regression)', () => { it('honors will_reconnect=false by clearing the hub session', () => { expect(SOURCE).toMatch(/will_reconnect\?: boolean/); @@ -47,16 +77,6 @@ describe('useReticulumRuntime RRC event routing (regression)', () => { expect(SOURCE).toMatch(/whoResult\.action === 'transcript'[\s\S]*?room = whoResult\.room/); }); - it('surfaces empty-K_ROOM NOTICE/ERROR into the focused room via resolveRrcHubScopedNoticeRoom', () => { - expect(SOURCE).toContain('resolveRrcHubScopedNoticeRoom'); - expect(SOURCE).toMatch( - /whoResult\.action === 'transcript'[\s\S]*?else if \(!isDirect\)[\s\S]*?resolveRrcHubScopedNoticeRoom/, - ); - expect(SOURCE).toMatch( - /\(kind === 'error' \|\| kind === 'system'\) && !isDirect[\s\S]*?resolveRrcHubScopedNoticeRoom/, - ); - }); - it('routes direct NOTICE into per-peer @hash DMs via applyRrcDirectMessageRoom', () => { expect(SOURCE).toContain('applyRrcDirectMessageRoom'); expect(SOURCE).toMatch(/applyRrcDirectMessageRoom\(\{[\s\S]*?openDm:/); @@ -102,3 +122,151 @@ describe('useReticulumRuntime RRC event routing (regression)', () => { expect(SOURCE).toMatch(/will_reconnect='/); }); }); + +describe('useReticulumRuntime RRC empty-K_ROOM hub-scoped routing', () => { + let eventHandler: ((evt: ReticulumSidecarEvent) => void) | null = null; + + beforeEach(() => { + resetReticulumManualStackStopSuppressForTests(); + useRrcSessionStore.getState().clearSession(); + useRrcSessionStore.getState().setNickname('nv0n'); + useRrcSessionStore.getState().setLocalIdentityHash(SELF); + useRrcSessionStore.getState().applyStatus('active', HUB, 'Community'); + useRrcSessionStore.getState().roomJoined('general'); + useRrcSessionStore.getState().setActiveRoom('general'); + eventHandler = null; + vi.mocked(window.electronAPI.db.insertRrcMessage).mockReset(); + vi.mocked(window.electronAPI.db.insertRrcMessage).mockResolvedValue({ changes: 1 }); + vi.mocked(window.electronAPI.reticulum.onEvent).mockImplementation((cb) => { + eventHandler = cb; + return () => { + if (eventHandler === cb) eventHandler = null; + }; + }); + vi.mocked(window.electronAPI.reticulum.start).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + }); + vi.mocked(window.electronAPI.reticulum.stop).mockResolvedValue(undefined); + vi.mocked(window.electronAPI.reticulum.getStatus).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + healthy: true, + }); + }); + + afterEach(() => { + vi.mocked(window.electronAPI.reticulum.onEvent).mockReset(); + vi.mocked(window.electronAPI.reticulum.onEvent).mockReturnValue(() => {}); + useRrcSessionStore.getState().clearSession(); + }); + + async function connectAndGetOnEvent() { + const { result, unmount } = renderHook(() => useReticulumRuntime()); + await act(async () => { + await result.current.connect(); + }); + expect(eventHandler).toBeTruthy(); + return { onEvent: eventHandler!, unmount }; + } + + function roomBodies(room: string): string[] { + const key = useRrcSessionStore.getState().roomMessageKey(room, HUB); + return (useRrcSessionStore.getState().messages.get(key ?? '') ?? []).map((m) => m.body); + } + + function sendEmptyRoomMessage( + onEvent: (evt: ReticulumSidecarEvent) => void, + kind: 'notice' | 'error' | 'system', + body: string, + id: string, + ): void { + act(() => { + onEvent({ + type: 'rrc.message', + payload: { + id, + hub_dest_hash: HUB, + room: '', + kind, + body, + sender_hash: PEER, + timestamp: Date.now(), + }, + }); + }); + } + + it.each(['notice', 'error', 'system'] as const)( + 'stores empty-room %s in the focused real room', + async (kind) => { + const { onEvent, unmount } = await connectAndGetOnEvent(); + const body = `hub-${kind}-reply`; + sendEmptyRoomMessage(onEvent, kind, body, `${kind}-real`); + expect(roomBodies('general')).toContain(body); + expect(roomBodies(RRC_HUB_STREAM_ROOM)).not.toContain(body); + unmount(); + }, + ); + + it.each(['notice', 'error', 'system'] as const)( + 'stores empty-room %s in [hub] when a DM is focused', + async (kind) => { + useRrcSessionStore.getState().openDm({ identity_hash: PEER, nickname: 'Bob' }, HUB, { + focus: true, + }); + expect(useRrcSessionStore.getState().activeRoom).toBe(DM_ROOM); + const { onEvent, unmount } = await connectAndGetOnEvent(); + const body = `dm-focus-${kind}`; + sendEmptyRoomMessage(onEvent, kind, body, `${kind}-dm`); + expect(roomBodies(RRC_HUB_STREAM_ROOM)).toContain(body); + expect(roomBodies(DM_ROOM)).not.toContain(body); + expect(roomBodies('general')).not.toContain(body); + unmount(); + }, + ); + + it.each(['notice', 'error', 'system'] as const)( + 'stores empty-room %s in [hub] when synthetic focus is active', + async (kind) => { + useRrcSessionStore.getState().setActiveRoom(RRC_HUB_STREAM_ROOM); + const { onEvent, unmount } = await connectAndGetOnEvent(); + const body = `synth-focus-${kind}`; + sendEmptyRoomMessage(onEvent, kind, body, `${kind}-synth`); + expect(roomBodies(RRC_HUB_STREAM_ROOM)).toContain(body); + expect(roomBodies('general')).not.toContain(body); + unmount(); + }, + ); + + it('routes rrc.error into the focused real room via resolveRrcHubScopedNoticeRoom', async () => { + const { onEvent, unmount } = await connectAndGetOnEvent(); + act(() => { + onEvent({ + type: 'rrc.error', + payload: { message: 'link proof timeout', hub_dest_hash: HUB }, + }); + }); + expect(roomBodies('general')).toContain('link proof timeout'); + expect(roomBodies(RRC_HUB_STREAM_ROOM)).not.toContain('link proof timeout'); + unmount(); + }); + + it('routes rrc.error into [hub] when a DM is focused', async () => { + useRrcSessionStore.getState().openDm({ identity_hash: PEER, nickname: 'Bob' }, HUB, { + focus: true, + }); + const { onEvent, unmount } = await connectAndGetOnEvent(); + act(() => { + onEvent({ + type: 'rrc.error', + payload: { message: 'path timeout', hub_dest_hash: HUB }, + }); + }); + expect(roomBodies(RRC_HUB_STREAM_ROOM)).toContain('path timeout'); + expect(roomBodies(DM_ROOM)).not.toContain('path timeout'); + unmount(); + }); +}); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 88dc67633..16c3f000a 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -1295,7 +1295,7 @@ export function useReticulumRuntime(): ProtocolRuntime { session.addMessage( { id: `err-${Date.now()}`, - room: view.activeRoom ?? RRC_HUB_STREAM_ROOM, + room: resolveRrcHubScopedNoticeRoom(undefined, view.activeRoom), kind: 'error', body: p.message, timestamp: Date.now(),