Merge remote-tracking branch 'origin/main' into fred777-add_bot_globals

This commit is contained in:
fred777
2026-06-12 16:34:00 +02:00
14 changed files with 631 additions and 146 deletions
+13
View File
@@ -1,3 +1,16 @@
## [3.15.0] - 2026-06-11
* Feature: Enhanced repeater telemetry with scrubbing and better extents
* Feature: Outbound message opt-in for Apprise
* Feature: Reverse-link button on trace pane
* Feature: Add recently traced contacts as own category in repeater pane
* Feature: More compact trace pane display
* Bugfix: Scavenge ACK codes for standalone acks, resolving issues with DM ack detection
* Bugfix: Proper timestamps for community MQTT
* Bugfix: Clearer packet history legend in packet view
* Misc: Add pubkey suffix to repeater neighbors
* Misc: Dependency bumps & test fixes
## [3.14.1] - 2026-06-01
* Feature: Enhance online documentation
+1 -1
View File
@@ -277,7 +277,7 @@ Apache License
</details>
### fastapi (0.128.0) — MIT
### fastapi (0.136.3) — MIT
<details>
<summary>Full license text</summary>
+1
View File
@@ -132,6 +132,7 @@ HTTP webhook delivery. Config blob:
Push notifications via Apprise library. Config blob:
- `urls` — newline-separated Apprise notification service URLs
- `preserve_identity` — suppress Discord webhook name/avatar override
- `include_outgoing` — when true, RemoteTerm-originated manual and bot-sent messages are forwarded to Apprise; missing/false preserves the legacy incoming-only behavior
- `include_path` — include routing path in notification body
- Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name.
+4 -3
View File
@@ -188,14 +188,15 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool, markdown: b
class AppriseModule(FanoutModule):
"""Sends push notifications via Apprise for incoming messages."""
"""Sends push notifications via Apprise for matched messages."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
async def on_message(self, data: dict) -> None:
# Skip outgoing messages — only notify on incoming
if data.get("outgoing"):
# Skip outgoing messages by default. Operators can opt in when they
# want RemoteTerm-originated manual/bot sends mirrored to Apprise.
if data.get("outgoing") and not self.config.get("include_outgoing", False):
return
urls = self.config.get("urls", "")
+2 -3
View File
@@ -274,9 +274,8 @@ def _validate_apprise_config(config: dict) -> None:
status_code=400, detail=f"Invalid format string in {field}"
) from None
markdown_format = config.get("markdown_format")
if markdown_format is not None:
config["markdown_format"] = bool(markdown_format)
config["markdown_format"] = bool(config.get("markdown_format", True))
config["include_outgoing"] = bool(config.get("include_outgoing", False))
def _validate_webhook_config(config: dict) -> None:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.14.1",
"version": "3.15.0",
"type": "module",
"scripts": {
"dev": "vite",
+238 -122
View File
@@ -26,11 +26,14 @@ import {
import { Input } from './ui/input';
import { cn } from '@/lib/utils';
type TraceSortMode = 'alpha' | 'recent' | 'distance';
type TraceSortMode = 'alpha' | 'recent' | 'distance' | 'traced';
type CustomHopBytes = 1 | 2 | 4;
const RECENT_TRACES_KEY = 'remoteterm-recent-traces';
const MAX_RECENT_TRACES = 5;
const RECENT_NODES_KEY = 'remoteterm-recent-trace-nodes';
const MAX_RECENT_NODES = 30;
const MAX_RENDERED_REPEATERS = 60;
interface SavedTraceHop {
kind: 'repeater' | 'custom';
@@ -71,6 +74,57 @@ function saveRecentTrace(trace: SavedTrace): void {
}
}
function repeaterKeysFromHops(hops: SavedTraceHop[]): string[] {
return [
...new Set(
hops
.filter((hop) => hop.kind === 'repeater' && hop.publicKey)
.map((hop) => hop.publicKey as string)
),
];
}
function loadRecentNodeKeys(): string[] {
try {
const raw = localStorage.getItem(RECENT_NODES_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return [
...new Set(
parsed
.map((entry) =>
typeof entry === 'string' ? entry : ((entry?.publicKey as string) ?? null)
)
.filter((key): key is string => typeof key === 'string' && key.length > 0)
),
].slice(0, MAX_RECENT_NODES);
}
// No usage history yet: seed from already-stored recent traces so the
// Recent Traced sort works immediately for users with existing history.
return repeaterKeysFromHops(loadRecentTraces().flatMap((trace) => trace.hops)).slice(
0,
MAX_RECENT_NODES
);
} catch {
return [];
}
}
function saveRecentNodeKeys(hops: SavedTraceHop[]): void {
try {
// MRU order: repeaters from this trace first, then prior history.
const fresh = repeaterKeysFromHops(hops);
const rest = loadRecentNodeKeys().filter((key) => !fresh.includes(key));
localStorage.setItem(
RECENT_NODES_KEY,
JSON.stringify([...fresh, ...rest].slice(0, MAX_RECENT_NODES))
);
} catch {
// localStorage may be disabled
}
}
type TraceDraftHop =
| { id: string; kind: 'repeater'; publicKey: string }
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
@@ -136,49 +190,45 @@ function nextDraftHopId(prefix: string, currentLength: number): string {
function TraceNodeRow({
title,
subtitle,
badge,
meta,
note,
fixed = false,
compact = false,
actions,
snr,
}: {
title: string;
subtitle: string;
badge?: string;
meta?: string | null;
note?: string | null;
fixed?: boolean;
compact?: boolean;
actions?: ReactNode;
snr?: string | null;
}) {
return (
<div
className={cn(
'flex items-center rounded-md border border-border bg-background',
compact ? 'gap-2 px-2.5 py-2' : 'gap-3 px-3 py-3'
)}
>
<div className="flex items-center gap-2 rounded-md border border-border bg-background px-2.5 py-2">
<div
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full border text-[0.6875rem] font-semibold uppercase tracking-wide',
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-[0.625rem] font-semibold uppercase tracking-wide',
fixed
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-muted text-muted-foreground'
)}
>
{fixed ? 'Self' : 'Hop'}
{fixed ? 'Self' : (badge ?? 'Hop')}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div>
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
{meta ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{meta}</div> : null}
{note ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{note}</div> : null}
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-sm font-medium">{title}</span>
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
{meta ? (
<span className="shrink-0 text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
{meta}
</span>
) : null}
</div>
{snr ? (
<div className="shrink-0 text-right">
<div className="text-[0.6875rem] text-muted-foreground">SNR</div>
<div className="font-mono text-sm">{snr}</div>
<div className="flex shrink-0 items-baseline gap-1">
<span className="text-[0.6875rem] text-muted-foreground">SNR</span>
<span className="font-mono text-sm">{snr}</span>
</div>
) : null}
{actions ? <div className="ml-1 flex items-center gap-1">{actions}</div> : null}
@@ -200,6 +250,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
const [customHopError, setCustomHopError] = useState<string | null>(null);
const [recentTraces, setRecentTraces] = useState<SavedTrace[]>(loadRecentTraces);
const [recentTracesOpen, setRecentTracesOpen] = useState(false);
const [recentNodeKeys, setRecentNodeKeys] = useState<string[]>(loadRecentNodeKeys);
const activeRunTokenRef = useRef(0);
const repeaters = useMemo(() => {
@@ -220,9 +271,16 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
[repeaters]
);
const tracedIndexByKey = useMemo(
() => new Map(recentNodeKeys.map((key, index) => [key, index])),
[recentNodeKeys]
);
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
const filteredRepeaters = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const matching = query
let matching = query
? repeaters.filter(
(contact) =>
(contact.name ?? '').toLowerCase().includes(query) ||
@@ -230,7 +288,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
)
: repeaters;
// Traced shows only repeaters actually used in traces; Dist. shows only
// repeaters with a computable distance (when the local radio has one).
if (sortMode === 'traced') {
matching = matching.filter((contact) => tracedIndexByKey.has(contact.public_key));
}
const distanceByKey =
sortMode === 'distance'
? new Map(matching.map((contact) => [contact.public_key, getDistanceKm(contact, config)]))
: null;
if (distanceByKey && canSortByDistance) {
matching = matching.filter((contact) => distanceByKey.get(contact.public_key) !== null);
}
return [...matching].sort((left, right) => {
if (sortMode === 'traced') {
const leftIndex = tracedIndexByKey.get(left.public_key) ?? Infinity;
const rightIndex = tracedIndexByKey.get(right.public_key) ?? Infinity;
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
}
if (sortMode === 'recent') {
const leftTs = getHeardTimestamp(left);
const rightTs = getHeardTimestamp(right);
@@ -238,9 +316,9 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
return rightTs - leftTs;
}
}
if (sortMode === 'distance') {
const leftDistance = getDistanceKm(left, config);
const rightDistance = getDistanceKm(right, config);
if (distanceByKey) {
const leftDistance = distanceByKey.get(left.public_key) ?? null;
const rightDistance = distanceByKey.get(right.public_key) ?? null;
if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) {
return leftDistance - rightDistance;
}
@@ -251,11 +329,15 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
getContactDisplayName(right.name, right.public_key, right.last_advert)
);
});
}, [config, repeaters, searchQuery, sortMode]);
}, [canSortByDistance, config, repeaters, searchQuery, sortMode, tracedIndexByKey]);
const visibleRepeaters = useMemo(
() => filteredRepeaters.slice(0, MAX_RENDERED_REPEATERS),
[filteredRepeaters]
);
const localRadioName = config?.name || 'Local radio';
const localRadioKey = config?.public_key ?? null;
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
const customHopBytesLocked = useMemo(
() => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null,
[draftHops]
@@ -318,6 +400,33 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
clearPendingResult();
};
// Append the reversed hop chain (minus the current endpoint) to build a return
// path, e.g. [R1, R2, R3] -> [R1, R2, R3, R2, R1]. A single hop is left as-is.
// See issue #287. Reverses every queued hop, including custom prefixes.
const handleReverseLink = () => {
setDraftHops((current) => {
if (current.length < 2) return current;
const returnHops = [...current]
.reverse()
.slice(1)
.map(
(hop, i): TraceDraftHop => ({
...hop,
id: nextDraftHopId(hop.kind, current.length + i),
})
);
return [...current, ...returnHops];
});
clearPendingResult();
};
const recordTraceRun = (hops: SavedTraceHop[]) => {
saveRecentTrace({ hops, ranAt: Date.now() });
setRecentTraces(loadRecentTraces());
saveRecentNodeKeys(hops);
setRecentNodeKeys(loadRecentNodeKeys());
};
const handleLoadRecentTrace = async (trace: SavedTrace) => {
const hops: TraceDraftHop[] = trace.hops.map((h, i) => {
if (h.kind === 'repeater' && h.publicKey) {
@@ -356,10 +465,8 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
if (activeRunTokenRef.current !== runToken) return;
setResult(traceResult);
// Re-save to bump this trace to the top of recents
const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() };
saveRecentTrace(savedTrace);
setRecentTraces(loadRecentTraces());
// Re-save to bump this trace and its nodes to the top of recents
recordTraceRun(trace.hops);
} catch (err) {
if (activeRunTokenRef.current !== runToken) return;
setError(err instanceof Error ? err.message : 'Unknown error');
@@ -406,9 +513,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`,
};
});
const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() };
saveRecentTrace(trace);
setRecentTraces(loadRecentTraces());
recordTraceRun(savedHops);
} catch (err) {
if (activeRunTokenRef.current !== runToken) {
return;
@@ -470,16 +575,18 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<div className="mt-3 flex flex-wrap gap-2">
{(
[
['alpha', 'Alpha'],
['recent', 'Recent Heard'],
['distance', 'Distance'],
['alpha', 'A/Z', 'Sort alphabetically'],
['recent', 'Heard', 'Most recently heard first'],
['traced', 'Traced', 'Most recently used in traces first'],
['distance', 'Dist.', 'Closest first'],
] as const
).map(([value, label]) => (
).map(([value, label, description]) => (
<Button
key={value}
type="button"
size="sm"
variant={sortMode === value ? 'default' : 'outline'}
title={description}
onClick={() => setSortMode(value)}
>
{label}
@@ -497,11 +604,17 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<div className="max-h-[40vh] overflow-y-auto p-2 lg:min-h-0 lg:max-h-none lg:flex-1">
{filteredRepeaters.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
No repeaters matched this search.
{sortMode === 'traced' && recentNodeKeys.length === 0
? 'No repeaters have been used in traces yet. Run a trace and its repeaters will show up here.'
: sortMode === 'traced'
? 'No known repeaters match your recent trace history.'
: sortMode === 'distance' && canSortByDistance
? 'No repeaters with a known distance matched this search.'
: 'No repeaters matched this search.'}
</div>
) : (
<div className="space-y-2">
{filteredRepeaters.map((contact) => {
{visibleRepeaters.map((contact) => {
const displayName = getContactDisplayName(
contact.name,
contact.public_key,
@@ -557,6 +670,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
</div>
);
})}
{filteredRepeaters.length > MAX_RENDERED_REPEATERS ? (
<p className="px-1 pt-1 text-center text-[0.6875rem] text-muted-foreground">
Showing the first {MAX_RENDERED_REPEATERS} of {filteredRepeaters.length}{' '}
repeaters. Search to narrow the list.
</p>
) : null}
</div>
)}
</div>
@@ -616,18 +735,31 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
)}
</div>
{draftHops.length > 0 ? (
<Button
type="button"
size="sm"
variant="ghost"
className="shrink-0 text-muted-foreground"
onClick={() => {
setDraftHops([]);
clearPendingResult();
}}
>
Clear
</Button>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground"
onClick={handleReverseLink}
disabled={draftHops.length < 2}
title="Append the reversed hop chain to build a return path"
>
Reverse link
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground"
onClick={() => {
setDraftHops([]);
clearPendingResult();
}}
>
Clear
</Button>
</div>
) : null}
</div>
<div className="space-y-2 p-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
@@ -636,7 +768,6 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
subtitle={getShortKey(localRadioKey)}
meta="Origin"
fixed
compact
/>
{draftHops.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
@@ -663,13 +794,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<TraceNodeRow
title={displayName}
subtitle={subtitle}
meta={`Hop ${index + 1}`}
note={
index === draftHops.length - 1
? 'Note: you must be able to hear the final repeater in the trace for trace success.'
: null
}
compact
badge={String(index + 1)}
actions={
<>
<Button
@@ -716,14 +841,13 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
subtitle={getShortKey(localRadioKey)}
meta="Terminal"
fixed
compact
/>
</div>
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
<div className="text-xs text-muted-foreground">
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
{draftHops.length === 0
? 'No hops selected'
: `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`}
: `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace · you must be able to hear the final repeater for trace success`}
</div>
<Button onClick={handleRunTrace} disabled={loading || draftHops.length === 0}>
{loading ? 'Tracing...' : 'Send trace'}
@@ -731,12 +855,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
</div>
</div>
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
</h3>
{result || error ? (
{result || error ? (
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
</h3>
<Button
type="button"
size="sm"
@@ -749,60 +873,52 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
>
Clear
</Button>
) : null}
</div>
<div className="min-h-0 flex-1 space-y-2 p-4 lg:overflow-y-auto">
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
{result
? resultNodes.map((node, index) => {
const title =
node.name ||
(node.role === 'custom'
? 'Custom hop'
: node.role === 'local'
? localRadioName
: getShortKey(node.public_key));
const subtitle =
node.role === 'custom'
? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}`
: node.observed_hash &&
node.public_key &&
node.observed_hash.toLowerCase() !==
getShortKey(node.public_key).toLowerCase()
? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}`
: getShortKey(node.public_key);
return (
<div
key={`${node.role}-${node.public_key ?? node.observed_hash ?? 'local'}-${index}`}
>
<TraceNodeRow
title={title}
subtitle={subtitle}
badge={String(index)}
meta={
index === 0 ? 'Origin' : node.role === 'local' ? 'Terminal' : null
}
fixed={node.role === 'local'}
snr={index === 0 ? null : formatSNR(node.snr)}
/>
</div>
);
})
: null}
</div>
</div>
<div className="min-h-0 flex-1 space-y-3 p-4 lg:overflow-y-auto">
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
{!error && !result ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Send a trace to see the returned hop-by-hop SNR values.
</div>
) : null}
{result
? resultNodes.map((node, index) => {
const title =
node.name ||
(node.role === 'custom'
? 'Custom hop'
: node.role === 'local'
? localRadioName
: getShortKey(node.public_key));
const subtitle =
node.role === 'custom'
? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}`
: node.observed_hash &&
node.public_key &&
node.observed_hash.toLowerCase() !==
getShortKey(node.public_key).toLowerCase()
? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}`
: getShortKey(node.public_key);
return (
<div
key={`${node.role}-${node.public_key ?? node.observed_hash ?? 'local'}-${index}`}
>
<TraceNodeRow
title={title}
subtitle={subtitle}
meta={
index === 0
? 'Origin'
: node.role === 'local'
? 'Terminal'
: `Hop ${index}`
}
fixed={node.role === 'local'}
snr={index === 0 ? null : formatSNR(node.snr)}
/>
</div>
);
})
: null}
</div>
</div>
) : null}
</section>
</div>
@@ -296,6 +296,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
config: {
urls: '',
preserve_identity: true,
include_outgoing: false,
markdown_format: true,
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
body_format_channel:
@@ -2599,6 +2600,23 @@ function AppriseConfigEditor({
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.include_outgoing === true}
onChange={(e) => onChange({ ...config, include_outgoing: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
<div>
<span className="text-sm">Forward RemoteTerm-sent messages</span>
<p className="text-[0.8125rem] text-muted-foreground">
Include DMs and channel messages sent by this RemoteTerm instance, including manual
sends and bot replies. Outgoing messages carry no routing path or signal data, so
path-related format fields render as direct and RSSI/SNR are empty.
</p>
</div>
</label>
<Separator />
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
+101
View File
@@ -561,6 +561,107 @@ describe('SettingsFanoutSection', () => {
);
});
it('creates Apprise with outgoing forwarding disabled by default', async () => {
const createdApprise: FanoutConfig = {
id: 'ap-new',
type: 'apprise',
name: 'Apprise #1',
enabled: true,
config: {
urls: '',
preserve_identity: true,
include_outgoing: false,
markdown_format: true,
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
body_format_channel:
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
},
scope: { messages: 'all', raw_packets: 'none' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdApprise);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdApprise]);
renderSection();
await openCreateIntegrationDialog();
selectCreateIntegration('Apprise');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
expect(screen.getByLabelText(/Forward RemoteTerm-sent messages/)).not.toBeChecked();
expect(
screen.getByText(/Outgoing messages carry no routing path or signal data/)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'apprise',
name: 'Apprise #1',
config: {
urls: '',
preserve_identity: true,
include_outgoing: false,
markdown_format: true,
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
body_format_channel:
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
},
scope: { messages: 'all', raw_packets: 'none' },
enabled: true,
})
);
});
it('can enable outgoing forwarding for an existing Apprise integration', async () => {
const appriseConfig: FanoutConfig = {
id: 'ap-1',
type: 'apprise',
name: 'Apprise Feed',
enabled: true,
config: {
urls: 'discord://abc',
preserve_identity: true,
markdown_format: true,
},
scope: { messages: 'all', raw_packets: 'none' },
sort_order: 0,
created_at: 1000,
};
mockedApi.getFanoutConfigs.mockResolvedValue([appriseConfig]);
mockedApi.updateFanoutConfig.mockResolvedValue({
...appriseConfig,
config: { ...appriseConfig.config, include_outgoing: true },
});
renderSection();
await waitFor(() => expect(screen.getByText('Apprise Feed')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
const includeOutgoing = screen.getByLabelText(/Forward RemoteTerm-sent messages/);
expect(includeOutgoing).not.toBeChecked();
fireEvent.click(includeOutgoing);
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('ap-1', {
name: 'Apprise Feed',
config: {
urls: 'discord://abc',
preserve_identity: true,
markdown_format: true,
include_outgoing: true,
},
scope: { messages: 'all', raw_packets: 'none' },
enabled: true,
})
);
});
it('new draft names increment within the integration type', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([
webhookConfig,
+177 -9
View File
@@ -1,5 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TracePane } from '../components/TracePane';
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
@@ -45,6 +45,10 @@ const config: RadioConfig = {
};
describe('TracePane', () => {
beforeEach(() => {
localStorage.clear();
});
it('shows only full-key repeaters and filters by name or key', () => {
render(
<TracePane
@@ -112,7 +116,7 @@ describe('TracePane', () => {
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText(/2 hops selected · 4-byte trace/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
@@ -129,11 +133,54 @@ describe('TracePane', () => {
expect(screen.getByText('+5.0 dB')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i }));
expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText(/1 hop selected · 4-byte trace/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
expect(screen.getByText('No hops selected')).toBeInTheDocument();
});
it('reverse link appends the reversed hop chain to build a return path (issue #287)', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
const relayC = makeContact('33'.repeat(32), 'Relay Charlie');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 0,
timeout_seconds: 6,
nodes: [],
})
);
render(
<TracePane
config={config}
onRunTracePath={onRunTracePath}
contacts={[relayA, relayB, relayC]}
/>
);
// Single hop: Reverse link is a no-op (and disabled).
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
expect(screen.getByRole('button', { name: /reverse link/i })).toBeDisabled();
// R1, R2, R3 -> append R2, R1 => R1, R2, R3, R2, R1.
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay charlie/i }));
fireEvent.click(screen.getByRole('button', { name: /reverse link/i }));
expect(screen.getByText(/5 hops selected · 4-byte trace/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [
{ public_key: relayA.public_key },
{ public_key: relayB.public_key },
{ public_key: relayC.public_key },
{ public_key: relayB.public_key },
{ public_key: relayA.public_key },
]);
});
});
it('allows adding the same repeater multiple times from the picker row', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
@@ -142,7 +189,7 @@ describe('TracePane', () => {
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText(/2 hops selected · 4-byte trace/)).toBeInTheDocument();
expect(screen.getByText('Added 2 times')).toBeInTheDocument();
});
@@ -185,7 +232,7 @@ describe('TracePane', () => {
fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } });
fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' }));
expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument();
expect(screen.getByText(/1 hop selected · 1-byte trace/)).toBeInTheDocument();
expect(screen.getByText('AE (1-byte)')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
@@ -204,6 +251,128 @@ describe('TracePane', () => {
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
});
it('Traced lists only trace-used repeaters in MRU order, persisted locally (issue #286)', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
const relayC = makeContact('33'.repeat(32), 'Relay Charlie');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 0,
timeout_seconds: 6,
nodes: [],
})
);
const { unmount } = render(
<TracePane
config={config}
onRunTracePath={onRunTracePath}
contacts={[relayA, relayB, relayC]}
/>
);
const rowNames = () =>
screen
.queryAllByRole('button', { name: /^add repeater/i })
.map((row) => row.getAttribute('aria-label'));
// No history yet: Traced shows an explanatory empty state, not the full list.
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(screen.getByText(/no repeaters have been used in traces yet/i)).toBeInTheDocument();
expect(rowNames()).toEqual([]);
// Build and run a trace with B from the A/Z list.
fireEvent.click(screen.getByRole('button', { name: 'A/Z' }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => expect(onRunTracePath).toHaveBeenCalledTimes(1));
// Traced lists only B; untraced A and C are filtered out.
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(rowNames()).toEqual(['Add repeater Relay Beta']);
// A second trace with C bumps it above B.
fireEvent.click(screen.getByRole('button', { name: 'A/Z' }));
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay charlie/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => expect(onRunTracePath).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(rowNames()).toEqual(['Add repeater Relay Charlie', 'Add repeater Relay Beta']);
// Order persists across remounts via localStorage.
unmount();
render(
<TracePane
config={config}
onRunTracePath={onRunTracePath}
contacts={[relayA, relayB, relayC]}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(rowNames()).toEqual(['Add repeater Relay Charlie', 'Add repeater Relay Beta']);
});
it('seeds Traced from stored recent traces when no usage history exists', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
localStorage.setItem(
'remoteterm-recent-traces',
JSON.stringify([
{
ranAt: 1,
hops: [
{ kind: 'repeater', publicKey: relayB.public_key, displayName: 'Relay Beta' },
{ kind: 'custom', hopHex: 'ae', hopBytes: 1, displayName: 'AE (1B)' },
],
},
])
);
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA, relayB]} />);
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(
screen
.getAllByRole('button', { name: /^add repeater/i })
.map((row) => row.getAttribute('aria-label'))
).toEqual(['Add repeater Relay Beta']);
});
it('Dist. hides repeaters without a known distance when the radio has a location', () => {
const located = makeContact('11'.repeat(32), 'Relay Located', CONTACT_TYPE_REPEATER, {
lat: 10.1,
lon: 20.1,
});
const unlocated = makeContact('22'.repeat(32), 'Relay Mystery');
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[located, unlocated]} />);
// A/Z shows both.
expect(screen.getByText('Relay Located')).toBeInTheDocument();
expect(screen.getByText('Relay Mystery')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Dist.' }));
expect(screen.getByText('Relay Located')).toBeInTheDocument();
expect(screen.queryByText('Relay Mystery')).not.toBeInTheDocument();
// Without a local radio location, the filter is skipped (note explains instead).
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'mystery' } });
expect(screen.getByText(/no repeaters with a known distance matched/i)).toBeInTheDocument();
});
it('caps the rendered repeater list and reports the overflow', () => {
const contacts = Array.from({ length: 70 }, (_, i) =>
makeContact(i.toString(16).padStart(2, '0').repeat(32), `Relay ${String(i).padStart(3, '0')}`)
);
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={contacts} />);
expect(screen.getAllByRole('button', { name: /^add repeater/i })).toHaveLength(60);
expect(screen.getByText(/showing the first 60 of 70 repeaters/i)).toBeInTheDocument();
});
it('drops an in-flight result after the draft path changes', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
@@ -228,7 +397,7 @@ describe('TracePane', () => {
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText(/2 hops selected · 4-byte trace/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled();
await act(async () => {
@@ -256,8 +425,7 @@ describe('TracePane', () => {
expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument();
expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument();
expect(
screen.getByText('Send a trace to see the returned hop-by-hop SNR values.')
).toBeInTheDocument();
// The Results section stays hidden entirely until a result or error lands.
expect(screen.queryByRole('heading', { name: /^results/i })).not.toBeInTheDocument();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.14.1"
version = "3.15.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.11"
+32 -3
View File
@@ -1017,7 +1017,7 @@ class TestAppriseModule:
assert mod.status == "connected"
@pytest.mark.asyncio
async def test_skips_outgoing_messages(self):
async def test_skips_outgoing_messages_by_default(self):
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
@@ -1027,6 +1027,19 @@ class TestAppriseModule:
await mod.on_message({"type": "PRIV", "text": "hi", "outgoing": True})
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_sends_outgoing_messages_when_enabled(self):
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule("test", {"urls": "json://localhost", "include_outgoing": True})
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{"type": "PRIV", "text": "hi", "outgoing": True, "sender_name": "Me"}
)
mock_send.assert_called_once()
@pytest.mark.asyncio
async def test_sends_for_incoming_messages(self):
from unittest.mock import patch as _patch
@@ -1379,10 +1392,26 @@ class TestAppriseValidation:
_validate_apprise_config(config)
assert config["markdown_format"] is False
def test_validate_apprise_config_works_without_markdown_format(self):
def test_validate_apprise_config_defaults_markdown_format_true(self):
from app.routers.fanout import _validate_apprise_config
_validate_apprise_config({"urls": "discord://123/abc"})
config: dict = {"urls": "discord://123/abc"}
_validate_apprise_config(config)
assert config["markdown_format"] is True
def test_validate_apprise_config_defaults_include_outgoing_false(self):
from app.routers.fanout import _validate_apprise_config
config: dict = {"urls": "discord://123/abc"}
_validate_apprise_config(config)
assert config["include_outgoing"] is False
def test_validate_apprise_config_normalizes_include_outgoing(self):
from app.routers.fanout import _validate_apprise_config
config: dict = {"urls": "discord://123/abc", "include_outgoing": 1}
_validate_apprise_config(config)
assert config["include_outgoing"] is True
class TestAppriseMarkdownFormat:
+41 -2
View File
@@ -1247,8 +1247,8 @@ class TestFanoutAppriseIntegration:
assert "#general" in body_text
@pytest.mark.asyncio
async def test_apprise_skips_outgoing(self, apprise_capture_server, integration_db):
"""Apprise should NOT deliver outgoing messages."""
async def test_apprise_skips_outgoing_by_default(self, apprise_capture_server, integration_db):
"""Apprise should NOT deliver outgoing messages unless explicitly enabled."""
cfg = await FanoutConfigRepository.create(
config_type="apprise",
name="No Outgoing",
@@ -1280,6 +1280,45 @@ class TestFanoutAppriseIntegration:
assert len(apprise_capture_server.received) == 0
@pytest.mark.asyncio
async def test_apprise_delivers_outgoing_when_enabled(
self, apprise_capture_server, integration_db
):
"""Apprise can opt in to delivering RemoteTerm-originated messages."""
cfg = await FanoutConfigRepository.create(
config_type="apprise",
name="Include Outgoing",
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"include_outgoing": True,
},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
)
manager = FanoutManager()
try:
await manager.load_from_db()
assert cfg["id"] in manager._modules
await manager.broadcast_message(
{
"type": "PRIV",
"conversation_key": "pk1",
"text": "my outgoing",
"sender_name": "Me",
"outgoing": True,
}
)
results = await apprise_capture_server.wait_for(1)
finally:
await manager.stop_all()
assert len(results) >= 1
body_text = str(results[0])
assert "my outgoing" in body_text
@pytest.mark.asyncio
async def test_apprise_disabled_no_delivery(self, apprise_capture_server, integration_db):
"""Disabled Apprise module should not deliver anything."""
Generated
+1 -1
View File
@@ -1550,7 +1550,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.14.1"
version = "3.15.0"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },