mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Implement repeater CLI interface
This commit is contained in:
@@ -181,6 +181,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| GET | `/api/contacts` | List contacts |
|
||||
| POST | `/api/contacts/sync` | Pull from radio |
|
||||
| POST | `/api/contacts/{key}/telemetry` | Request telemetry from repeater |
|
||||
| POST | `/api/contacts/{key}/command` | Send CLI command to repeater |
|
||||
| GET | `/api/channels` | List channels |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| GET | `/api/messages` | List with filters |
|
||||
|
||||
@@ -360,7 +360,7 @@ PYTHONPATH=. uv run pytest tests/ -v
|
||||
Key test files:
|
||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange, real-world test vectors
|
||||
- `tests/test_keystore.py` - Ephemeral key store operations
|
||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
|
||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection, CLI response filtering
|
||||
- `tests/test_api.py` - API endpoint tests
|
||||
|
||||
## Common Tasks
|
||||
@@ -443,3 +443,57 @@ class TelemetryResponse(BaseModel):
|
||||
neighbors: list[NeighborInfo]
|
||||
acl: list[AclEntry]
|
||||
```
|
||||
|
||||
## Repeater CLI Commands
|
||||
|
||||
After login via telemetry endpoint, you can send CLI commands to repeaters:
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /api/contacts/{key}/command` - Send a CLI command (assumes already logged in)
|
||||
|
||||
### Request/Response
|
||||
|
||||
```python
|
||||
class CommandRequest(BaseModel):
|
||||
command: str # CLI command to send
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
command: str # Echo of sent command
|
||||
response: str # Response from repeater
|
||||
sender_timestamp: int | None # Timestamp from response
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```
|
||||
get name / set name <value> # Repeater name
|
||||
get tx / set tx <dbm> # TX power
|
||||
get radio / set radio <freq,bw,sf,cr> # Radio params
|
||||
tempradio <freq,bw,sf,cr,mins> # Temporary radio change
|
||||
setperm <pubkey> <0-3> # ACL: 0=guest, 1=ro, 2=rw, 3=admin
|
||||
clock / clock sync # Get/sync time
|
||||
ver # Firmware version
|
||||
reboot # Restart repeater
|
||||
```
|
||||
|
||||
### CLI Response Filtering
|
||||
|
||||
CLI responses have `txt_type=1` (vs `txt_type=0` for normal messages). The event handler
|
||||
in `event_handlers.py` skips these to prevent duplicates—the command endpoint returns
|
||||
the response directly, so we don't also store/broadcast via WebSocket.
|
||||
|
||||
```python
|
||||
# In on_contact_message()
|
||||
txt_type = payload.get("txt_type", 0)
|
||||
if txt_type == 1:
|
||||
return # Skip CLI responses
|
||||
```
|
||||
|
||||
### Helper Function
|
||||
|
||||
`prepare_repeater_connection()` handles the login dance:
|
||||
1. Sync contacts from radio
|
||||
2. Remove contact if exists (clears stale auth)
|
||||
3. Re-add with flood mode (`out_path_len=-1`)
|
||||
4. Send login with password
|
||||
|
||||
@@ -44,6 +44,14 @@ async def on_contact_message(event: "Event") -> None:
|
||||
The packet processor cannot decrypt these without the node's private key.
|
||||
"""
|
||||
payload = event.payload
|
||||
|
||||
# Skip CLI command responses (txt_type=1) - these are handled by the command endpoint
|
||||
# and should not be stored in the database or broadcast via WebSocket
|
||||
txt_type = payload.get("txt_type", 0)
|
||||
if txt_type == 1:
|
||||
logger.debug("Skipping CLI response from %s (txt_type=1)", payload.get("pubkey_prefix"))
|
||||
return
|
||||
|
||||
logger.debug("Received direct message from %s", payload.get("pubkey_prefix"))
|
||||
|
||||
# Get full public key if available, otherwise use prefix
|
||||
|
||||
@@ -166,3 +166,15 @@ class TelemetryResponse(BaseModel):
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
neighbors: list[NeighborInfo] = Field(default_factory=list, description="List of neighbors seen by repeater")
|
||||
acl: list[AclEntry] = Field(default_factory=list, description="Access control list")
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
"""Request to send a CLI command to a repeater."""
|
||||
command: str = Field(min_length=1, description="CLI command to send")
|
||||
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
"""Response from a repeater CLI command."""
|
||||
command: str = Field(description="The command that was sent")
|
||||
response: str = Field(description="Response from the repeater")
|
||||
sender_timestamp: int | None = Field(default=None, description="Timestamp from the repeater's response")
|
||||
|
||||
@@ -4,7 +4,16 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Contact, TelemetryRequest, TelemetryResponse, NeighborInfo, AclEntry, CONTACT_TYPE_REPEATER
|
||||
from app.models import (
|
||||
Contact,
|
||||
TelemetryRequest,
|
||||
TelemetryResponse,
|
||||
NeighborInfo,
|
||||
AclEntry,
|
||||
CommandRequest,
|
||||
CommandResponse,
|
||||
CONTACT_TYPE_REPEATER,
|
||||
)
|
||||
|
||||
# ACL permission level names
|
||||
ACL_PERMISSION_NAMES = {
|
||||
@@ -20,6 +29,70 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> None:
|
||||
"""Prepare connection to a repeater by removing/re-adding with flood mode and logging in.
|
||||
|
||||
This clears any stale auth state on the radio and establishes a fresh connection.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
contact: The repeater contact
|
||||
password: Password for login (empty string for no password)
|
||||
|
||||
Raises:
|
||||
HTTPException: If contact cannot be added or login fails
|
||||
"""
|
||||
# Sync contacts from radio to ensure our cache is up-to-date
|
||||
logger.info("Syncing contacts from radio before repeater connection")
|
||||
await mc.ensure_contacts()
|
||||
|
||||
# Remove contact if it exists (clears any stale auth state on radio)
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
logger.info("Removing existing contact %s from radio", contact.public_key[:12])
|
||||
await mc.commands.remove_contact(contact.public_key)
|
||||
await mc.commands.get_contacts()
|
||||
|
||||
# Add contact fresh with flood mode (matching test_telemetry.py pattern)
|
||||
logger.info("Adding repeater %s to radio with flood mode", contact.public_key[:12])
|
||||
contact_data = {
|
||||
"public_key": contact.public_key,
|
||||
"adv_name": contact.name or "",
|
||||
"type": contact.type,
|
||||
"flags": contact.flags,
|
||||
"out_path": "",
|
||||
"out_path_len": -1, # Flood mode
|
||||
"adv_lat": contact.lat or 0.0,
|
||||
"adv_lon": contact.lon or 0.0,
|
||||
"last_advert": contact.last_advert or 0,
|
||||
}
|
||||
add_result = await mc.commands.add_contact(contact_data)
|
||||
if add_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to add contact to radio: {add_result.payload}"
|
||||
)
|
||||
|
||||
# Refresh and verify
|
||||
await mc.commands.get_contacts()
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not radio_contact:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to add contact to radio - contact not found after add"
|
||||
)
|
||||
|
||||
# Send login with password
|
||||
logger.info("Sending login to repeater %s", contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Login failed: {login_result.payload}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
async def list_contacts(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
@@ -167,56 +240,8 @@ async def request_telemetry(public_key: str, request: TelemetryRequest) -> Telem
|
||||
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})"
|
||||
)
|
||||
|
||||
# Sync contacts from radio to ensure our cache is up-to-date
|
||||
logger.info("Syncing contacts from radio before telemetry request")
|
||||
await mc.ensure_contacts()
|
||||
|
||||
# Remove contact if it exists (clears any stale auth state on radio)
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
logger.info("Removing existing contact %s from radio", contact.public_key[:12])
|
||||
await mc.commands.remove_contact(contact.public_key)
|
||||
await mc.commands.get_contacts()
|
||||
|
||||
# Add contact fresh with flood mode (matching test_telemetry.py pattern)
|
||||
logger.info("Adding repeater %s to radio with flood mode", contact.public_key[:12])
|
||||
contact_data = {
|
||||
"public_key": contact.public_key,
|
||||
"adv_name": contact.name or "",
|
||||
"type": contact.type,
|
||||
"flags": contact.flags,
|
||||
"out_path": "",
|
||||
"out_path_len": -1, # Flood mode
|
||||
"adv_lat": contact.lat or 0.0,
|
||||
"adv_lon": contact.lon or 0.0,
|
||||
"last_advert": contact.last_advert or 0,
|
||||
}
|
||||
add_result = await mc.commands.add_contact(contact_data)
|
||||
if add_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to add contact to radio: {add_result.payload}"
|
||||
)
|
||||
|
||||
# Refresh and verify
|
||||
await mc.commands.get_contacts()
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not radio_contact:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to add contact to radio - contact not found after add"
|
||||
)
|
||||
|
||||
# Send login with password
|
||||
password = request.password
|
||||
logger.info("Sending login to repeater %s", contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Login failed: {login_result.payload}"
|
||||
)
|
||||
# Prepare connection (add/remove dance + login)
|
||||
await prepare_repeater_connection(mc, contact, request.password)
|
||||
|
||||
# Request status with retries
|
||||
logger.info("Requesting status from repeater %s", contact.public_key[:12])
|
||||
@@ -323,3 +348,84 @@ async def request_telemetry(public_key: str, request: TelemetryRequest) -> Telem
|
||||
neighbors=neighbors,
|
||||
acl=acl_entries,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater.
|
||||
|
||||
The contact must be a repeater (type=2). This endpoint assumes the user has already
|
||||
logged in via the telemetry endpoint - it does NOT perform the add/remove dance
|
||||
or login again.
|
||||
|
||||
Common commands:
|
||||
- get name, set name <value>
|
||||
- get tx, set tx <dbm>
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
mc = require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
# Verify it's a repeater
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})"
|
||||
)
|
||||
|
||||
# Send the command
|
||||
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
# Wait for response (MESSAGES_WAITING event, then get_msg)
|
||||
try:
|
||||
wait_result = await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=10.0)
|
||||
|
||||
if wait_result is None:
|
||||
# Timeout - no response received
|
||||
logger.warning("No response from repeater %s for command: %s", contact.public_key[:12], request.command)
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response="(no response - command may have been processed)"
|
||||
)
|
||||
|
||||
response_event = await mc.commands.get_msg()
|
||||
|
||||
if response_event.type == EventType.ERROR:
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=f"(error: {response_event.payload})"
|
||||
)
|
||||
|
||||
# Extract the response text and timestamp from the payload
|
||||
response_text = response_event.payload.get("text", str(response_event.payload))
|
||||
sender_timestamp = response_event.payload.get("timestamp")
|
||||
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
|
||||
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error waiting for response: %s", e)
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=f"(error waiting for response: {e})"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
537
frontend/dist/assets/index-DJfUQN-2.js
vendored
537
frontend/dist/assets/index-DJfUQN-2.js
vendored
File diff suppressed because one or more lines are too long
537
frontend/dist/assets/index-Dj98vxU3.js
vendored
Normal file
537
frontend/dist/assets/index-Dj98vxU3.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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}...`
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
frontend/src/test/repeaterMode.test.ts
Normal file
132
frontend/src/test/repeaterMode.test.ts
Normal 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('..');
|
||||
});
|
||||
});
|
||||
@@ -149,3 +149,9 @@ export interface TelemetryResponse {
|
||||
neighbors: NeighborInfo[];
|
||||
acl: AclEntry[];
|
||||
}
|
||||
|
||||
export interface CommandResponse {
|
||||
command: string;
|
||||
response: string;
|
||||
sender_timestamp: number | null;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -193,3 +193,90 @@ class TestAckEventHandler:
|
||||
await on_ack(MockEvent())
|
||||
|
||||
mock_repo.mark_acked.assert_not_called()
|
||||
|
||||
|
||||
class TestContactMessageCLIFiltering:
|
||||
"""Test that CLI responses (txt_type=1) are filtered out.
|
||||
|
||||
This prevents duplicate messages when sending CLI commands to repeaters:
|
||||
the command endpoint returns the response directly, so we must NOT also
|
||||
persist/broadcast it via the normal message handler.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_response_skipped_not_stored(self):
|
||||
"""CLI responses (txt_type=1) are not stored in database."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo, \
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo, \
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"pubkey_prefix": "abc123def456",
|
||||
"text": "clock: 2024-01-01 12:00:00",
|
||||
"txt_type": 1, # CLI response
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
|
||||
await on_contact_message(MockEvent())
|
||||
|
||||
# Should NOT store in database
|
||||
mock_repo.create.assert_not_called()
|
||||
# Should NOT broadcast via WebSocket
|
||||
mock_broadcast.assert_not_called()
|
||||
# Should NOT update contact last_contacted
|
||||
mock_contact_repo.update_last_contacted.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_message_still_processed(self):
|
||||
"""Normal messages (txt_type=0) are still processed normally."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo, \
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo, \
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
mock_repo.create = AsyncMock(return_value=42)
|
||||
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"pubkey_prefix": "abc123def456",
|
||||
"text": "Hello, this is a normal message",
|
||||
"txt_type": 0, # Normal message (default)
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
|
||||
await on_contact_message(MockEvent())
|
||||
|
||||
# SHOULD store in database
|
||||
mock_repo.create.assert_called_once()
|
||||
# SHOULD broadcast via WebSocket
|
||||
mock_broadcast.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_txt_type_defaults_to_normal(self):
|
||||
"""Messages without txt_type field are treated as normal (not filtered)."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo, \
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo, \
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
mock_repo.create = AsyncMock(return_value=42)
|
||||
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"pubkey_prefix": "abc123def456",
|
||||
"text": "Message without txt_type field",
|
||||
"sender_timestamp": 1700000000,
|
||||
# No txt_type field
|
||||
}
|
||||
|
||||
await on_contact_message(MockEvent())
|
||||
|
||||
# SHOULD still be processed (defaults to txt_type=0)
|
||||
mock_repo.create.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user