Implement repeater CLI interface

This commit is contained in:
Jack Kingsman
2026-01-09 22:58:46 -08:00
parent e401a32049
commit e262bd677a
18 changed files with 1155 additions and 617 deletions
+50 -8
View File
@@ -144,6 +144,9 @@ await api.reconnectRadio(); // Returns { status, message, connected }
// Repeater telemetry
await api.requestTelemetry(publicKey, password); // Returns TelemetryResponse
// Repeater CLI commands (after login)
await api.sendRepeaterCommand(publicKey, 'ver'); // Returns CommandResponse
```
### API Proxy (Development)
@@ -232,6 +235,12 @@ interface TelemetryResponse {
neighbors: NeighborInfo[];
acl: AclEntry[];
}
interface CommandResponse {
command: string;
response: string;
sender_timestamp: number | null;
}
```
## Component Patterns
@@ -264,24 +273,56 @@ messageInputRef.current?.appendText(`@[${sender}] `);
### Repeater Mode
When selecting a repeater contact (type=2), MessageInput switches to password mode:
Repeater contacts (type=2) have a two-phase interaction:
**Phase 1: Login (password mode)**
- Input type changes to `password`
- Button shows "Fetch" instead of "Send"
- Submitting requests telemetry instead of sending a message
- Enter "." for empty password
- Enter "." for empty password (converted to empty string)
- Submitting requests telemetry + logs in
**Phase 2: CLI commands (after login)**
- Input switches back to normal text
- Placeholder shows "Enter CLI command..."
- Commands sent via `/contacts/{key}/command` endpoint
- Responses displayed as local messages (not persisted to database)
```typescript
// State tracking
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
// Reset on conversation change
useEffect(() => {
setRepeaterLoggedIn(false);
}, [activeConversation?.id]);
// Mode switches after successful telemetry
const isRepeaterMode = activeContactIsRepeater && !repeaterLoggedIn;
<MessageInput
onSend={activeContactIsRepeater ? handleTelemetryRequest : handleSendMessage}
isRepeaterMode={activeContactIsRepeater}
onSend={isRepeaterMode ? handleTelemetryRequest :
(repeaterLoggedIn ? handleRepeaterCommand : handleSendMessage)}
isRepeaterMode={isRepeaterMode}
placeholder={repeaterLoggedIn ? 'Enter CLI command...' : undefined}
/>
```
Telemetry response is displayed as three local messages (not persisted):
1. **[Telemetry]** - Battery voltage, uptime, signal quality, packet stats
2. **[Neighbors]** - Sorted by SNR (highest first), with resolved names
3. **[ACL]** - Access control list with permission levels
1. **Telemetry** - Battery voltage, uptime, signal quality, packet stats
2. **Neighbors** - Sorted by SNR (highest first), with resolved names
3. **ACL** - Access control list with permission levels
### Repeater Message Rendering
Repeater CLI responses often contain colons (e.g., `clock: 12:30:00`). To prevent
incorrect sender parsing, MessageList skips `parseSenderFromText()` for repeater contacts:
```typescript
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
const { sender, content } = isRepeater
? { sender: null, content: msg.text } // Preserve full text
: parseSenderFromText(msg.text);
```
### Unread Count Tracking
@@ -432,6 +473,7 @@ npm run test # Watch mode
- `contactAvatar.test.ts` - Avatar text extraction, color generation, repeater handling
- `messageDeduplication.test.ts` - Message deduplication logic
- `websocket.test.ts` - WebSocket message routing
- `repeaterMode.test.ts` - Repeater CLI parsing, password "." conversion
### Test Setup
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RemoteTerm for MeshCore</title>
<script type="module" crossorigin src="/assets/index-DJfUQN-2.js"></script>
<script type="module" crossorigin src="/assets/index-Dj98vxU3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtV9BARe.css">
</head>
<body>
+88 -9
View File
@@ -61,10 +61,9 @@ function formatDuration(seconds: number): string {
}
// Format telemetry response as human-readable text
// Note: Avoid "Word: " pattern at line start - it triggers sender extraction in MessageList
function formatTelemetry(telemetry: TelemetryResponse): string {
const lines = [
`[Telemetry]`,
`Telemetry`,
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
`Uptime: ${formatDuration(telemetry.uptime_seconds)}`,
`TX Airtime: ${formatDuration(telemetry.airtime_seconds)}`,
@@ -88,11 +87,11 @@ function formatTelemetry(telemetry: TelemetryResponse): string {
// Format neighbors list as human-readable text
function formatNeighbors(neighbors: NeighborInfo[]): string {
if (neighbors.length === 0) {
return '[Neighbors]\nNo neighbors reported';
return 'Neighbors\nNo neighbors reported';
}
// Sort by SNR descending (highest first)
const sorted = [...neighbors].sort((a, b) => b.snr - a.snr);
const lines = [`[Neighbors] (${sorted.length})`];
const lines = [`Neighbors (${sorted.length})`];
for (const n of sorted) {
const name = n.name || n.pubkey_prefix;
const snr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
@@ -104,9 +103,9 @@ function formatNeighbors(neighbors: NeighborInfo[]): string {
// Format ACL list as human-readable text
function formatAcl(acl: AclEntry[]): string {
if (acl.length === 0) {
return '[ACL]\nNo ACL entries';
return 'ACL\nNo ACL entries';
}
const lines = [`[ACL] (${acl.length})`];
const lines = [`ACL (${acl.length})`];
for (const entry of acl) {
const name = entry.name || entry.pubkey_prefix;
lines.push(`${name}: ${entry.permission_name}`);
@@ -172,6 +171,8 @@ export function App() {
const [undecryptedCount, setUndecryptedCount] = useState(0);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
// Track if we've logged into the current repeater (for CLI command mode)
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
// Track last message times (persisted in localStorage, used for sorting)
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
// Track unread counts (calculated on load and incremented during session)
@@ -555,6 +556,9 @@ export function App() {
useEffect(() => {
activeConversationRef.current = activeConversation;
// Reset repeater login state when conversation changes
setRepeaterLoggedIn(false);
// Mark conversation as read when user views it
if (activeConversation && activeConversation.type !== 'raw') {
const key = getStateKey(
@@ -670,6 +674,9 @@ export function App() {
// Add all messages to the list
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
// Mark as logged in for CLI command mode
setRepeaterLoggedIn(true);
} catch (err) {
// Show error as a local message
const errorMessage: Message = {
@@ -691,6 +698,72 @@ export function App() {
[activeConversation, activeContactIsRepeater]
);
// Send CLI command to a repeater (after logged in)
const handleRepeaterCommand = useCallback(
async (command: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater || !repeaterLoggedIn) return;
const now = Math.floor(Date.now() / 1000);
// Show the command as an outgoing message
const commandMessage: Message = {
id: -Date.now(),
type: 'PRIV',
conversation_key: activeConversation.id,
text: `> ${command}`,
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: true,
acked: true,
};
setMessages((prev) => [...prev, commandMessage]);
try {
const response = await api.sendRepeaterCommand(activeConversation.id, command);
// Use the actual timestamp from the repeater if available
const responseTimestamp = response.sender_timestamp ?? now;
// Show the response
const responseMessage: Message = {
id: -Date.now() - 1,
type: 'PRIV',
conversation_key: activeConversation.id,
text: response.response,
sender_timestamp: responseTimestamp,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: true,
};
setMessages((prev) => [...prev, responseMessage]);
} catch (err) {
const errorMessage: Message = {
id: -Date.now() - 1,
type: 'PRIV',
conversation_key: activeConversation.id,
text: `Command failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: true,
};
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater, repeaterLoggedIn]
);
// Config save handler
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
await api.updateRadioConfig(update);
@@ -983,14 +1056,20 @@ export function App() {
/>
<MessageInput
ref={messageInputRef}
onSend={activeContactIsRepeater ? handleTelemetryRequest : handleSendMessage}
onSend={
activeContactIsRepeater
? (repeaterLoggedIn ? handleRepeaterCommand : handleTelemetryRequest)
: handleSendMessage
}
disabled={!health?.radio_connected}
isRepeaterMode={activeContactIsRepeater}
isRepeaterMode={activeContactIsRepeater && !repeaterLoggedIn}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: activeContactIsRepeater
? `Enter password for ${activeConversation.name} (or . for none)...`
? (repeaterLoggedIn
? 'Send CLI command (requires admin login)...'
: `Enter password for ${activeConversation.name} (or . for none)...`)
: `Message ${activeConversation.name}...`
}
/>
+6
View File
@@ -2,6 +2,7 @@ import type {
AppSettings,
AppSettingsUpdate,
Channel,
CommandResponse,
Contact,
HealthStatus,
Message,
@@ -86,6 +87,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ password }),
}),
sendRepeaterCommand: (publicKey: string, command: string) =>
fetchJson<CommandResponse>(`/contacts/${publicKey}/command`, {
method: 'POST',
body: JSON.stringify({ command }),
}),
// Channels
getChannels: () => fetchJson<Channel[]>('/channels'),
+6
View File
@@ -44,9 +44,12 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
setText('');
} catch (err) {
console.error('Failed to request telemetry:', err);
return;
} finally {
setSending(false);
}
// Refocus after React re-enables the input (now in CLI command mode)
setTimeout(() => inputRef.current?.focus(), 0);
} else {
if (!trimmed || sending || disabled) return;
setSending(true);
@@ -55,9 +58,12 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
setText('');
} catch (err) {
console.error('Failed to send message:', err);
return;
} finally {
setSending(false);
}
// Refocus after React re-enables the input
setTimeout(() => inputRef.current?.focus(), 0);
}
},
[text, sending, disabled, onSend, isRepeaterMode]
+7 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useState, type ReactNode } from 'react';
import type { Contact, Message } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { pubkeysMatch } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
@@ -207,9 +208,14 @@ export function MessageList({
</div>
)}
{sortedMessages.map((msg, index) => {
const { sender, content } = parseSenderFromText(msg.text);
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
// Skip sender parsing for repeater messages (CLI responses often have colons)
const { sender, content } = isRepeater
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
const displaySender = msg.outgoing
? 'You'
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown';
-7
View File
@@ -38,13 +38,6 @@ describe('parseSenderFromText', () => {
expect(result.content).toBe('Note:this is not a sender');
});
it('rejects sender containing square brackets', () => {
const result = parseSenderFromText('[System]: Alert message');
expect(result.sender).toBeNull();
expect(result.content).toBe('[System]: Alert message');
});
it('rejects sender containing colon', () => {
const result = parseSenderFromText('12:30: Time announcement');
+132
View File
@@ -0,0 +1,132 @@
/**
* Tests for repeater-specific behavior.
*
* These tests verify edge cases in repeater interactions that could easily
* regress if the code is modified:
*
* 1. Repeater messages should NOT have sender parsed from text (colons are common in CLI output)
* 2. Password field "." should convert to empty string (for repeaters with no password)
*/
import { describe, it, expect } from 'vitest';
import { parseSenderFromText } from '../utils/messageParser';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_CLIENT } from '../types';
describe('Repeater message sender parsing', () => {
/**
* CLI responses from repeaters often contain colons (e.g., "clock: 12:30:00").
* If we parse these like normal channel messages, we'd incorrectly extract
* "clock" as a sender name, breaking the display.
*
* The fix in MessageList.tsx is to check if the contact is a repeater and
* skip parseSenderFromText entirely. These tests document the expected
* behavior pattern.
*/
it('parseSenderFromText would incorrectly parse CLI responses with colons', () => {
// This demonstrates WHY we skip parsing for repeaters
const cliResponse = 'clock: 2024-01-09 12:30:00';
const parsed = parseSenderFromText(cliResponse);
// Without the repeater check, we'd get this incorrect result:
expect(parsed.sender).toBe('clock');
expect(parsed.content).toBe('2024-01-09 12:30:00');
// This would display as "clock" sent "2024-01-09 12:30:00" - WRONG!
});
it('repeater messages should bypass parsing entirely', () => {
// This documents the correct behavior: skip parsing for repeaters
const cliResponse = 'clock: 2024-01-09 12:30:00';
const contactType = CONTACT_TYPE_REPEATER;
// The pattern used in MessageList.tsx:
const isRepeater = contactType === CONTACT_TYPE_REPEATER;
const { sender, content } = isRepeater
? { sender: null, content: cliResponse }
: parseSenderFromText(cliResponse);
// Correct: full text preserved, no sender extracted
expect(sender).toBeNull();
expect(content).toBe('clock: 2024-01-09 12:30:00');
});
it('non-repeater messages still get sender parsed', () => {
const channelMessage = 'Alice: Hello everyone!';
const contactType = CONTACT_TYPE_CLIENT;
const isRepeater = contactType === CONTACT_TYPE_REPEATER;
const { sender, content } = isRepeater
? { sender: null, content: channelMessage }
: parseSenderFromText(channelMessage);
// Normal behavior: sender extracted
expect(sender).toBe('Alice');
expect(content).toBe('Hello everyone!');
});
it('handles various CLI response formats that would be mis-parsed', () => {
const cliResponses = [
'ver: 1.2.3',
'tx: 20 dBm',
'name: MyRepeater',
'radio: 915.0,125,9,5',
'Error: command not found',
'uptime: 3d 12h 30m',
];
for (const response of cliResponses) {
// All of these would be incorrectly parsed without the repeater check
const parsed = parseSenderFromText(response);
expect(parsed.sender).not.toBeNull();
// But with repeater check, they're preserved
const isRepeater = true;
const { sender, content } = isRepeater
? { sender: null, content: response }
: parseSenderFromText(response);
expect(sender).toBeNull();
expect(content).toBe(response);
}
});
});
describe('Repeater password handling', () => {
/**
* The "." password convention allows users to specify an empty password
* for repeaters that don't require authentication. Without this, users
* couldn't submit an empty password through the form.
*/
it('"." converts to empty password', () => {
// This is the logic in MessageInput.tsx handleSubmit
const trimmed = '.';
const password = trimmed === '.' ? '' : trimmed;
expect(password).toBe('');
});
it('normal password is passed through unchanged', () => {
const trimmed = 'mySecretPassword';
const password = trimmed === '.' ? '' : trimmed;
expect(password).toBe('mySecretPassword');
});
it('"." with surrounding whitespace still works after trim', () => {
// In MessageInput, text.trim() is called before the check
const text = ' . ';
const trimmed = text.trim();
const password = trimmed === '.' ? '' : trimmed;
expect(password).toBe('');
});
it('".." is NOT converted (only single dot)', () => {
const trimmed = '..';
const password = trimmed === '.' ? '' : trimmed;
// Double dot is passed through as-is (it's a valid password)
expect(password).toBe('..');
});
});
+6
View File
@@ -149,3 +149,9 @@ export interface TelemetryResponse {
neighbors: NeighborInfo[];
acl: AclEntry[];
}
export interface CommandResponse {
command: string;
response: string;
sender_timestamp: number | null;
}
+2 -2
View File
@@ -6,8 +6,8 @@ export function parseSenderFromText(text: string): { sender: string | null; cont
const colonIndex = text.indexOf(': ');
if (colonIndex > 0 && colonIndex < 50) {
const potentialSender = text.substring(0, colonIndex);
// Check for invalid characters that would indicate it's not a sender
if (!/[:\[\]]/.test(potentialSender)) {
// Check for colon in potential sender (would indicate it's not a simple name)
if (!potentialSender.includes(':')) {
return {
sender: potentialSender,
content: text.substring(colonIndex + 2),