mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-13 12:56:05 +02:00
Initial commit
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
frontend/node_modules/
|
||||
|
||||
# reference librarys
|
||||
references/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,267 @@
|
||||
# RemoteTerm for MeshCore
|
||||
|
||||
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over serial and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
|
||||
|
||||
**For detailed component documentation, see:**
|
||||
- `app/CLAUDE.md` - Backend (FastAPI, database, radio connection, packet decryption)
|
||||
- `frontend/CLAUDE.md` - Frontend (React, state management, WebSocket, components)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ RawPacketList + CrackerPanel (WebGPU key bruteforcing) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ useWebSocket ←──── Real-time updates │
|
||||
│ │ │
|
||||
│ api.ts ←──── REST API calls │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ HTTP + WebSocket (/api/*)
|
||||
┌───────────────────────────┼──────────────────────────────────────┐
|
||||
│ Backend (FastAPI) │
|
||||
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
|
||||
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │
|
||||
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │
|
||||
│ ↓ └───────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ RadioManager + Event Handlers │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ Serial
|
||||
┌──────┴──────┐
|
||||
│ MeshCore │
|
||||
│ Radio │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Store-and-serve**: Backend stores all packets even when no client is connected
|
||||
2. **Parallel storage**: Messages stored both decrypted (when possible) and as raw packets
|
||||
3. **Extended capacity**: Server stores contacts/channels beyond radio limits (~350 contacts, ~40 channels)
|
||||
4. **Real-time updates**: WebSocket pushes events; REST for actions
|
||||
5. **Offline-capable**: Radio operates independently; server syncs when connected
|
||||
6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Incoming Messages
|
||||
|
||||
1. Radio receives message → MeshCore library emits event
|
||||
2. `event_handlers.py` catches event → stores in database
|
||||
3. `ws_manager` broadcasts to connected clients
|
||||
4. Frontend `useWebSocket` receives → updates React state
|
||||
|
||||
### Outgoing Messages
|
||||
|
||||
1. User types message → clicks send
|
||||
2. `api.sendChannelMessage()` → POST to backend
|
||||
3. Backend calls `radio_manager.meshcore.commands.send_chan_msg()`
|
||||
4. Message stored in database with `outgoing=true`
|
||||
5. For direct messages: ACK tracked; for channel: repeat detection
|
||||
|
||||
### ACK and Repeat Detection
|
||||
|
||||
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
|
||||
|
||||
**Channel messages**: Flood messages echo back. The decoder identifies repeats by matching (channel_idx, text_hash, timestamp ±5s) and marks the original as "acked".
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── app/ # FastAPI backend
|
||||
│ ├── CLAUDE.md # Backend documentation
|
||||
│ ├── main.py # App entry, lifespan
|
||||
│ ├── routers/ # API endpoints
|
||||
│ ├── repository.py # Database CRUD
|
||||
│ ├── event_handlers.py # Radio events
|
||||
│ ├── decoder.py # Packet decryption
|
||||
│ └── websocket.py # Real-time broadcasts
|
||||
├── frontend/ # React frontend
|
||||
│ ├── CLAUDE.md # Frontend documentation
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx # Main component
|
||||
│ │ ├── api.ts # REST client
|
||||
│ │ ├── useWebSocket.ts # WebSocket hook
|
||||
│ │ └── components/
|
||||
│ │ ├── CrackerPanel.tsx # WebGPU key cracking
|
||||
│ │ └── ...
|
||||
│ └── vite.config.ts
|
||||
├── references/meshcore_py/ # MeshCore Python library
|
||||
├── tests/ # Backend tests (pytest)
|
||||
├── data/ # SQLite database (runtime)
|
||||
├── integration_test.html # Browser-based API tests
|
||||
└── pyproject.toml # Python dependencies
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Run server (auto-detects radio)
|
||||
uv run uvicorn app.main:app --reload
|
||||
|
||||
# Or specify port
|
||||
MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173, proxies /api to :8000
|
||||
```
|
||||
|
||||
### Both Together (Development)
|
||||
|
||||
Terminal 1: `uv run uvicorn app.main:app --reload`
|
||||
Terminal 2: `cd frontend && npm run dev`
|
||||
|
||||
### Production
|
||||
|
||||
In production, the FastAPI backend serves the compiled frontend:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Access at `http://localhost:8000`. All API routes are prefixed with `/api`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend (pytest)
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
Key test files:
|
||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
|
||||
- `tests/test_keystore.py` - Ephemeral key store
|
||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
|
||||
- `tests/test_api.py` - API endpoints
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Open `integration_test.html` in a browser with the backend running.
|
||||
|
||||
## API Summary
|
||||
|
||||
All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Connection status |
|
||||
| GET | `/api/radio/config` | Radio configuration |
|
||||
| PATCH | `/api/radio/config` | Update name, location, radio params |
|
||||
| POST | `/api/radio/advertise` | Send advertisement |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
| POST | `/api/radio/enable-server-decryption` | Export private key, enable decryption |
|
||||
| GET | `/api/radio/decryption-status` | Check if decryption enabled |
|
||||
| GET | `/api/contacts` | List contacts |
|
||||
| POST | `/api/contacts/sync` | Pull from radio |
|
||||
| GET | `/api/channels` | List channels |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| GET | `/api/messages` | List with filters |
|
||||
| POST | `/api/messages/direct` | Send direct message |
|
||||
| POST | `/api/messages/channel` | Send channel message |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| GET | `/api/settings` | Get app settings |
|
||||
| PATCH | `/api/settings` | Update app settings |
|
||||
| WS | `/api/ws` | Real-time updates |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Contact Public Keys
|
||||
|
||||
- Full key: 64-character hex string
|
||||
- Prefix: 12-character hex (used for matching)
|
||||
- Lookups use `LIKE 'prefix%'` for matching
|
||||
|
||||
### Contact Types
|
||||
|
||||
- `0` - Unknown
|
||||
- `1` - Client (regular node)
|
||||
- `2` - Repeater
|
||||
- `3` - Room
|
||||
|
||||
### Channel Keys
|
||||
|
||||
- Stored as 32-character hex string (TEXT PRIMARY KEY)
|
||||
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||
- Custom channels: User-provided or generated
|
||||
|
||||
### Message Types
|
||||
|
||||
- `PRIV` - Direct messages
|
||||
- `CHAN` - Channel messages
|
||||
- Both use `conversation_key` (user pubkey for PRIV, channel key for CHAN)
|
||||
|
||||
### State Tracking Keys (Frontend)
|
||||
|
||||
Generated by `getStateKey()` for unread tracking and message times:
|
||||
- Channels: `channel-{channel_key}`
|
||||
- Contacts: `contact-{12-char-pubkey-prefix}`
|
||||
|
||||
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
|
||||
|
||||
### Server-Side Decryption
|
||||
|
||||
The server can decrypt historical packets if given the necessary keys:
|
||||
|
||||
**Channel messages**: Decrypted automatically using stored channel keys.
|
||||
|
||||
**Direct messages**: Requires the node's private key, which must be:
|
||||
1. Exported from radio via `POST /radio/enable-server-decryption`
|
||||
2. Stored **only in memory** (never persisted to disk)
|
||||
3. Re-exported after every server restart
|
||||
|
||||
This allows decrypting messages from contacts whose public keys were learned after the message was received.
|
||||
|
||||
## MeshCore Library
|
||||
|
||||
The `meshcore_py` library provides radio communication. Key patterns:
|
||||
|
||||
```python
|
||||
# Connection
|
||||
mc = await MeshCore.create_serial(port="/dev/ttyUSB0")
|
||||
|
||||
# Commands
|
||||
await mc.commands.send_msg(dst, msg)
|
||||
await mc.commands.send_chan_msg(channel_idx, msg)
|
||||
await mc.commands.get_contacts()
|
||||
await mc.commands.set_channel(idx, name, key)
|
||||
|
||||
# Events
|
||||
mc.subscribe(EventType.CONTACT_MSG_RECV, handler)
|
||||
mc.subscribe(EventType.CHANNEL_MSG_RECV, handler)
|
||||
mc.subscribe(EventType.ACK, handler)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
| `MESHCORE_MAX_RADIO_CONTACTS` | `200` | Max recent contacts to keep on radio for DM ACKs |
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2026 Jack Kingsman <jack@jackkingsman.me>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,118 @@
|
||||
# RemoteTerm for MeshCore
|
||||
|
||||
Web interface for MeshCore mesh radio networks. Attach your radio over serial, and then you can:
|
||||
|
||||
* Cache all received packets, decrypting as you gain keys
|
||||
* Send and receive DMs and GroupTexts
|
||||
* Passively monitor as many contacts and channels as you want; radio limitations are irrelevant as all packets get hoovered up, then decrypted serverside
|
||||
* Use your radio remotely over your network or away from home over a VPN
|
||||
* Look for hashtag room names by brute forcing channel keys of GroupTexts you don't have the keys for yet
|
||||
|
||||
## This is a personal toolkit, and not optimized for general consumption! This is entirely vibecoded slop and I make no warranty of fitness for any purpose.
|
||||
|
||||
For real, this code is bad and totally LLM generated. If you insist on extending it, there are three `CLAUDE.md` fils you should have your LLM read in `./`, `./frontend`, and `./app`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+
|
||||
- UV (Python package manager): `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- MeshCore-compatible radio connected via USB serial
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Run (auto-detects serial port)
|
||||
uv run uvicorn app.main:app --reload
|
||||
|
||||
# Or specify port explicitly
|
||||
MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Backend runs at http://localhost:8000
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Development server (proxies API to localhost:8000)
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
```
|
||||
|
||||
Dev server runs at http://localhost:5173
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production, the FastAPI backend serves the compiled frontend directly.
|
||||
|
||||
```bash
|
||||
# 1. Install Python dependencies
|
||||
uv sync
|
||||
|
||||
# 2. Build frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# 3. Run server
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# Or with explicit serial port
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Access the app at http://localhost:8000 (or your server's IP/hostname).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Baud rate |
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
|
||||
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
|
||||
| `MESHCORE_MAX_RADIO_CONTACTS` | 200 | Max recent contacts to keep on radio for DM ACKs |
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend (pytest)
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
uv sync --extra test
|
||||
|
||||
# Run all tests
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
# Run specific test file
|
||||
PYTHONPATH=. uv run pytest tests/test_decoder.py -v
|
||||
```
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Run tests once
|
||||
npm run test:run
|
||||
|
||||
# Run tests in watch mode
|
||||
npm test
|
||||
```
|
||||
|
||||
## API Docs
|
||||
|
||||
With the backend running, visit http://localhost:8000/docs for interactive API documentation.
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
# Backend CLAUDE.md
|
||||
|
||||
This document provides context for AI assistants and developers working on the FastAPI backend.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **FastAPI** - Async web framework with automatic OpenAPI docs
|
||||
- **aiosqlite** - Async SQLite driver
|
||||
- **meshcore** - MeshCore radio library (local dependency at `../meshcore_py`)
|
||||
- **Pydantic** - Data validation and settings management
|
||||
- **PyCryptodome** - AES-128 encryption for packet decryption
|
||||
- **PyNaCl** - Ed25519/X25519 key exchange for direct message decryption
|
||||
- **UV** - Python package manager
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── main.py # FastAPI app, lifespan, router registration, static file serving
|
||||
├── config.py # Pydantic settings (env vars: MESHCORE_*)
|
||||
├── database.py # SQLite schema, connection management
|
||||
├── models.py # Pydantic models for API request/response
|
||||
├── repository.py # Database CRUD (ContactRepository, ChannelRepository, etc.)
|
||||
├── radio.py # RadioManager - serial connection to MeshCore device
|
||||
├── radio_sync.py # Periodic sync, contact auto-loading to radio
|
||||
├── decoder.py # Packet decryption (channel + direct messages)
|
||||
├── packet_processor.py # Raw packet processing, advertisement handling
|
||||
├── keystore.py # Ephemeral key store (private key in memory only)
|
||||
├── event_handlers.py # Radio event subscriptions, ACK tracking, repeat detection
|
||||
├── websocket.py # WebSocketManager for real-time client updates
|
||||
└── routers/ # All routes prefixed with /api
|
||||
├── health.py # GET /api/health
|
||||
├── radio.py # Radio config, advertise, private key, reboot
|
||||
├── contacts.py # Contact CRUD and radio sync
|
||||
├── channels.py # Channel CRUD and radio sync
|
||||
├── messages.py # Message list and send (direct/channel)
|
||||
├── packets.py # Raw packet endpoints, historical decryption
|
||||
├── settings.py # App settings (max_radio_contacts)
|
||||
└── ws.py # WebSocket endpoint at /api/ws
|
||||
```
|
||||
|
||||
## Key Architectural Patterns
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
All database operations go through repository classes in `repository.py`:
|
||||
|
||||
```python
|
||||
from app.repository import ContactRepository, ChannelRepository, MessageRepository, RawPacketRepository
|
||||
|
||||
# Examples
|
||||
contact = await ContactRepository.get_by_key_prefix("abc123")
|
||||
await MessageRepository.create(msg_type="PRIV", text="Hello", received_at=timestamp)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, message_id)
|
||||
```
|
||||
|
||||
### Radio Connection
|
||||
|
||||
`RadioManager` in `radio.py` handles serial connection:
|
||||
|
||||
```python
|
||||
from app.radio import radio_manager
|
||||
|
||||
# Access meshcore instance
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.commands.send_msg(dst, msg)
|
||||
```
|
||||
|
||||
Auto-detection scans common serial ports when `MESHCORE_SERIAL_PORT` is not set.
|
||||
|
||||
### Event-Driven Architecture
|
||||
|
||||
Radio events flow through `event_handlers.py`:
|
||||
|
||||
| Event | Handler | Actions |
|
||||
|-------|---------|---------|
|
||||
| `CONTACT_MSG_RECV` | `on_contact_message` | Store message, update contact last_seen, broadcast via WS |
|
||||
| `CHANNEL_MSG_RECV` | `on_channel_message` | Store message, broadcast via WS |
|
||||
| `RAW_DATA` | `on_raw_data` | Store packet, try decrypt with all channel keys, detect repeats |
|
||||
| `ADVERTISEMENT` | `on_advertisement` | Upsert contact with location |
|
||||
| `ACK` | `on_ack` | Match pending ACKs, mark message acked, broadcast |
|
||||
|
||||
### WebSocket Broadcasting
|
||||
|
||||
Real-time updates use `ws_manager` singleton:
|
||||
|
||||
```python
|
||||
from app.websocket import ws_manager
|
||||
|
||||
# Broadcast to all connected clients
|
||||
await ws_manager.broadcast("message", {"id": 1, "text": "Hello"})
|
||||
```
|
||||
|
||||
Event types: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
|
||||
|
||||
Helper functions for common broadcasts:
|
||||
|
||||
```python
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
|
||||
# Notify clients of errors (shows toast in frontend)
|
||||
broadcast_error("Operation failed", "Additional details")
|
||||
|
||||
# Notify clients of connection status change
|
||||
broadcast_health(radio_connected=True, serial_port="/dev/ttyUSB0")
|
||||
```
|
||||
|
||||
### Connection Monitoring
|
||||
|
||||
`RadioManager` includes a background task that monitors connection status:
|
||||
|
||||
- Checks connection every 5 seconds
|
||||
- Broadcasts `health` event on status change
|
||||
- Attempts automatic reconnection when connection lost
|
||||
- Supports manual reconnection via `POST /api/radio/reconnect`
|
||||
|
||||
```python
|
||||
from app.radio import radio_manager
|
||||
|
||||
# Manual reconnection
|
||||
success = await radio_manager.reconnect()
|
||||
|
||||
# Background monitor (started automatically in app lifespan)
|
||||
await radio_manager.start_connection_monitor()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
contacts (
|
||||
public_key TEXT PRIMARY KEY, -- 64-char hex
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0, -- 0=unknown, 1=client, 2=repeater, 3=room
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT, -- Routing path hex
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
last_advert INTEGER, -- Unix timestamp of last advertisement
|
||||
lat REAL, lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0, -- Boolean: contact loaded on radio
|
||||
last_contacted INTEGER -- Unix timestamp of last message sent/received
|
||||
)
|
||||
|
||||
channels (
|
||||
key TEXT PRIMARY KEY, -- 32-char hex channel key
|
||||
name TEXT NOT NULL,
|
||||
is_hashtag INTEGER DEFAULT 0, -- Key derived from SHA256(name)[:16]
|
||||
on_radio INTEGER DEFAULT 0
|
||||
)
|
||||
|
||||
messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
type TEXT NOT NULL, -- 'PRIV' or 'CHAN'
|
||||
conversation_key TEXT NOT NULL, -- User pubkey for PRIV, channel key for CHAN
|
||||
text TEXT NOT NULL,
|
||||
sender_timestamp INTEGER,
|
||||
received_at INTEGER NOT NULL,
|
||||
path_len INTEGER,
|
||||
txt_type INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
outgoing INTEGER DEFAULT 0,
|
||||
acked INTEGER DEFAULT 0,
|
||||
UNIQUE(type, conversation_key, text, sender_timestamp) -- Deduplication
|
||||
)
|
||||
|
||||
raw_packets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL, -- Raw packet bytes
|
||||
decrypted INTEGER DEFAULT 0,
|
||||
message_id INTEGER, -- FK to messages if decrypted
|
||||
decrypt_attempts INTEGER DEFAULT 0,
|
||||
last_attempt INTEGER,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
)
|
||||
```
|
||||
|
||||
## Packet Decryption (`decoder.py`)
|
||||
|
||||
The decoder handles MeshCore packet decryption for historical packet analysis:
|
||||
|
||||
### Packet Types
|
||||
|
||||
```python
|
||||
class PayloadType(IntEnum):
|
||||
GROUP_TEXT = 0x05 # Channel messages (decryptable)
|
||||
TEXT_MESSAGE = 0x02 # Direct messages
|
||||
ACK = 0x03
|
||||
ADVERT = 0x04
|
||||
# ... see decoder.py for full list
|
||||
```
|
||||
|
||||
### Channel Key Derivation
|
||||
|
||||
Hashtag channels derive keys from name:
|
||||
```python
|
||||
channel_key = hashlib.sha256(b"#channelname").digest()[:16]
|
||||
```
|
||||
|
||||
### Decryption Flow
|
||||
|
||||
1. Parse packet header to get payload type
|
||||
2. For `GROUP_TEXT`: extract channel_hash (1 byte), cipher_mac (2 bytes), ciphertext
|
||||
3. Verify HMAC-SHA256 using 32-byte secret (key + 16 zero bytes)
|
||||
4. Decrypt with AES-128 ECB
|
||||
5. Parse decrypted content: timestamp (4 bytes), flags (1 byte), "sender: message" text
|
||||
|
||||
```python
|
||||
from app.decoder import try_decrypt_packet_with_channel_key
|
||||
|
||||
result = try_decrypt_packet_with_channel_key(raw_bytes, channel_key)
|
||||
if result:
|
||||
print(f"{result.sender}: {result.message}")
|
||||
```
|
||||
|
||||
### Direct Message Decryption
|
||||
|
||||
Direct messages use ECDH key exchange (Ed25519 → X25519) with the sender's public key
|
||||
and recipient's private key:
|
||||
|
||||
```python
|
||||
from app.decoder import try_decrypt_packet_with_contact_key
|
||||
|
||||
result = try_decrypt_packet_with_contact_key(
|
||||
raw_bytes, sender_pub_key, recipient_prv_key
|
||||
)
|
||||
if result:
|
||||
print(f"Message: {result.message}")
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Sender's Ed25519 public key (32 bytes)
|
||||
- Recipient's Ed25519 private key (64 bytes) - from ephemeral KeyStore
|
||||
|
||||
### Ephemeral Key Store (`keystore.py`)
|
||||
|
||||
Private keys are stored **only in memory** for security:
|
||||
|
||||
```python
|
||||
from app.keystore import KeyStore
|
||||
|
||||
# Set private key (exported from radio)
|
||||
KeyStore.set_private_key(private_key_bytes)
|
||||
|
||||
# Check if available
|
||||
if KeyStore.has_private_key():
|
||||
key = KeyStore.get_private_key()
|
||||
|
||||
# Clear from memory
|
||||
KeyStore.clear_private_key()
|
||||
```
|
||||
|
||||
**Security guarantees:**
|
||||
- Never written to disk
|
||||
- Never logged
|
||||
- Lost on server restart (must re-export from radio)
|
||||
|
||||
## ACK and Repeat Detection
|
||||
|
||||
### Direct Message ACKs
|
||||
|
||||
When sending a direct message, an expected ACK code is tracked:
|
||||
```python
|
||||
from app.event_handlers import track_pending_ack
|
||||
|
||||
track_pending_ack(expected_ack="abc123", message_id=42, timeout_ms=30000)
|
||||
```
|
||||
|
||||
When ACK event arrives, the message is marked as acked.
|
||||
|
||||
### Channel Message Repeats
|
||||
|
||||
Flood messages echo back through repeaters. Detection uses:
|
||||
- Channel key
|
||||
- Text hash
|
||||
- Timestamp (±5 second window)
|
||||
|
||||
When a repeat is detected, the original outgoing message is marked as "acked".
|
||||
|
||||
### Auto-Contact Sync to Radio
|
||||
|
||||
To enable the radio to auto-ACK incoming DMs, recent non-repeater contacts are
|
||||
automatically loaded to the radio. Configured via `max_radio_contacts` setting (default 200).
|
||||
|
||||
- Triggered on each advertisement from a non-repeater contact
|
||||
- Loads most recently contacted non-repeaters (by `last_contacted` timestamp)
|
||||
- Throttled to at most once per 30 seconds
|
||||
- `last_contacted` updated on message send/receive
|
||||
|
||||
```python
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
result = await sync_recent_contacts_to_radio(force=True)
|
||||
# Returns: {"loaded": 5, "already_on_radio": 195, "failed": 0}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints are prefixed with `/api`.
|
||||
|
||||
### Health
|
||||
- `GET /api/health` - Connection status, serial port
|
||||
|
||||
### Radio
|
||||
- `GET /api/radio/config` - Read config (public key, name, radio params)
|
||||
- `PATCH /api/radio/config` - Update name, lat/lon, tx_power, radio params
|
||||
- `PUT /api/radio/private-key` - Import private key (write-only)
|
||||
- `POST /api/radio/advertise?flood=true` - Send advertisement
|
||||
- `POST /api/radio/reboot` - Reboot radio
|
||||
- `POST /api/radio/reconnect` - Manual reconnection attempt
|
||||
- `POST /api/radio/enable-server-decryption` - Export private key from radio, enable server-side decryption
|
||||
- `GET /api/radio/decryption-status` - Check if server-side decryption is enabled
|
||||
- `POST /api/radio/disable-server-decryption` - Clear private key from memory
|
||||
|
||||
### Contacts
|
||||
- `GET /api/contacts` - List from database
|
||||
- `GET /api/contacts/{key}` - Get by public key or prefix
|
||||
- `POST /api/contacts/sync` - Pull from radio to database
|
||||
- `POST /api/contacts/{key}/add-to-radio` - Push to radio
|
||||
- `POST /api/contacts/{key}/remove-from-radio` - Remove from radio
|
||||
|
||||
### Channels
|
||||
- `GET /api/channels` - List from database
|
||||
- `GET /api/channels/{key}` - Get by channel key
|
||||
- `POST /api/channels` - Create (hashtag if name starts with # or no key provided)
|
||||
- `POST /api/channels/sync` - Pull from radio
|
||||
- `DELETE /api/channels/{key}` - Delete channel
|
||||
|
||||
### Messages
|
||||
- `GET /api/messages?type=&conversation_key=&limit=&offset=` - List with filters
|
||||
- `POST /api/messages/direct` - Send direct message
|
||||
- `POST /api/messages/channel` - Send channel message
|
||||
|
||||
### Packets
|
||||
- `GET /api/packets/undecrypted/count` - Count of undecrypted packets
|
||||
- `POST /api/packets/decrypt/historical` - Try decrypting old packets with new key
|
||||
|
||||
### Settings
|
||||
- `GET /api/settings` - Get app settings (max_radio_contacts)
|
||||
- `PATCH /api/settings` - Update app settings
|
||||
|
||||
### WebSocket
|
||||
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
|
||||
|
||||
### Static Files (Production)
|
||||
In production, the backend also serves the frontend:
|
||||
- `/` - Serves `frontend/dist/index.html`
|
||||
- `/assets/*` - Serves compiled JS/CSS from `frontend/dist/assets/`
|
||||
- `/*` - Falls back to `index.html` for SPA routing
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
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_api.py` - API endpoint tests
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Endpoint
|
||||
|
||||
1. Create or update router in `app/routers/`
|
||||
2. Define Pydantic models in `app/models.py` if needed
|
||||
3. Add repository methods in `app/repository.py` for database operations
|
||||
4. Register router in `app/main.py` if new file
|
||||
5. Add tests in `tests/`
|
||||
|
||||
### Adding a New Event Handler
|
||||
|
||||
1. Define handler in `app/event_handlers.py`
|
||||
2. Register in `register_event_handlers()` function
|
||||
3. Broadcast updates via `ws_manager` as needed
|
||||
|
||||
### Working with Radio Commands
|
||||
|
||||
```python
|
||||
# Available via radio_manager.meshcore.commands
|
||||
await mc.commands.send_msg(dst, msg)
|
||||
await mc.commands.send_chan_msg(chan, msg)
|
||||
await mc.commands.get_contacts()
|
||||
await mc.commands.add_contact(contact_dict)
|
||||
await mc.commands.set_channel(idx, name, key)
|
||||
await mc.commands.send_advert(flood=True)
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
serial_port: str = "" # Empty string triggers auto-detection
|
||||
serial_baudrate: int = 115200
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
database_path: str = "data/meshcore.db"
|
||||
max_radio_contacts: int = 200 # Max non-repeater contacts to keep on radio for DM ACKs
|
||||
|
||||
class Config:
|
||||
env_prefix = "MESHCORE_"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure logging for the application."""
|
||||
logging.basicConfig(
|
||||
level=settings.log_level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
conversation_key TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
sender_timestamp INTEGER,
|
||||
received_at INTEGER NOT NULL,
|
||||
path_len INTEGER,
|
||||
txt_type INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
outgoing INTEGER DEFAULT 0,
|
||||
acked INTEGER DEFAULT 0,
|
||||
UNIQUE(type, conversation_key, text, sender_timestamp)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raw_packets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
decrypted INTEGER DEFAULT 0,
|
||||
message_id INTEGER,
|
||||
decrypt_attempts INTEGER DEFAULT 0,
|
||||
last_attempt INTEGER,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(type, conversation_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_decrypted ON raw_packets(decrypted);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
||||
"""
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
self._connection: aiosqlite.Connection | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
logger.info("Connecting to database at %s", self.db_path)
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._connection = await aiosqlite.connect(self.db_path)
|
||||
self._connection.row_factory = aiosqlite.Row
|
||||
await self._connection.executescript(SCHEMA)
|
||||
await self._connection.commit()
|
||||
logger.debug("Database schema initialized")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
await self._connection.close()
|
||||
self._connection = None
|
||||
logger.debug("Database connection closed")
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if not self._connection:
|
||||
raise RuntimeError("Database not connected")
|
||||
return self._connection
|
||||
|
||||
|
||||
db = Database(settings.database_path)
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
MeshCore packet decoder for historical packet decryption.
|
||||
Based on https://github.com/michaelhart/meshcore-decoder
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PayloadType(IntEnum):
|
||||
REQUEST = 0x00
|
||||
RESPONSE = 0x01
|
||||
TEXT_MESSAGE = 0x02
|
||||
ACK = 0x03
|
||||
ADVERT = 0x04
|
||||
GROUP_TEXT = 0x05
|
||||
GROUP_DATA = 0x06
|
||||
ANON_REQUEST = 0x07
|
||||
PATH = 0x08
|
||||
TRACE = 0x09
|
||||
MULTIPART = 0x0A
|
||||
CONTROL = 0x0B
|
||||
RAW_CUSTOM = 0x0F
|
||||
|
||||
|
||||
class RouteType(IntEnum):
|
||||
TRANSPORT_FLOOD = 0x00
|
||||
FLOOD = 0x01
|
||||
DIRECT = 0x02
|
||||
TRANSPORT_DIRECT = 0x03
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptedGroupText:
|
||||
"""Result of decrypting a GroupText (channel) message."""
|
||||
|
||||
timestamp: int
|
||||
flags: int
|
||||
sender: str | None
|
||||
message: str
|
||||
channel_hash: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedAdvertisement:
|
||||
"""Result of parsing an advertisement packet."""
|
||||
|
||||
public_key: str # 64-char hex
|
||||
name: str | None
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PacketInfo:
|
||||
"""Basic packet header info."""
|
||||
|
||||
route_type: RouteType
|
||||
payload_type: PayloadType
|
||||
payload_version: int
|
||||
path_length: int
|
||||
payload: bytes
|
||||
|
||||
|
||||
def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
"""
|
||||
Calculate the channel hash from a 16-byte channel key.
|
||||
Returns the first byte of SHA256(key) as hex.
|
||||
"""
|
||||
hash_bytes = hashlib.sha256(channel_key).digest()
|
||||
return format(hash_bytes[0], "02x")
|
||||
|
||||
|
||||
def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
"""
|
||||
Extract just the payload from a raw packet, skipping header and path.
|
||||
|
||||
Packet structure:
|
||||
- Byte 0: header (route_type, payload_type, version)
|
||||
- For TRANSPORT routes: bytes 1-4 are transport codes
|
||||
- Next byte: path_length
|
||||
- Next path_length bytes: path data
|
||||
- Remaining: payload
|
||||
|
||||
Returns the payload bytes, or None if packet is malformed.
|
||||
"""
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = header & 0x03
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
|
||||
if route_type in (0x00, 0x03):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
# Skip path data
|
||||
if len(raw_packet) < offset + path_length:
|
||||
return None
|
||||
offset += path_length
|
||||
|
||||
# Rest is payload
|
||||
return raw_packet[offset:]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_packet(raw_packet: bytes) -> PacketInfo | None:
|
||||
"""Parse a raw packet and extract basic info."""
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = RouteType(header & 0x03)
|
||||
payload_type = PayloadType((header >> 2) & 0x0F)
|
||||
payload_version = (header >> 6) & 0x03
|
||||
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present
|
||||
if route_type in (RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
# Skip path data
|
||||
if len(raw_packet) < offset + path_length:
|
||||
return None
|
||||
offset += path_length
|
||||
|
||||
# Rest is payload
|
||||
payload = raw_packet[offset:]
|
||||
|
||||
return PacketInfo(
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
payload_version=payload_version,
|
||||
path_length=path_length,
|
||||
payload=payload,
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def decrypt_group_text(
|
||||
payload: bytes, channel_key: bytes
|
||||
) -> DecryptedGroupText | None:
|
||||
"""
|
||||
Decrypt a GroupText payload using the channel key.
|
||||
|
||||
GroupText structure:
|
||||
- channel_hash (1 byte): First byte of SHA256 of channel key
|
||||
- cipher_mac (2 bytes): First 2 bytes of HMAC-SHA256
|
||||
- ciphertext (rest): AES-128 ECB encrypted content
|
||||
|
||||
Decrypted content structure:
|
||||
- timestamp (4 bytes, little-endian)
|
||||
- flags (1 byte)
|
||||
- message text (null-terminated string, format: "sender: message")
|
||||
"""
|
||||
if len(payload) < 3:
|
||||
return None
|
||||
|
||||
channel_hash = format(payload[0], "02x")
|
||||
cipher_mac = payload[1:3]
|
||||
ciphertext = payload[3:]
|
||||
|
||||
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
|
||||
# AES requires 16-byte blocks
|
||||
return None
|
||||
|
||||
# Create the 32-byte channel secret (key + 16 zero bytes)
|
||||
channel_secret = channel_key + bytes(16)
|
||||
|
||||
# Verify MAC: HMAC-SHA256 of ciphertext using full 32-byte secret
|
||||
calculated_mac = hmac.new(channel_secret, ciphertext, hashlib.sha256).digest()
|
||||
if calculated_mac[:2] != cipher_mac:
|
||||
return None
|
||||
|
||||
# Decrypt using AES-128 ECB with the 16-byte key
|
||||
try:
|
||||
cipher = AES.new(channel_key, AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(ciphertext)
|
||||
except Exception as e:
|
||||
logger.debug("AES decryption failed: %s", e)
|
||||
return None
|
||||
|
||||
if len(decrypted) < 5:
|
||||
return None
|
||||
|
||||
# Parse decrypted content
|
||||
timestamp = int.from_bytes(decrypted[0:4], "little")
|
||||
flags = decrypted[4]
|
||||
|
||||
# Extract message text (UTF-8, null-terminated)
|
||||
message_bytes = decrypted[5:]
|
||||
try:
|
||||
message_text = message_bytes.decode("utf-8")
|
||||
# Remove null terminator and any padding
|
||||
null_idx = message_text.find("\x00")
|
||||
if null_idx >= 0:
|
||||
message_text = message_text[:null_idx]
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
# Parse "sender: message" format
|
||||
sender = None
|
||||
content = message_text
|
||||
colon_idx = message_text.find(": ")
|
||||
if 0 < colon_idx < 50:
|
||||
potential_sender = message_text[:colon_idx]
|
||||
# Check for invalid characters in sender name
|
||||
if not any(c in potential_sender for c in ":[]\x00"):
|
||||
sender = potential_sender
|
||||
content = message_text[colon_idx + 2 :]
|
||||
|
||||
return DecryptedGroupText(
|
||||
timestamp=timestamp,
|
||||
flags=flags,
|
||||
sender=sender,
|
||||
message=content,
|
||||
channel_hash=channel_hash,
|
||||
)
|
||||
|
||||
|
||||
def try_decrypt_packet_with_channel_key(
|
||||
raw_packet: bytes, channel_key: bytes
|
||||
) -> DecryptedGroupText | None:
|
||||
"""
|
||||
Try to decrypt a raw packet using a channel key.
|
||||
Returns decrypted content if successful, None otherwise.
|
||||
"""
|
||||
packet_info = parse_packet(raw_packet)
|
||||
if packet_info is None:
|
||||
return None
|
||||
|
||||
# Only GroupText packets can be decrypted with channel keys
|
||||
if packet_info.payload_type != PayloadType.GROUP_TEXT:
|
||||
return None
|
||||
|
||||
# Check if channel hash matches
|
||||
if len(packet_info.payload) < 1:
|
||||
return None
|
||||
|
||||
packet_channel_hash = format(packet_info.payload[0], "02x")
|
||||
expected_hash = calculate_channel_hash(channel_key)
|
||||
|
||||
if packet_channel_hash != expected_hash:
|
||||
return None
|
||||
|
||||
return decrypt_group_text(packet_info.payload, channel_key)
|
||||
|
||||
|
||||
def get_packet_payload_type(raw_packet: bytes) -> PayloadType | None:
|
||||
"""Get the payload type of a raw packet without full parsing."""
|
||||
if len(raw_packet) < 1:
|
||||
return None
|
||||
header = raw_packet[0]
|
||||
try:
|
||||
return PayloadType((header >> 2) & 0x0F)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
|
||||
"""
|
||||
Parse an advertisement payload.
|
||||
|
||||
Advertisement structure:
|
||||
- public_key (32 bytes): Ed25519 public key
|
||||
- signature (64 bytes): Ed25519 signature
|
||||
- advert_data (variable): Contains name and possibly lat/lon
|
||||
|
||||
The name is typically at the end of the payload as a UTF-8 string.
|
||||
"""
|
||||
# Minimum: 32 (pubkey) + 64 (sig) + at least 1 byte for flags/data
|
||||
if len(payload) < 97:
|
||||
return None
|
||||
|
||||
public_key = payload[:32].hex()
|
||||
# signature = payload[32:96] # Not currently verified
|
||||
advert_data = payload[96:]
|
||||
|
||||
if len(advert_data) == 0:
|
||||
return ParsedAdvertisement(
|
||||
public_key=public_key,
|
||||
name=None,
|
||||
lat=None,
|
||||
lon=None,
|
||||
)
|
||||
|
||||
# Try to extract name from the advert data
|
||||
# The structure varies, but the name is typically near the end
|
||||
name = None
|
||||
lat = None
|
||||
lon = None
|
||||
|
||||
# Try to decode the entire advert_data as UTF-8 to find the name
|
||||
# Names are typically at the end after any binary data
|
||||
try:
|
||||
# Find the last valid UTF-8 string
|
||||
for start in range(len(advert_data)):
|
||||
try:
|
||||
text = advert_data[start:].decode("utf-8")
|
||||
# Filter out control characters and check if it looks like a name
|
||||
null_idx = text.find("\x00")
|
||||
if null_idx >= 0:
|
||||
text = text[:null_idx]
|
||||
text = text.strip()
|
||||
if text and len(text) >= 1 and len(text) <= 40:
|
||||
# Check if it contains printable characters
|
||||
if any(c.isalnum() for c in text):
|
||||
name = text
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ParsedAdvertisement(
|
||||
public_key=public_key,
|
||||
name=name,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
|
||||
|
||||
def try_parse_advertisement(raw_packet: bytes) -> ParsedAdvertisement | None:
|
||||
"""
|
||||
Try to parse a raw packet as an advertisement.
|
||||
Returns parsed advertisement if successful, None otherwise.
|
||||
"""
|
||||
packet_info = parse_packet(raw_packet)
|
||||
if packet_info is None:
|
||||
return None
|
||||
|
||||
if packet_info.payload_type != PayloadType.ADVERT:
|
||||
return None
|
||||
|
||||
return parse_advertisement(packet_info.payload)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Shared dependencies for FastAPI routers."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.radio import radio_manager
|
||||
|
||||
|
||||
def require_connected():
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance.
|
||||
|
||||
Raises HTTPException 503 if radio is not connected.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
return radio_manager.meshcore
|
||||
@@ -0,0 +1,193 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import Contact
|
||||
from app.packet_processor import process_raw_packet, track_pending_repeat
|
||||
from app.repository import ContactRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
|
||||
_pending_acks: dict[str, tuple[int, float, int]] = {}
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
"""Track a pending ACK for a direct message."""
|
||||
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
|
||||
logger.debug("Tracking pending ACK %s for message %d (timeout %dms)", expected_ack, message_id, timeout_ms)
|
||||
|
||||
|
||||
def _cleanup_expired_acks() -> None:
|
||||
"""Remove expired pending ACKs."""
|
||||
now = time.time()
|
||||
expired = []
|
||||
for code, (msg_id, created_at, timeout_ms) in _pending_acks.items():
|
||||
if now - created_at > (timeout_ms / 1000) * 2: # 2x timeout as buffer
|
||||
expired.append(code)
|
||||
for code in expired:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Expired pending ACK %s", code)
|
||||
|
||||
|
||||
async def on_contact_message(event: "Event") -> None:
|
||||
"""Handle incoming direct messages.
|
||||
|
||||
Direct messages are decrypted by MeshCore library using ECDH key exchange.
|
||||
The packet processor cannot decrypt these without the node's private key.
|
||||
"""
|
||||
payload = event.payload
|
||||
logger.debug("Received direct message from %s", payload.get("pubkey_prefix"))
|
||||
|
||||
# Get full public key if available, otherwise use prefix
|
||||
sender_pubkey = payload.get("public_key") or payload.get("pubkey_prefix", "")
|
||||
received_at = int(time.time())
|
||||
|
||||
# Look up full public key from contact database if we only have prefix
|
||||
if len(sender_pubkey) < 64:
|
||||
contact = await ContactRepository.get_by_key_prefix(sender_pubkey)
|
||||
if contact:
|
||||
sender_pubkey = contact.public_key
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=payload.get("text", ""),
|
||||
conversation_key=sender_pubkey,
|
||||
sender_timestamp=payload.get("sender_timestamp"),
|
||||
received_at=received_at,
|
||||
path_len=payload.get("path_len"),
|
||||
txt_type=payload.get("txt_type", 0),
|
||||
signature=payload.get("signature"),
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message (same content from same sender) - skip broadcast
|
||||
logger.debug("Duplicate direct message from %s ignored", sender_pubkey[:12])
|
||||
return
|
||||
|
||||
# Broadcast only genuinely new messages
|
||||
broadcast_event("message", {
|
||||
"id": msg_id,
|
||||
"type": "PRIV",
|
||||
"conversation_key": sender_pubkey,
|
||||
"text": payload.get("text", ""),
|
||||
"sender_timestamp": payload.get("sender_timestamp"),
|
||||
"received_at": received_at,
|
||||
"path_len": payload.get("path_len"),
|
||||
"txt_type": payload.get("txt_type", 0),
|
||||
"signature": payload.get("signature"),
|
||||
"outgoing": False,
|
||||
"acked": False,
|
||||
})
|
||||
|
||||
# Update contact last_seen and last_contacted
|
||||
contact = await ContactRepository.get_by_key_prefix(sender_pubkey)
|
||||
if contact:
|
||||
await ContactRepository.update_last_contacted(contact.public_key, received_at)
|
||||
|
||||
|
||||
async def on_rx_log_data(event: "Event") -> None:
|
||||
"""Store raw RF packet data and process via centralized packet processor.
|
||||
|
||||
This is the unified entry point for all RF packets. The packet processor
|
||||
handles channel messages (GROUP_TEXT) and advertisements (ADVERT).
|
||||
"""
|
||||
payload = event.payload
|
||||
logger.debug("Received RX log data packet")
|
||||
|
||||
if "payload" not in payload:
|
||||
logger.warning("RX_LOG_DATA event missing 'payload' field")
|
||||
return
|
||||
|
||||
raw_hex = payload["payload"]
|
||||
raw_bytes = bytes.fromhex(raw_hex)
|
||||
|
||||
await process_raw_packet(
|
||||
raw_bytes=raw_bytes,
|
||||
snr=payload.get("snr"),
|
||||
rssi=payload.get("rssi"),
|
||||
)
|
||||
|
||||
|
||||
async def on_path_update(event: "Event") -> None:
|
||||
"""Handle path update events."""
|
||||
payload = event.payload
|
||||
logger.debug("Path update for %s", payload.get("pubkey_prefix"))
|
||||
|
||||
pubkey_prefix = payload.get("pubkey_prefix", "")
|
||||
path = payload.get("path", "")
|
||||
path_len = payload.get("path_len", -1)
|
||||
|
||||
existing = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
if existing:
|
||||
await ContactRepository.update_path(existing.public_key, path, path_len)
|
||||
|
||||
|
||||
async def on_new_contact(event: "Event") -> None:
|
||||
"""Handle new contact from radio's internal contact database.
|
||||
|
||||
This is different from RF advertisements - these are contacts synced
|
||||
from the radio's stored contact list.
|
||||
"""
|
||||
payload = event.payload
|
||||
public_key = payload.get("public_key", "")
|
||||
|
||||
if not public_key:
|
||||
logger.warning("Received new contact event with no public_key, skipping")
|
||||
return
|
||||
|
||||
logger.debug("New contact: %s", public_key[:12])
|
||||
|
||||
contact_data = {
|
||||
**Contact.from_radio_dict(public_key, payload, on_radio=True),
|
||||
"last_seen": int(time.time()),
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
|
||||
broadcast_event("contact", contact_data)
|
||||
|
||||
|
||||
async def on_ack(event: "Event") -> None:
|
||||
"""Handle ACK events for direct messages."""
|
||||
payload = event.payload
|
||||
ack_code = payload.get("code", "")
|
||||
|
||||
if not ack_code:
|
||||
logger.debug("Received ACK with no code")
|
||||
return
|
||||
|
||||
logger.debug("Received ACK with code %s", ack_code)
|
||||
|
||||
_cleanup_expired_acks()
|
||||
|
||||
if ack_code in _pending_acks:
|
||||
message_id, _, _ = _pending_acks.pop(ack_code)
|
||||
logger.info("ACK received for message %d", message_id)
|
||||
|
||||
await MessageRepository.mark_acked(message_id)
|
||||
broadcast_event("message_acked", {"message_id": message_id})
|
||||
else:
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
def register_event_handlers(meshcore) -> None:
|
||||
"""Register event handlers with the MeshCore instance.
|
||||
|
||||
Note: CHANNEL_MSG_RECV and ADVERTISEMENT events are NOT subscribed.
|
||||
These are handled by the packet processor via RX_LOG_DATA to avoid
|
||||
duplicate processing and ensure consistent handling.
|
||||
"""
|
||||
meshcore.subscribe(EventType.CONTACT_MSG_RECV, on_contact_message)
|
||||
meshcore.subscribe(EventType.RX_LOG_DATA, on_rx_log_data)
|
||||
meshcore.subscribe(EventType.PATH_UPDATE, on_path_update)
|
||||
meshcore.subscribe(EventType.NEW_CONTACT, on_new_contact)
|
||||
meshcore.subscribe(EventType.ACK, on_ack)
|
||||
logger.info("Event handlers registered")
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import (
|
||||
start_periodic_sync,
|
||||
stop_periodic_sync,
|
||||
sync_and_offload_all,
|
||||
)
|
||||
from app.routers import channels, contacts, health, messages, packets, radio, settings, ws
|
||||
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage database and radio connection lifecycle."""
|
||||
await db.connect()
|
||||
logger.info("Database connected")
|
||||
|
||||
try:
|
||||
await radio_manager.connect()
|
||||
logger.info("Connected to radio")
|
||||
if radio_manager.meshcore:
|
||||
register_event_handlers(radio_manager.meshcore)
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all()
|
||||
logger.info("Sync complete: %s", result)
|
||||
|
||||
# Start periodic sync
|
||||
start_periodic_sync()
|
||||
|
||||
# Send advertisement to announce our presence
|
||||
logger.info("Sending startup advertisement...")
|
||||
advert_result = await radio_manager.meshcore.commands.send_advert(flood=True)
|
||||
logger.info("Advertisement sent: %s", advert_result.type)
|
||||
|
||||
await radio_manager.meshcore.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to radio on startup: %s", e)
|
||||
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down")
|
||||
await radio_manager.stop_connection_monitor()
|
||||
stop_periodic_sync()
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.stop_auto_message_fetching()
|
||||
await radio_manager.disconnect()
|
||||
await db.disconnect()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="RemoteTerm for MeshCore API",
|
||||
description="API for interacting with MeshCore mesh radio networks",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API routes - all prefixed with /api for production compatibility
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(radio.router, prefix="/api")
|
||||
app.include_router(contacts.router, prefix="/api")
|
||||
app.include_router(channels.router, prefix="/api")
|
||||
app.include_router(messages.router, prefix="/api")
|
||||
app.include_router(packets.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
# Serve static assets (JS, CSS, etc.)
|
||||
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
|
||||
|
||||
# Serve other static files from frontend/dist (like wordlist)
|
||||
@app.get("/{path:path}")
|
||||
async def serve_frontend(path: str):
|
||||
"""Serve frontend files, falling back to index.html for SPA routing."""
|
||||
file_path = FRONTEND_DIR / path
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
# Fall back to index.html for SPA routing
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
"""Serve the frontend index.html."""
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
name: str | None = None
|
||||
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
last_seen: int | None = None
|
||||
on_radio: bool = False
|
||||
last_contacted: int | None = None # Last time we sent/received a message
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
|
||||
The radio API uses different field names (adv_name, out_path, etc.)
|
||||
than our database schema (name, last_path, etc.).
|
||||
"""
|
||||
return {
|
||||
"public_key": self.public_key,
|
||||
"adv_name": self.name or "",
|
||||
"type": self.type,
|
||||
"flags": self.flags,
|
||||
"out_path": self.last_path or "",
|
||||
"out_path_len": self.last_path_len,
|
||||
"adv_lat": self.lat or 0.0,
|
||||
"adv_lon": self.lon or 0.0,
|
||||
"last_advert": self.last_advert or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
|
||||
"""Convert radio contact data to database format dict.
|
||||
|
||||
This is the inverse of to_radio_dict(), used when syncing contacts
|
||||
from radio to database.
|
||||
"""
|
||||
return {
|
||||
"public_key": public_key,
|
||||
"name": radio_data.get("adv_name"),
|
||||
"type": radio_data.get("type", 0),
|
||||
"flags": radio_data.get("flags", 0),
|
||||
"last_path": radio_data.get("out_path"),
|
||||
"last_path_len": radio_data.get("out_path_len", -1),
|
||||
"lat": radio_data.get("adv_lat"),
|
||||
"lon": radio_data.get("adv_lon"),
|
||||
"last_advert": radio_data.get("last_advert"),
|
||||
"on_radio": on_radio,
|
||||
}
|
||||
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
|
||||
|
||||
class Channel(BaseModel):
|
||||
key: str = Field(description="Channel key (32-char hex)")
|
||||
name: str
|
||||
is_hashtag: bool = False
|
||||
on_radio: bool = False
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
id: int
|
||||
type: str = Field(description="PRIV or CHAN")
|
||||
conversation_key: str = Field(description="User pubkey for PRIV, channel key for CHAN")
|
||||
text: str
|
||||
sender_timestamp: int | None = None
|
||||
received_at: int
|
||||
path_len: int | None = None
|
||||
txt_type: int = 0
|
||||
signature: str | None = None
|
||||
outgoing: bool = False
|
||||
acked: bool = False
|
||||
|
||||
|
||||
class RawPacket(BaseModel):
|
||||
"""Raw packet as stored in the database."""
|
||||
id: int
|
||||
timestamp: int
|
||||
data: str = Field(description="Hex-encoded packet data")
|
||||
decrypted: bool = False
|
||||
message_id: int | None = None
|
||||
decrypt_attempts: int = 0
|
||||
last_attempt: int | None = None
|
||||
|
||||
|
||||
class RawPacketDecryptedInfo(BaseModel):
|
||||
"""Decryption info for a raw packet (when successfully decrypted)."""
|
||||
channel_name: str | None = None
|
||||
sender: str | None = None
|
||||
|
||||
|
||||
class RawPacketBroadcast(BaseModel):
|
||||
"""Raw packet payload broadcast via WebSocket.
|
||||
|
||||
This extends the database model with runtime-computed fields
|
||||
like payload_type, snr, rssi, and decryption info.
|
||||
"""
|
||||
id: int
|
||||
timestamp: int
|
||||
data: str = Field(description="Hex-encoded packet data")
|
||||
payload_type: str = Field(description="Packet type name (e.g., GROUP_TEXT, ADVERT)")
|
||||
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB")
|
||||
rssi: int | None = Field(default=None, description="Received signal strength in dBm")
|
||||
decrypted: bool = False
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
|
||||
class SendDirectMessageRequest(SendMessageRequest):
|
||||
destination: str = Field(description="Public key or prefix of recipient")
|
||||
|
||||
|
||||
class SendChannelMessageRequest(SendMessageRequest):
|
||||
channel_key: str = Field(description="Channel key (32-char hex)")
|
||||
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Centralized packet processing for MeshCore messages.
|
||||
|
||||
This module handles:
|
||||
- Storing raw packets
|
||||
- Decrypting channel messages (GroupText) with stored channel keys
|
||||
- Decrypting direct messages with stored contact keys (if private key available)
|
||||
- Creating message entries for successfully decrypted packets
|
||||
- Broadcasting updates via WebSocket
|
||||
|
||||
This is the primary path for message processing when channel/contact keys
|
||||
are offloaded from the radio to the server.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.decoder import (
|
||||
PayloadType,
|
||||
parse_packet,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
try_parse_advertisement,
|
||||
)
|
||||
from app.models import CONTACT_TYPE_REPEATER, RawPacketBroadcast, RawPacketDecryptedInfo
|
||||
from app.repository import ChannelRepository, ContactRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Pending repeats for outgoing message ACK detection
|
||||
# Key: (channel_key, text_hash, timestamp) -> message_id
|
||||
_pending_repeats: dict[tuple[str, str, int], int] = {}
|
||||
_pending_repeat_expiry: dict[tuple[str, str, int], float] = {}
|
||||
REPEAT_EXPIRY_SECONDS = 30
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
packet_id: int,
|
||||
channel_key: str,
|
||||
sender: str | None,
|
||||
message_text: str,
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted channel packet content.
|
||||
|
||||
This is the shared logic for storing decrypted channel messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
import time as time_module
|
||||
received = received_at or int(time_module.time())
|
||||
|
||||
# Format the message text
|
||||
text = f"{sender}: {message_text}" if sender else message_text
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=channel_key.upper(),
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate detected - find existing message ID for packet linkage
|
||||
existing_id = await MessageRepository.find_duplicate(
|
||||
conversation_key=channel_key.upper(),
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
)
|
||||
if existing_id:
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_id)
|
||||
return None
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
return msg_id
|
||||
|
||||
|
||||
def track_pending_repeat(channel_key: str, text: str, timestamp: int, message_id: int) -> None:
|
||||
"""Track an outgoing channel message for repeat detection."""
|
||||
text_hash = str(hash(text))
|
||||
key = (channel_key.upper(), text_hash, timestamp)
|
||||
_pending_repeats[key] = message_id
|
||||
_pending_repeat_expiry[key] = time.time() + REPEAT_EXPIRY_SECONDS
|
||||
logger.debug("Tracking repeat for channel %s, message %d", channel_key[:8], message_id)
|
||||
|
||||
|
||||
def _cleanup_expired_repeats() -> None:
|
||||
"""Remove expired pending repeats."""
|
||||
now = time.time()
|
||||
expired = [k for k, exp in _pending_repeat_expiry.items() if exp < now]
|
||||
for k in expired:
|
||||
_pending_repeats.pop(k, None)
|
||||
_pending_repeat_expiry.pop(k, None)
|
||||
|
||||
|
||||
async def process_raw_packet(
|
||||
raw_bytes: bytes,
|
||||
timestamp: int | None = None,
|
||||
snr: float | None = None,
|
||||
rssi: int | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Process an incoming raw packet.
|
||||
|
||||
This is the main entry point for all incoming RF packets.
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
|
||||
packet_id = await RawPacketRepository.create(raw_bytes, ts)
|
||||
|
||||
raw_hex = raw_bytes.hex()
|
||||
|
||||
# Parse packet to get type
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
payload_type = packet_info.payload_type if packet_info else None
|
||||
payload_type_name = payload_type.name if payload_type else "Unknown"
|
||||
|
||||
result = {
|
||||
"packet_id": packet_id,
|
||||
"timestamp": ts,
|
||||
"raw_hex": raw_hex,
|
||||
"payload_type": payload_type_name,
|
||||
"snr": snr,
|
||||
"rssi": rssi,
|
||||
"decrypted": False,
|
||||
"message_id": None,
|
||||
"channel_name": None,
|
||||
"sender": None,
|
||||
}
|
||||
|
||||
# Try to decrypt/parse based on payload type
|
||||
if payload_type == PayloadType.GROUP_TEXT:
|
||||
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
elif payload_type == PayloadType.ADVERT:
|
||||
await _process_advertisement(raw_bytes, ts)
|
||||
|
||||
# TODO: Add TEXT_MESSAGE (direct message) decryption when private key is available
|
||||
# elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
# if decrypt_result:
|
||||
# result.update(decrypt_result)
|
||||
|
||||
# Broadcast raw packet for the packet feed UI
|
||||
broadcast_payload = RawPacketBroadcast(
|
||||
id=packet_id,
|
||||
timestamp=ts,
|
||||
data=raw_hex,
|
||||
payload_type=payload_type_name,
|
||||
snr=snr,
|
||||
rssi=rssi,
|
||||
decrypted=result["decrypted"],
|
||||
decrypted_info=RawPacketDecryptedInfo(
|
||||
channel_name=result["channel_name"],
|
||||
sender=result["sender"],
|
||||
) if result["decrypted"] else None,
|
||||
)
|
||||
broadcast_event("raw_packet", broadcast_payload.model_dump())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _process_group_text(
|
||||
raw_bytes: bytes,
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a GroupText (channel message) packet.
|
||||
|
||||
Tries all known channel keys to decrypt.
|
||||
Creates a message entry if successful.
|
||||
Handles repeat detection for outgoing message ACKs.
|
||||
"""
|
||||
# Try to decrypt with all known channel keys
|
||||
channels = await ChannelRepository.get_all()
|
||||
|
||||
for channel in channels:
|
||||
# Convert hex key to bytes for decryption
|
||||
try:
|
||||
channel_key_bytes = bytes.fromhex(channel.key)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
decrypted = try_decrypt_packet_with_channel_key(raw_bytes, channel_key_bytes)
|
||||
if not decrypted:
|
||||
continue
|
||||
|
||||
# Successfully decrypted!
|
||||
logger.debug(
|
||||
"Decrypted GroupText for channel %s: %s",
|
||||
channel.name, decrypted.message[:50]
|
||||
)
|
||||
|
||||
# Check for repeat detection (our own message echoed back)
|
||||
is_repeat = False
|
||||
_cleanup_expired_repeats()
|
||||
text_hash = str(hash(decrypted.message))
|
||||
|
||||
for ts_offset in range(-5, 6):
|
||||
key = (channel.key, text_hash, decrypted.timestamp + ts_offset)
|
||||
if key in _pending_repeats:
|
||||
message_id = _pending_repeats[key]
|
||||
# Don't pop - let it expire naturally so subsequent repeats via
|
||||
# different radio paths are also caught as duplicates
|
||||
logger.info("Repeat detected for channel message %d", message_id)
|
||||
await MessageRepository.mark_acked(message_id)
|
||||
broadcast_event("message_acked", {"message_id": message_id})
|
||||
is_repeat = True
|
||||
break
|
||||
|
||||
if is_repeat:
|
||||
# Mark packet as decrypted but don't create new message
|
||||
await RawPacketRepository.mark_decrypted(packet_id, message_id)
|
||||
return {
|
||||
"decrypted": True,
|
||||
"channel_name": channel.name,
|
||||
"sender": decrypted.sender,
|
||||
"message_id": message_id,
|
||||
}
|
||||
|
||||
# Format the message text
|
||||
if decrypted.sender:
|
||||
text = f"{decrypted.sender}: {decrypted.message}"
|
||||
else:
|
||||
text = decrypted.message
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=channel.key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=timestamp,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate detected by database constraint (same message via different RF path)
|
||||
# Find existing message ID for packet linkage
|
||||
existing_id = await MessageRepository.find_duplicate(
|
||||
conversation_key=channel.key,
|
||||
text=text,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
)
|
||||
logger.debug(
|
||||
"Duplicate message detected for channel %s (existing id=%s)",
|
||||
channel.name, existing_id
|
||||
)
|
||||
if existing_id:
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_id)
|
||||
return {
|
||||
"decrypted": True,
|
||||
"channel_name": channel.name,
|
||||
"sender": decrypted.sender,
|
||||
"message_id": existing_id,
|
||||
}
|
||||
|
||||
logger.info("Stored channel message %d for %s", msg_id, channel.name)
|
||||
|
||||
# Broadcast new message (only for genuinely new messages)
|
||||
broadcast_event("message", {
|
||||
"id": msg_id,
|
||||
"type": "CHAN",
|
||||
"conversation_key": channel.key,
|
||||
"text": text,
|
||||
"sender_timestamp": decrypted.timestamp,
|
||||
"received_at": timestamp,
|
||||
"path_len": packet_info.path_length if packet_info else None,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": False,
|
||||
"acked": False,
|
||||
})
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
return {
|
||||
"decrypted": True,
|
||||
"channel_name": channel.name,
|
||||
"sender": decrypted.sender,
|
||||
"message_id": msg_id,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known key
|
||||
return None
|
||||
|
||||
|
||||
async def _process_advertisement(
|
||||
raw_bytes: bytes,
|
||||
timestamp: int,
|
||||
) -> None:
|
||||
"""
|
||||
Process an advertisement packet.
|
||||
|
||||
Extracts contact info and updates the database/broadcasts to clients.
|
||||
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
|
||||
"""
|
||||
advert = try_parse_advertisement(raw_bytes)
|
||||
if not advert:
|
||||
logger.debug("Failed to parse advertisement packet")
|
||||
return
|
||||
|
||||
logger.debug("Parsed advertisement from %s: %s", advert.public_key[:12], advert.name)
|
||||
|
||||
# Try to find existing contact
|
||||
existing = await ContactRepository.get_by_key(advert.public_key)
|
||||
|
||||
contact_data = {
|
||||
"public_key": advert.public_key,
|
||||
"name": advert.name,
|
||||
"lat": advert.lat,
|
||||
"lon": advert.lon,
|
||||
"last_advert": timestamp,
|
||||
"last_seen": timestamp,
|
||||
}
|
||||
|
||||
await ContactRepository.upsert(contact_data)
|
||||
|
||||
# Broadcast contact update to connected clients
|
||||
contact_type = existing.type if existing else 0
|
||||
broadcast_event("contact", {
|
||||
"public_key": advert.public_key,
|
||||
"name": advert.name,
|
||||
"type": contact_type,
|
||||
"flags": existing.flags if existing else 0,
|
||||
"last_path": existing.last_path if existing else None,
|
||||
"last_path_len": existing.last_path_len if existing else -1,
|
||||
"last_advert": timestamp,
|
||||
"lat": advert.lat,
|
||||
"lon": advert.lon,
|
||||
"last_seen": timestamp,
|
||||
"on_radio": existing.on_radio if existing else False,
|
||||
})
|
||||
|
||||
# If this is not a repeater, trigger recent contacts sync to radio
|
||||
# This ensures we can auto-ACK DMs from recent contacts
|
||||
if contact_type != CONTACT_TYPE_REPEATER:
|
||||
# Import here to avoid circular import
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
asyncio.create_task(sync_recent_contacts_to_radio())
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from meshcore import MeshCore
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def detect_serial_devices() -> list[str]:
|
||||
"""Detect available serial devices based on platform."""
|
||||
devices: list[str] = []
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
# macOS: Use /dev/cu.* devices (callout devices, preferred over tty.*)
|
||||
patterns = [
|
||||
"/dev/cu.usb*",
|
||||
"/dev/cu.wchusbserial*",
|
||||
"/dev/cu.SLAB_USBtoUART*",
|
||||
]
|
||||
for pattern in patterns:
|
||||
devices.extend(glob.glob(pattern))
|
||||
devices.sort()
|
||||
else:
|
||||
# Linux: Prefer /dev/serial/by-id/ for persistent naming
|
||||
by_id_path = Path("/dev/serial/by-id")
|
||||
if by_id_path.is_dir():
|
||||
devices.extend(str(p) for p in by_id_path.iterdir())
|
||||
|
||||
# Also check /dev/ttyACM* and /dev/ttyUSB* as fallback
|
||||
resolved_paths = set()
|
||||
for dev in devices:
|
||||
try:
|
||||
resolved_paths.add(str(Path(dev).resolve()))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for pattern in ["/dev/ttyACM*", "/dev/ttyUSB*"]:
|
||||
for dev in glob.glob(pattern):
|
||||
try:
|
||||
if str(Path(dev).resolve()) not in resolved_paths:
|
||||
devices.append(dev)
|
||||
except OSError:
|
||||
devices.append(dev)
|
||||
|
||||
devices.sort()
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) -> bool:
|
||||
"""Test if a MeshCore radio responds on the given serial port."""
|
||||
try:
|
||||
logger.debug("Testing serial device %s", port)
|
||||
mc = await asyncio.wait_for(
|
||||
MeshCore.create_serial(port=port, baudrate=baudrate),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Check if we got valid self_info (indicates successful communication)
|
||||
if mc.is_connected and mc.self_info:
|
||||
logger.debug("Device %s responded with valid self_info", port)
|
||||
await mc.disconnect()
|
||||
return True
|
||||
|
||||
await mc.disconnect()
|
||||
return False
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Device %s timed out", port)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("Device %s failed: %s", port, e)
|
||||
return False
|
||||
|
||||
|
||||
async def find_radio_port(baudrate: int) -> str | None:
|
||||
"""Find the first serial port with a responding MeshCore radio."""
|
||||
devices = detect_serial_devices()
|
||||
|
||||
if not devices:
|
||||
logger.warning("No serial devices found")
|
||||
return None
|
||||
|
||||
logger.info("Found %d serial device(s), testing for MeshCore radio...", len(devices))
|
||||
|
||||
for device in devices:
|
||||
if await test_serial_device(device, baudrate):
|
||||
logger.info("Found MeshCore radio at %s", device)
|
||||
return device
|
||||
|
||||
logger.warning("No MeshCore radio found on any serial device")
|
||||
return None
|
||||
|
||||
|
||||
class RadioManager:
|
||||
"""Manages the MeshCore radio connection."""
|
||||
|
||||
def __init__(self):
|
||||
self._meshcore: MeshCore | None = None
|
||||
self._port: str | None = None
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._last_connected: bool = False
|
||||
self._reconnecting: bool = False
|
||||
|
||||
@property
|
||||
def meshcore(self) -> MeshCore | None:
|
||||
return self._meshcore
|
||||
|
||||
@property
|
||||
def port(self) -> str | None:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._meshcore is not None and self._meshcore.is_connected
|
||||
|
||||
@property
|
||||
def is_reconnecting(self) -> bool:
|
||||
return self._reconnecting
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the radio over serial."""
|
||||
if self._meshcore is not None:
|
||||
await self.disconnect()
|
||||
|
||||
port = settings.serial_port
|
||||
|
||||
# Auto-detect if no port specified
|
||||
if not port:
|
||||
logger.info("No serial port specified, auto-detecting...")
|
||||
port = await find_radio_port(settings.serial_baudrate)
|
||||
if not port:
|
||||
raise RuntimeError("No MeshCore radio found. Please specify MESHCORE_SERIAL_PORT.")
|
||||
|
||||
logger.debug(
|
||||
"Connecting to radio at %s (baud %d)",
|
||||
port,
|
||||
settings.serial_baudrate,
|
||||
)
|
||||
self._meshcore = await MeshCore.create_serial(
|
||||
port=port,
|
||||
baudrate=settings.serial_baudrate,
|
||||
auto_reconnect=True,
|
||||
max_reconnect_attempts=10,
|
||||
)
|
||||
self._port = port
|
||||
self._last_connected = True
|
||||
logger.debug("Serial connection established")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the radio."""
|
||||
if self._meshcore is not None:
|
||||
logger.debug("Disconnecting from radio")
|
||||
await self._meshcore.disconnect()
|
||||
self._meshcore = None
|
||||
logger.debug("Radio disconnected")
|
||||
|
||||
async def reconnect(self) -> bool:
|
||||
"""Attempt to reconnect to the radio.
|
||||
|
||||
Returns True if reconnection was successful, False otherwise.
|
||||
"""
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
|
||||
if self._reconnecting:
|
||||
logger.debug("Reconnection already in progress")
|
||||
return False
|
||||
|
||||
self._reconnecting = True
|
||||
logger.info("Attempting to reconnect to radio...")
|
||||
|
||||
try:
|
||||
# Disconnect if we have a stale connection
|
||||
if self._meshcore is not None:
|
||||
try:
|
||||
await self._meshcore.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._meshcore = None
|
||||
|
||||
# Try to connect (will auto-detect if no port specified)
|
||||
await self.connect()
|
||||
|
||||
if self.is_connected:
|
||||
logger.info("Radio reconnected successfully at %s", self._port)
|
||||
broadcast_health(True, self._port)
|
||||
return True
|
||||
else:
|
||||
logger.warning("Reconnection failed: not connected after connect()")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Reconnection failed: %s", e)
|
||||
broadcast_error("Reconnection failed", str(e))
|
||||
return False
|
||||
finally:
|
||||
self._reconnecting = False
|
||||
|
||||
async def start_connection_monitor(self) -> None:
|
||||
"""Start background task to monitor connection and auto-reconnect."""
|
||||
if self._reconnect_task is not None:
|
||||
return
|
||||
|
||||
async def monitor_loop():
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(5) # Check every 5 seconds
|
||||
|
||||
current_connected = self.is_connected
|
||||
|
||||
# Detect status change
|
||||
if self._last_connected and not current_connected:
|
||||
# Connection lost
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, self._port)
|
||||
self._last_connected = False
|
||||
|
||||
# Attempt reconnection
|
||||
await asyncio.sleep(3) # Wait a bit before trying
|
||||
await self.reconnect()
|
||||
|
||||
elif not self._last_connected and current_connected:
|
||||
# Connection restored (might have reconnected automatically)
|
||||
logger.info("Radio connection restored")
|
||||
broadcast_health(True, self._port)
|
||||
self._last_connected = True
|
||||
|
||||
self._reconnect_task = asyncio.create_task(monitor_loop())
|
||||
logger.info("Radio connection monitor started")
|
||||
|
||||
async def stop_connection_monitor(self) -> None:
|
||||
"""Stop the connection monitor task."""
|
||||
if self._reconnect_task is not None:
|
||||
self._reconnect_task.cancel()
|
||||
try:
|
||||
await self._reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._reconnect_task = None
|
||||
logger.info("Radio connection monitor stopped")
|
||||
|
||||
|
||||
radio_manager = RadioManager()
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Radio sync and offload management.
|
||||
|
||||
This module handles syncing contacts and channels from the radio to the database,
|
||||
then removing them from the radio to free up space for new discoveries.
|
||||
|
||||
Also handles loading recent non-repeater contacts TO the radio for DM ACK support.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.config import settings
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ChannelRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Background task handle
|
||||
_sync_task: asyncio.Task | None = None
|
||||
|
||||
# Sync interval in seconds (5 minutes)
|
||||
SYNC_INTERVAL = 300
|
||||
|
||||
|
||||
async def sync_and_offload_contacts() -> dict:
|
||||
"""
|
||||
Sync contacts from radio to database, then remove them from radio.
|
||||
Returns counts of synced and removed contacts.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.warning("Cannot sync contacts: radio not connected")
|
||||
return {"synced": 0, "removed": 0, "error": "Radio not connected"}
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
synced = 0
|
||||
removed = 0
|
||||
|
||||
try:
|
||||
# Get all contacts from radio
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.error("Failed to get contacts from radio: %s", result)
|
||||
return {"synced": 0, "removed": 0, "error": str(result)}
|
||||
|
||||
contacts = result.payload or {}
|
||||
logger.info("Found %d contacts on radio", len(contacts))
|
||||
|
||||
# Sync each contact to database, then remove from radio
|
||||
for public_key, contact_data in contacts.items():
|
||||
# Save to database
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
)
|
||||
synced += 1
|
||||
|
||||
# Remove from radio
|
||||
try:
|
||||
remove_result = await mc.commands.remove_contact(contact_data)
|
||||
if remove_result.type == EventType.OK:
|
||||
removed += 1
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to remove contact %s: %s",
|
||||
public_key[:12], remove_result.payload
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Error removing contact %s: %s", public_key[:12], e)
|
||||
|
||||
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error during contact sync: %s", e)
|
||||
return {"synced": synced, "removed": removed, "error": str(e)}
|
||||
|
||||
return {"synced": synced, "removed": removed}
|
||||
|
||||
|
||||
async def sync_and_offload_channels() -> dict:
|
||||
"""
|
||||
Sync channels from radio to database, then clear them from radio.
|
||||
Returns counts of synced and cleared channels.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.warning("Cannot sync channels: radio not connected")
|
||||
return {"synced": 0, "cleared": 0, "error": "Radio not connected"}
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
synced = 0
|
||||
cleared = 0
|
||||
|
||||
try:
|
||||
# Check all 40 channel slots
|
||||
for idx in range(40):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
|
||||
if result.type != EventType.CHANNEL_INFO:
|
||||
continue
|
||||
|
||||
payload = result.payload
|
||||
name = payload.get("channel_name", "")
|
||||
secret = payload.get("channel_secret", b"")
|
||||
|
||||
# Skip empty channels
|
||||
if not name or name == "\x00" * len(name) or all(b == 0 for b in secret):
|
||||
continue
|
||||
|
||||
is_hashtag = name.startswith("#")
|
||||
|
||||
# Convert key bytes to hex string
|
||||
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
|
||||
key_hex = key_bytes.hex().upper()
|
||||
|
||||
# Save to database
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False, # We're about to clear it
|
||||
)
|
||||
synced += 1
|
||||
logger.debug("Synced channel %s: %s", key_hex[:8], name)
|
||||
|
||||
# Clear from radio (set empty name and zero key)
|
||||
try:
|
||||
clear_result = await mc.commands.set_channel(
|
||||
channel_idx=idx,
|
||||
channel_name="",
|
||||
channel_secret=bytes(16),
|
||||
)
|
||||
if clear_result.type == EventType.OK:
|
||||
cleared += 1
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to clear channel %d: %s",
|
||||
idx, clear_result.payload
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Error clearing channel %d: %s", idx, e)
|
||||
|
||||
logger.info("Synced %d channels, cleared %d from radio", synced, cleared)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error during channel sync: %s", e)
|
||||
return {"synced": synced, "cleared": cleared, "error": str(e)}
|
||||
|
||||
return {"synced": synced, "cleared": cleared}
|
||||
|
||||
|
||||
async def ensure_default_channels() -> None:
|
||||
"""
|
||||
Ensure default channels exist in the database.
|
||||
These will be configured on the radio when needed for sending.
|
||||
"""
|
||||
# Public channel - no hashtag, specific key
|
||||
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
|
||||
|
||||
existing = await ChannelRepository.get_by_name("Public")
|
||||
if not existing:
|
||||
logger.info("Creating default Public channel")
|
||||
await ChannelRepository.upsert(
|
||||
key=PUBLIC_CHANNEL_KEY_HEX,
|
||||
name="Public",
|
||||
is_hashtag=False,
|
||||
on_radio=False,
|
||||
)
|
||||
|
||||
|
||||
async def sync_and_offload_all() -> dict:
|
||||
"""Sync and offload both contacts and channels, then ensure defaults exist."""
|
||||
logger.info("Starting full radio sync and offload")
|
||||
|
||||
contacts_result = await sync_and_offload_contacts()
|
||||
channels_result = await sync_and_offload_channels()
|
||||
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
return {
|
||||
"contacts": contacts_result,
|
||||
"channels": channels_result,
|
||||
}
|
||||
|
||||
|
||||
async def _periodic_sync_loop():
|
||||
"""Background task that periodically syncs and offloads."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SYNC_INTERVAL)
|
||||
logger.debug("Running periodic radio sync")
|
||||
await sync_and_offload_all()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Periodic sync task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic sync: %s", e)
|
||||
|
||||
|
||||
def start_periodic_sync():
|
||||
"""Start the periodic sync background task."""
|
||||
global _sync_task
|
||||
if _sync_task is None or _sync_task.done():
|
||||
_sync_task = asyncio.create_task(_periodic_sync_loop())
|
||||
logger.info("Started periodic radio sync (interval: %ds)", SYNC_INTERVAL)
|
||||
|
||||
|
||||
def stop_periodic_sync():
|
||||
"""Stop the periodic sync background task."""
|
||||
global _sync_task
|
||||
if _sync_task and not _sync_task.done():
|
||||
_sync_task.cancel()
|
||||
logger.info("Stopped periodic radio sync")
|
||||
|
||||
|
||||
# Throttling for contact sync to radio
|
||||
_last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
|
||||
|
||||
async def sync_recent_contacts_to_radio(force: bool = False) -> dict:
|
||||
"""
|
||||
Load recent non-repeater contacts to the radio for DM ACK support.
|
||||
|
||||
This ensures the radio can auto-ACK incoming DMs from recent contacts.
|
||||
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
|
||||
|
||||
Returns counts of contacts loaded.
|
||||
"""
|
||||
global _last_contact_sync
|
||||
|
||||
# Throttle unless forced
|
||||
now = time.time()
|
||||
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
|
||||
logger.debug("Contact sync throttled (last sync %ds ago)", int(now - _last_contact_sync))
|
||||
return {"loaded": 0, "throttled": True}
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot sync contacts to radio: not connected")
|
||||
return {"loaded": 0, "error": "Radio not connected"}
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
_last_contact_sync = now
|
||||
|
||||
try:
|
||||
# Get recent non-repeater contacts from database
|
||||
max_contacts = settings.max_radio_contacts
|
||||
contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
|
||||
logger.debug("Found %d recent non-repeater contacts to sync", len(contacts))
|
||||
|
||||
loaded = 0
|
||||
already_on_radio = 0
|
||||
failed = 0
|
||||
|
||||
for contact in contacts:
|
||||
# Check if already on radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
already_on_radio += 1
|
||||
# Update DB if not marked as on_radio
|
||||
if not contact.on_radio:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if result.type == EventType.OK:
|
||||
loaded += 1
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
|
||||
else:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"Failed to load contact %s: %s",
|
||||
contact.public_key[:12], result.payload
|
||||
)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning("Error loading contact %s: %s", contact.public_key[:12], e)
|
||||
|
||||
if loaded > 0 or failed > 0:
|
||||
logger.info(
|
||||
"Contact sync: loaded %d, already on radio %d, failed %d",
|
||||
loaded, already_on_radio, failed
|
||||
)
|
||||
|
||||
return {
|
||||
"loaded": loaded,
|
||||
"already_on_radio": already_on_radio,
|
||||
"failed": failed,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error syncing contacts to radio: %s", e)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
@@ -0,0 +1,483 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import Channel, Contact, Message, RawPacket
|
||||
|
||||
|
||||
class ContactRepository:
|
||||
@staticmethod
|
||||
async def upsert(contact: dict[str, Any]) -> None:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
|
||||
last_advert, lat, lon, last_seen, on_radio, last_contacted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, contacts.name),
|
||||
type = excluded.type,
|
||||
flags = excluded.flags,
|
||||
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
||||
last_path_len = excluded.last_path_len,
|
||||
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
|
||||
lat = COALESCE(excluded.lat, contacts.lat),
|
||||
lon = COALESCE(excluded.lon, contacts.lon),
|
||||
last_seen = excluded.last_seen,
|
||||
on_radio = excluded.on_radio,
|
||||
last_contacted = COALESCE(excluded.last_contacted, contacts.last_contacted)
|
||||
""",
|
||||
(
|
||||
contact.get("public_key"),
|
||||
contact.get("name") or contact.get("adv_name"),
|
||||
contact.get("type", 0),
|
||||
contact.get("flags", 0),
|
||||
contact.get("last_path") or contact.get("out_path"),
|
||||
contact.get("last_path_len") if "last_path_len" in contact else contact.get("out_path_len", -1),
|
||||
contact.get("last_advert"),
|
||||
contact.get("lat") or contact.get("adv_lat"),
|
||||
contact.get("lon") or contact.get("adv_lon"),
|
||||
contact.get("last_seen", int(time.time())),
|
||||
contact.get("on_radio", False),
|
||||
contact.get("last_contacted"),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _row_to_contact(row) -> Contact:
|
||||
"""Convert a database row to a Contact model."""
|
||||
return Contact(
|
||||
public_key=row["public_key"],
|
||||
name=row["name"],
|
||||
type=row["type"],
|
||||
flags=row["flags"],
|
||||
last_path=row["last_path"],
|
||||
last_path_len=row["last_path_len"],
|
||||
last_advert=row["last_advert"],
|
||||
lat=row["lat"],
|
||||
lon=row["lon"],
|
||||
last_seen=row["last_seen"],
|
||||
on_radio=bool(row["on_radio"]),
|
||||
last_contacted=row["last_contacted"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key(public_key: str) -> Contact | None:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key = ?", (public_key,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return ContactRepository._row_to_contact(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key_prefix(prefix: str) -> Contact | None:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? LIMIT 1",
|
||||
(f"{prefix}%",),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return ContactRepository._row_to_contact(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key_or_prefix(key_or_prefix: str) -> Contact | None:
|
||||
"""Get a contact by exact key match, falling back to prefix match.
|
||||
|
||||
Useful when the input might be a full 64-char public key or a shorter prefix.
|
||||
"""
|
||||
contact = await ContactRepository.get_by_key(key_or_prefix)
|
||||
if not contact:
|
||||
contact = await ContactRepository.get_by_key_prefix(key_or_prefix)
|
||||
return contact
|
||||
|
||||
@staticmethod
|
||||
async def get_all(limit: int = 100, offset: int = 0) -> list[Contact]:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts ORDER BY COALESCE(name, public_key) LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get the most recently active non-repeater contacts.
|
||||
|
||||
Orders by most recent activity (last_contacted or last_advert),
|
||||
excluding repeaters (type=2).
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2
|
||||
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def update_path(public_key: str, path: str, path_len: int) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
|
||||
(path, path_len, int(time.time()), public_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_on_radio(public_key: str, on_radio: bool) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
|
||||
(on_radio, public_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contacts WHERE public_key = ?",
|
||||
(public_key,),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_contacted(public_key: str, timestamp: int | None = None) -> None:
|
||||
"""Update the last_contacted timestamp for a contact."""
|
||||
ts = timestamp or int(time.time())
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET last_contacted = ?, last_seen = ? WHERE public_key = ?",
|
||||
(ts, ts, public_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_all_on_radio() -> None:
|
||||
"""Clear the on_radio flag for all contacts."""
|
||||
await db.conn.execute("UPDATE contacts SET on_radio = 0")
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_multiple_on_radio(public_keys: list[str], on_radio: bool = True) -> None:
|
||||
"""Set on_radio flag for multiple contacts."""
|
||||
if not public_keys:
|
||||
return
|
||||
placeholders = ",".join("?" * len(public_keys))
|
||||
await db.conn.execute(
|
||||
f"UPDATE contacts SET on_radio = ? WHERE public_key IN ({placeholders})",
|
||||
[on_radio] + public_keys,
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
|
||||
class ChannelRepository:
|
||||
@staticmethod
|
||||
async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None:
|
||||
"""Upsert a channel. Key is 32-char hex string."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO channels (key, name, is_hashtag, on_radio)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_hashtag = excluded.is_hashtag,
|
||||
on_radio = excluded.on_radio
|
||||
""",
|
||||
(key.upper(), name, is_hashtag, on_radio),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key(key: str) -> Channel | None:
|
||||
"""Get a channel by its key (32-char hex string)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
|
||||
(key.upper(),)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio FROM channels ORDER BY name"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_by_name(name: str) -> Channel | None:
|
||||
"""Get a channel by name."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE name = ?", (name,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
await db.conn.execute(
|
||||
"DELETE FROM channels WHERE key = ?",
|
||||
(key.upper(),),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@staticmethod
|
||||
async def create(
|
||||
msg_type: str,
|
||||
text: str,
|
||||
received_at: int,
|
||||
conversation_key: str,
|
||||
sender_timestamp: int | None = None,
|
||||
path_len: int | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
) -> int | None:
|
||||
"""Create a message, returning the ID or None if duplicate.
|
||||
|
||||
Uses INSERT OR IGNORE to handle the UNIQUE constraint on
|
||||
(type, conversation_key, text, sender_timestamp). This prevents
|
||||
duplicate messages when the same message arrives via multiple RF paths.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||
received_at, path_len, txt_type, signature, outgoing)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(msg_type, conversation_key, text, sender_timestamp, received_at,
|
||||
path_len, txt_type, signature, outgoing),
|
||||
)
|
||||
await db.conn.commit()
|
||||
# lastrowid is 0 if no row was inserted (duplicate)
|
||||
return cursor.lastrowid if cursor.lastrowid else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
msg_type: str | None = None,
|
||||
conversation_key: str | None = None,
|
||||
) -> list[Message]:
|
||||
query = "SELECT * FROM messages WHERE 1=1"
|
||||
params: list[Any] = []
|
||||
|
||||
if msg_type:
|
||||
query += " AND type = ?"
|
||||
params.append(msg_type)
|
||||
if conversation_key:
|
||||
# Support both exact match and prefix match for DMs
|
||||
query += " AND conversation_key LIKE ?"
|
||||
params.append(f"{conversation_key}%")
|
||||
|
||||
query += " ORDER BY received_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor = await db.conn.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
path_len=row["path_len"],
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=bool(row["acked"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def mark_acked(message_id: int) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE messages SET acked = 1 WHERE id = ?", (message_id,)
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def find_duplicate(
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int | None,
|
||||
) -> int | None:
|
||||
"""Find existing message with same content (for deduplication).
|
||||
|
||||
Returns message ID if found, None otherwise.
|
||||
Used to detect the same message arriving via multiple RF paths.
|
||||
"""
|
||||
if sender_timestamp is None:
|
||||
return None
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT id FROM messages
|
||||
WHERE conversation_key = ?
|
||||
AND text = ?
|
||||
AND sender_timestamp = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(conversation_key, text, sender_timestamp),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["id"] if row else None
|
||||
|
||||
@staticmethod
|
||||
async def get_bulk(
|
||||
conversations: list[dict],
|
||||
limit_per_conversation: int = 100,
|
||||
) -> dict[str, list["Message"]]:
|
||||
"""Fetch messages for multiple conversations in one query per conversation.
|
||||
|
||||
Args:
|
||||
conversations: List of {type: 'PRIV'|'CHAN', conversation_key: string}
|
||||
limit_per_conversation: Max messages to return per conversation
|
||||
|
||||
Returns:
|
||||
Dict mapping 'type:conversation_key' to list of messages
|
||||
"""
|
||||
result: dict[str, list[Message]] = {}
|
||||
|
||||
for conv in conversations:
|
||||
msg_type = conv.get("type")
|
||||
conv_key = conv.get("conversation_key")
|
||||
if not msg_type or not conv_key:
|
||||
continue
|
||||
|
||||
key = f"{msg_type}:{conv_key}"
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE type = ? AND conversation_key LIKE ?
|
||||
ORDER BY received_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(msg_type, f"{conv_key}%", limit_per_conversation),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
result[key] = [
|
||||
Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
path_len=row["path_len"],
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=bool(row["acked"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class RawPacketRepository:
|
||||
@staticmethod
|
||||
async def create(data: bytes, timestamp: int | None = None) -> int:
|
||||
"""Create a raw packet. Always stores (no deduplication at this level)."""
|
||||
ts = timestamp or int(time.time())
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data) VALUES (?, ?)",
|
||||
(ts, data),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.lastrowid or 0
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_count() -> int:
|
||||
"""Get count of undecrypted packets."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as count FROM raw_packets WHERE decrypted = 0"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_all_undecrypted() -> list[tuple[int, bytes]]:
|
||||
"""Get all undecrypted packets as (id, data) tuples."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data FROM raw_packets WHERE decrypted = 0 ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["id"], bytes(row["data"])) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE raw_packets SET decrypted = 1, message_id = ? WHERE id = ?",
|
||||
(message_id, packet_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted(limit: int = 100) -> list[RawPacket]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM raw_packets
|
||||
WHERE decrypted = 0
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
RawPacket(
|
||||
id=row["id"],
|
||||
timestamp=row["timestamp"],
|
||||
data=row["data"].hex(),
|
||||
decrypted=bool(row["decrypted"]),
|
||||
message_id=row["message_id"],
|
||||
decrypt_attempts=row["decrypt_attempts"],
|
||||
last_attempt=row["last_attempt"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def increment_attempts(packet_id: int) -> None:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE raw_packets
|
||||
SET decrypt_attempts = decrypt_attempts + 1, last_attempt = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(time.time()), packet_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
@@ -0,0 +1,130 @@
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Channel
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
|
||||
|
||||
class CreateChannelRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=32)
|
||||
key: str | None = Field(
|
||||
default=None,
|
||||
description="Channel key as hex string (32 chars = 16 bytes). If omitted or name starts with #, key is derived from name hash."
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
return await ChannelRepository.get_all()
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=Channel)
|
||||
async def get_channel(key: str) -> Channel:
|
||||
"""Get a specific channel by key (32-char hex string)."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return channel
|
||||
|
||||
|
||||
@router.post("", response_model=Channel)
|
||||
async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
"""Create a channel in the database.
|
||||
|
||||
Channels are NOT pushed to radio on creation. They are loaded to the radio
|
||||
automatically when sending a message (see messages.py send_channel_message).
|
||||
"""
|
||||
is_hashtag = request.name.startswith("#")
|
||||
|
||||
# Determine the channel secret
|
||||
if request.key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key")
|
||||
else:
|
||||
# Derive key from name hash (same as meshcore library does)
|
||||
key_bytes = sha256(request.name.encode("utf-8")).digest()[:16]
|
||||
|
||||
key_hex = key_bytes.hex().upper()
|
||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, request.name, is_hashtag)
|
||||
|
||||
# Store in database only - radio sync happens at send time
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=request.name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
)
|
||||
|
||||
return Channel(
|
||||
key=key_hex,
|
||||
name=request.name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_channels_from_radio(
|
||||
max_channels: int = Query(default=40, ge=1, le=40)
|
||||
) -> dict:
|
||||
"""Sync channels from the radio to the database."""
|
||||
mc = require_connected()
|
||||
|
||||
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
|
||||
count = 0
|
||||
|
||||
for idx in range(max_channels):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
|
||||
if result.type == EventType.CHANNEL_INFO:
|
||||
payload = result.payload
|
||||
name = payload.get("channel_name", "")
|
||||
secret = payload.get("channel_secret", b"")
|
||||
|
||||
# Skip empty channels
|
||||
if not name or name == "\x00" * len(name):
|
||||
continue
|
||||
|
||||
is_hashtag = name.startswith("#")
|
||||
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
|
||||
key_hex = key_bytes.hex().upper()
|
||||
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=True,
|
||||
)
|
||||
count += 1
|
||||
logger.debug("Synced channel %s: %s", key_hex, name)
|
||||
|
||||
logger.info("Synced %d channels from radio", count)
|
||||
return {"synced": count}
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
|
||||
Note: This does not clear the channel from the radio. The radio's channel
|
||||
slots are managed separately (channels are loaded temporarily when sending).
|
||||
"""
|
||||
logger.info("Deleting channel %s from database", key)
|
||||
await ChannelRepository.delete(key)
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,138 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Contact
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
async def list_contacts(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[Contact]:
|
||||
"""List contacts from the database."""
|
||||
return await ContactRepository.get_all(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=Contact)
|
||||
async def get_contact(public_key: str) -> Contact:
|
||||
"""Get a specific contact by public key or prefix."""
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
return contact
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_contacts_from_radio() -> dict:
|
||||
"""Sync contacts from the radio to the database."""
|
||||
mc = require_connected()
|
||||
|
||||
logger.info("Syncing contacts from radio")
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get contacts: {result.payload}"
|
||||
)
|
||||
|
||||
contacts = result.payload
|
||||
count = 0
|
||||
|
||||
for public_key, contact_data in contacts.items():
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(public_key, contact_data, on_radio=True)
|
||||
)
|
||||
count += 1
|
||||
|
||||
logger.info("Synced %d contacts from radio", count)
|
||||
return {"synced": count}
|
||||
|
||||
|
||||
@router.post("/{public_key}/remove-from-radio")
|
||||
async def remove_contact_from_radio(public_key: str) -> dict:
|
||||
"""Remove a contact from the radio (keeps it in database)."""
|
||||
mc = require_connected()
|
||||
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
# Get the contact from radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not radio_contact:
|
||||
# Already not on radio
|
||||
await ContactRepository.set_on_radio(contact.public_key, False)
|
||||
return {"status": "ok", "message": "Contact was not on radio"}
|
||||
|
||||
logger.info("Removing contact %s from radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.remove_contact(radio_contact)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to remove contact: {result.payload}"
|
||||
)
|
||||
|
||||
await ContactRepository.set_on_radio(contact.public_key, False)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{public_key}/add-to-radio")
|
||||
async def add_contact_to_radio(public_key: str) -> dict:
|
||||
"""Add a contact from the database to the radio."""
|
||||
mc = require_connected()
|
||||
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found in database")
|
||||
|
||||
# Check if already on radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
return {"status": "ok", "message": "Contact already on radio"}
|
||||
|
||||
logger.info("Adding contact %s to radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to add contact: {result.payload}"
|
||||
)
|
||||
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/{public_key}")
|
||||
async def delete_contact(public_key: str) -> dict:
|
||||
"""Delete a contact from the database (and radio if present)."""
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
# Remove from radio if connected and contact is on radio
|
||||
if radio_manager.is_connected and radio_manager.meshcore:
|
||||
mc = radio_manager.meshcore
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
logger.info("Removing contact %s from radio before deletion", contact.public_key[:12])
|
||||
await mc.commands.remove_contact(radio_contact)
|
||||
|
||||
# Delete from database
|
||||
await ContactRepository.delete(contact.public_key)
|
||||
logger.info("Deleted contact %s", contact.public_key[:12])
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.radio import radio_manager
|
||||
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
serial_port: str | None
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def healthcheck() -> HealthResponse:
|
||||
"""Check if the API is running and if the radio is connected."""
|
||||
return HealthResponse(
|
||||
status="ok" if radio_manager.is_connected else "degraded",
|
||||
radio_connected=radio_manager.is_connected,
|
||||
serial_port=radio_manager.port,
|
||||
)
|
||||
@@ -0,0 +1,206 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack, track_pending_repeat
|
||||
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
|
||||
from app.repository import MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[Message])
|
||||
async def list_messages(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
type: str | None = Query(default=None, description="Filter by type: PRIV or CHAN"),
|
||||
conversation_key: str | None = Query(default=None, description="Filter by conversation key (channel key or contact pubkey)"),
|
||||
) -> list[Message]:
|
||||
"""List messages from the database."""
|
||||
return await MessageRepository.get_all(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
msg_type=type,
|
||||
conversation_key=conversation_key,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk", response_model=dict[str, list[Message]])
|
||||
async def get_messages_bulk(
|
||||
conversations: list[dict],
|
||||
limit_per_conversation: int = Query(default=100, ge=1, le=1000),
|
||||
) -> dict[str, list[Message]]:
|
||||
"""Fetch messages for multiple conversations in one request.
|
||||
|
||||
Body should be a list of {type: 'PRIV'|'CHAN', conversation_key: string}.
|
||||
Returns a dict mapping 'type:conversation_key' to list of messages.
|
||||
"""
|
||||
return await MessageRepository.get_bulk(conversations, limit_per_conversation)
|
||||
|
||||
|
||||
@router.post("/direct", response_model=Message)
|
||||
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
"""Send a direct message to a contact."""
|
||||
mc = require_connected()
|
||||
|
||||
# First check our database for the contact
|
||||
from app.repository import ContactRepository
|
||||
db_contact = await ContactRepository.get_by_key_or_prefix(request.destination)
|
||||
if not db_contact:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Contact not found in database: {request.destination}"
|
||||
)
|
||||
|
||||
# Check if contact is on radio, if not add it
|
||||
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
|
||||
if not contact:
|
||||
logger.info("Adding contact %s to radio before sending", db_contact.public_key[:12])
|
||||
contact_data = db_contact.to_radio_dict()
|
||||
add_result = await mc.commands.add_contact(contact_data)
|
||||
if add_result.type == EventType.ERROR:
|
||||
logger.warning("Failed to add contact to radio: %s", add_result.payload)
|
||||
# Continue anyway - might still work
|
||||
|
||||
# Get the contact from radio again
|
||||
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
|
||||
if not contact:
|
||||
# Use the contact_data we built as fallback
|
||||
contact = contact_data
|
||||
|
||||
logger.info("Sending direct message to %s", db_contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.send_msg(
|
||||
dst=contact,
|
||||
msg=request.text,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to send message: {result.payload}"
|
||||
)
|
||||
|
||||
# Store outgoing message
|
||||
now = int(time.time())
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=request.text,
|
||||
conversation_key=db_contact.public_key,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
|
||||
# Update last_contacted for the contact
|
||||
await ContactRepository.update_last_contacted(db_contact.public_key, now)
|
||||
|
||||
# Track the expected ACK for this message
|
||||
expected_ack = result.payload.get("expected_ack")
|
||||
suggested_timeout = result.payload.get("suggested_timeout", 10000) # default 10s
|
||||
if expected_ack:
|
||||
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
track_pending_ack(ack_code, message_id, suggested_timeout)
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
|
||||
|
||||
return Message(
|
||||
id=message_id,
|
||||
type="PRIV",
|
||||
conversation_key=db_contact.public_key,
|
||||
text=request.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=False,
|
||||
)
|
||||
|
||||
|
||||
# Temporary radio slot used for sending channel messages
|
||||
TEMP_RADIO_SLOT = 0
|
||||
|
||||
|
||||
@router.post("/channel", response_model=Message)
|
||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
"""Send a message to a channel."""
|
||||
mc = require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
from app.repository import ChannelRepository
|
||||
from app.decoder import calculate_channel_hash
|
||||
db_channel = await ChannelRepository.get_by_key(request.channel_key)
|
||||
if not db_channel:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Channel {request.channel_key} not found in database"
|
||||
)
|
||||
|
||||
# Convert channel key hex to bytes
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.channel_key)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid channel key format: {request.channel_key}"
|
||||
)
|
||||
|
||||
expected_hash = calculate_channel_hash(key_bytes)
|
||||
logger.info(
|
||||
"Sending to channel %s (%s) via radio slot %d, key hash: %s",
|
||||
request.channel_key, db_channel.name, TEMP_RADIO_SLOT, expected_hash
|
||||
)
|
||||
|
||||
# Load the channel to a temporary radio slot before sending
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=TEMP_RADIO_SLOT,
|
||||
channel_name=db_channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before sending: %s",
|
||||
TEMP_RADIO_SLOT, set_result.payload
|
||||
)
|
||||
# Continue anyway - the channel might already be correctly configured
|
||||
|
||||
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
|
||||
|
||||
result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=request.text,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to send message: {result.payload}"
|
||||
)
|
||||
|
||||
# Store outgoing message
|
||||
now = int(time.time())
|
||||
channel_key_upper = request.channel_key.upper()
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=request.text,
|
||||
conversation_key=channel_key_upper,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
|
||||
# Track for repeat detection (flood messages get confirmed by hearing repeats)
|
||||
track_pending_repeat(channel_key_upper, request.text, now, message_id)
|
||||
|
||||
return Message(
|
||||
id=message_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=request.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=False,
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.decoder import try_decrypt_packet_with_channel_key
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
from app.repository import RawPacketRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/packets", tags=["packets"])
|
||||
|
||||
|
||||
class DecryptRequest(BaseModel):
|
||||
key_type: str = Field(description="Type of key: 'channel' or 'contact'")
|
||||
channel_key: str | None = Field(default=None, description="Channel key as hex (16 bytes = 32 chars)")
|
||||
channel_name: str | None = Field(default=None, description="Channel name (for hashtag channels, key derived from name)")
|
||||
|
||||
|
||||
class DecryptResult(BaseModel):
|
||||
started: bool
|
||||
total_packets: int
|
||||
message: str
|
||||
|
||||
|
||||
class DecryptProgress(BaseModel):
|
||||
total: int
|
||||
processed: int
|
||||
decrypted: int
|
||||
in_progress: bool
|
||||
|
||||
|
||||
# Global state for tracking decryption progress
|
||||
_decrypt_progress: DecryptProgress | None = None
|
||||
|
||||
|
||||
async def _run_historical_decryption(channel_key_bytes: bytes, channel_key_hex: str) -> None:
|
||||
"""Background task to decrypt historical packets with a channel key."""
|
||||
global _decrypt_progress
|
||||
|
||||
packets = await RawPacketRepository.get_all_undecrypted()
|
||||
total = len(packets)
|
||||
processed = 0
|
||||
decrypted_count = 0
|
||||
|
||||
_decrypt_progress = DecryptProgress(
|
||||
total=total, processed=0, decrypted=0, in_progress=True
|
||||
)
|
||||
|
||||
logger.info("Starting historical decryption of %d packets", total)
|
||||
|
||||
for packet_id, packet_data in packets:
|
||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||
|
||||
if result is not None:
|
||||
# Successfully decrypted - use shared logic to store message
|
||||
logger.debug(
|
||||
"Decrypted packet %d: sender=%s, message=%s",
|
||||
packet_id,
|
||||
result.sender,
|
||||
result.message[:50] if result.message else "",
|
||||
)
|
||||
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key_hex,
|
||||
sender=result.sender,
|
||||
message_text=result.message,
|
||||
timestamp=result.timestamp,
|
||||
)
|
||||
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
|
||||
processed += 1
|
||||
_decrypt_progress = DecryptProgress(
|
||||
total=total, processed=processed, decrypted=decrypted_count, in_progress=True
|
||||
)
|
||||
|
||||
_decrypt_progress = DecryptProgress(
|
||||
total=total, processed=processed, decrypted=decrypted_count, in_progress=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Historical decryption complete: %d/%d packets decrypted", decrypted_count, total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/undecrypted/count")
|
||||
async def get_undecrypted_count() -> dict:
|
||||
"""Get the count of undecrypted packets."""
|
||||
count = await RawPacketRepository.get_undecrypted_count()
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.post("/decrypt/historical", response_model=DecryptResult)
|
||||
async def decrypt_historical_packets(
|
||||
request: DecryptRequest, background_tasks: BackgroundTasks
|
||||
) -> DecryptResult:
|
||||
"""
|
||||
Attempt to decrypt historical packets with the provided key.
|
||||
Runs in the background to avoid blocking.
|
||||
"""
|
||||
global _decrypt_progress
|
||||
|
||||
# Check if decryption is already in progress
|
||||
if _decrypt_progress and _decrypt_progress.in_progress:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=_decrypt_progress.total,
|
||||
message=f"Decryption already in progress: {_decrypt_progress.processed}/{_decrypt_progress.total}",
|
||||
)
|
||||
|
||||
# Determine the channel key
|
||||
channel_key_bytes: bytes | None = None
|
||||
channel_key_hex: str | None = None
|
||||
|
||||
if request.key_type == "channel":
|
||||
if request.channel_key:
|
||||
# Direct key provided
|
||||
try:
|
||||
channel_key_bytes = bytes.fromhex(request.channel_key)
|
||||
if len(channel_key_bytes) != 16:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Channel key must be 16 bytes (32 hex chars)",
|
||||
)
|
||||
channel_key_hex = request.channel_key.upper()
|
||||
except ValueError:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Invalid hex string for channel key",
|
||||
)
|
||||
elif request.channel_name:
|
||||
# Derive key from channel name (hashtag channel)
|
||||
channel_key_bytes = sha256(request.channel_name.encode("utf-8")).digest()[:16]
|
||||
channel_key_hex = channel_key_bytes.hex().upper()
|
||||
else:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Must provide channel_key or channel_name",
|
||||
)
|
||||
else:
|
||||
# Contact decryption not yet supported (requires Ed25519 shared secret)
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
total_packets=0,
|
||||
message="Contact key decryption not yet supported",
|
||||
)
|
||||
|
||||
# Get count of undecrypted packets
|
||||
count = await RawPacketRepository.get_undecrypted_count()
|
||||
if count == 0:
|
||||
return DecryptResult(
|
||||
started=False, total_packets=0, message="No undecrypted packets to process"
|
||||
)
|
||||
|
||||
# Start background decryption
|
||||
background_tasks.add_task(_run_historical_decryption, channel_key_bytes, channel_key_hex)
|
||||
|
||||
return DecryptResult(
|
||||
started=True,
|
||||
total_packets=count,
|
||||
message=f"Started decryption of {count} packets in background",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/decrypt/progress", response_model=DecryptProgress | None)
|
||||
async def get_decrypt_progress() -> DecryptProgress | None:
|
||||
"""Get the current progress of historical decryption."""
|
||||
return _decrypt_progress
|
||||
@@ -0,0 +1,188 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||
|
||||
|
||||
class RadioSettings(BaseModel):
|
||||
freq: float = Field(description="Frequency in MHz")
|
||||
bw: float = Field(description="Bandwidth in kHz")
|
||||
sf: int = Field(description="Spreading factor (7-12)")
|
||||
cr: int = Field(description="Coding rate (1-4)")
|
||||
|
||||
|
||||
class RadioConfigResponse(BaseModel):
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
name: str
|
||||
lat: float
|
||||
lon: float
|
||||
tx_power: int = Field(description="Transmit power in dBm")
|
||||
max_tx_power: int = Field(description="Maximum transmit power in dBm")
|
||||
radio: RadioSettings
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
|
||||
radio: RadioSettings | None = None
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
private_key: str = Field(description="Private key as hex string")
|
||||
|
||||
|
||||
@router.get("/config", response_model=RadioConfigResponse)
|
||||
async def get_radio_config() -> RadioConfigResponse:
|
||||
"""Get the current radio configuration."""
|
||||
mc = require_connected()
|
||||
|
||||
info = mc.self_info
|
||||
if not info:
|
||||
raise HTTPException(status_code=503, detail="Radio info not available")
|
||||
|
||||
return RadioConfigResponse(
|
||||
public_key=info.get("public_key", ""),
|
||||
name=info.get("name", ""),
|
||||
lat=info.get("adv_lat", 0.0),
|
||||
lon=info.get("adv_lon", 0.0),
|
||||
tx_power=info.get("tx_power", 0),
|
||||
max_tx_power=info.get("max_tx_power", 0),
|
||||
radio=RadioSettings(
|
||||
freq=info.get("radio_freq", 0.0),
|
||||
bw=info.get("radio_bw", 0.0),
|
||||
sf=info.get("radio_sf", 0),
|
||||
cr=info.get("radio_cr", 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=RadioConfigResponse)
|
||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
"""Update radio configuration. Only provided fields will be updated."""
|
||||
mc = require_connected()
|
||||
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
if update.lat is not None or update.lon is not None:
|
||||
current_info = mc.self_info
|
||||
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
|
||||
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
|
||||
logger.info("Setting radio coordinates to %f, %f", lat, lon)
|
||||
await mc.commands.set_coords(lat=lat, lon=lon)
|
||||
|
||||
if update.tx_power is not None:
|
||||
logger.info("Setting TX power to %d dBm", update.tx_power)
|
||||
await mc.commands.set_tx_power(val=update.tx_power)
|
||||
|
||||
if update.radio is not None:
|
||||
logger.info(
|
||||
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
|
||||
update.radio.freq,
|
||||
update.radio.bw,
|
||||
update.radio.sf,
|
||||
update.radio.cr,
|
||||
)
|
||||
await mc.commands.set_radio(
|
||||
freq=update.radio.freq,
|
||||
bw=update.radio.bw,
|
||||
sf=update.radio.sf,
|
||||
cr=update.radio.cr,
|
||||
)
|
||||
|
||||
# Sync time with system clock
|
||||
now = int(time.time())
|
||||
logger.debug("Syncing radio time to %d", now)
|
||||
await mc.commands.set_time(now)
|
||||
|
||||
return await get_radio_config()
|
||||
|
||||
|
||||
@router.put("/private-key")
|
||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
"""Set the radio's private key. This is write-only."""
|
||||
mc = require_connected()
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(update.private_key)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for private key")
|
||||
|
||||
logger.info("Importing private key")
|
||||
result = await mc.commands.import_private_key(key_bytes)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to import private key: {result.payload}")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/advertise")
|
||||
async def send_advertisement(flood: bool = True) -> dict:
|
||||
"""Send a radio advertisement to announce presence on the mesh."""
|
||||
mc = require_connected()
|
||||
|
||||
logger.info("Sending advertisement (flood=%s)", flood)
|
||||
result = await mc.commands.send_advert(flood=flood)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send advertisement: {result.payload}")
|
||||
|
||||
return {"status": "ok", "flood": flood}
|
||||
|
||||
|
||||
@router.post("/reboot")
|
||||
async def reboot_radio() -> dict:
|
||||
"""Reboot the radio. Connection will temporarily drop and auto-reconnect."""
|
||||
mc = require_connected()
|
||||
|
||||
logger.info("Rebooting radio")
|
||||
await mc.commands.reboot()
|
||||
|
||||
return {"status": "ok", "message": "Reboot command sent. Radio will reconnect automatically."}
|
||||
|
||||
|
||||
@router.post("/reconnect")
|
||||
async def reconnect_radio() -> dict:
|
||||
"""Attempt to reconnect to the radio.
|
||||
|
||||
This will try to re-establish connection to the radio, with auto-detection
|
||||
if no specific port is configured. Useful when the radio has been disconnected
|
||||
or power-cycled.
|
||||
"""
|
||||
from app.radio import radio_manager
|
||||
|
||||
if radio_manager.is_connected:
|
||||
return {"status": "ok", "message": "Already connected", "connected": True}
|
||||
|
||||
if radio_manager.is_reconnecting:
|
||||
return {"status": "pending", "message": "Reconnection already in progress", "connected": False}
|
||||
|
||||
logger.info("Manual reconnect requested")
|
||||
success = await radio_manager.reconnect()
|
||||
|
||||
if success:
|
||||
# Re-register event handlers after successful reconnect
|
||||
from app.event_handlers import register_event_handlers
|
||||
if radio_manager.meshcore:
|
||||
register_event_handlers(radio_manager.meshcore)
|
||||
# Restart auto message fetching
|
||||
await radio_manager.meshcore.start_auto_message_fetching()
|
||||
logger.info("Event handlers re-registered and auto message fetching started")
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
class AppSettingsResponse(BaseModel):
|
||||
max_radio_contacts: int = Field(description="Maximum non-repeater contacts to keep on radio for DM ACKs")
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum non-repeater contacts to keep on radio (1-1000)"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettingsResponse)
|
||||
async def get_settings() -> AppSettingsResponse:
|
||||
"""Get current application settings."""
|
||||
return AppSettingsResponse(
|
||||
max_radio_contacts=settings.max_radio_contacts,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("", response_model=AppSettingsResponse)
|
||||
async def update_settings(update: AppSettingsUpdate) -> AppSettingsResponse:
|
||||
"""Update application settings.
|
||||
|
||||
Note: Changes are applied immediately but not persisted across restarts.
|
||||
Set MESHCORE_MAX_RADIO_CONTACTS environment variable for persistent changes.
|
||||
"""
|
||||
if update.max_radio_contacts is not None:
|
||||
logger.info("Updating max_radio_contacts from %d to %d",
|
||||
settings.max_radio_contacts, update.max_radio_contacts)
|
||||
# Pydantic settings are mutable, we can update them directly
|
||||
object.__setattr__(settings, 'max_radio_contacts', update.max_radio_contacts)
|
||||
|
||||
return AppSettingsResponse(
|
||||
max_radio_contacts=settings.max_radio_contacts,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""WebSocket router for real-time updates."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ChannelRepository, ContactRepository
|
||||
from app.websocket import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
"""WebSocket endpoint for real-time updates."""
|
||||
await ws_manager.connect(websocket)
|
||||
|
||||
# Send initial state
|
||||
try:
|
||||
# Health status
|
||||
health_data = {
|
||||
"radio_connected": radio_manager.is_connected,
|
||||
"serial_port": radio_manager.port,
|
||||
}
|
||||
await ws_manager.send_personal(websocket, "health", health_data)
|
||||
|
||||
# Contacts
|
||||
contacts = await ContactRepository.get_all(limit=500)
|
||||
await ws_manager.send_personal(
|
||||
websocket,
|
||||
"contacts",
|
||||
[c.model_dump() for c in contacts],
|
||||
)
|
||||
|
||||
# Channels
|
||||
channels = await ChannelRepository.get_all()
|
||||
await ws_manager.send_personal(
|
||||
websocket,
|
||||
"channels",
|
||||
[c.model_dump() for c in channels],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending initial state: %s", e)
|
||||
|
||||
# Keep connection alive and handle incoming messages
|
||||
try:
|
||||
while True:
|
||||
# We don't expect messages from client, but need to keep connection open
|
||||
# and handle pings/pongs
|
||||
data = await websocket.receive_text()
|
||||
# Client can send "ping" to keep alive
|
||||
if data == "ping":
|
||||
await websocket.send_text('{"type":"pong"}')
|
||||
except WebSocketDisconnect:
|
||||
await ws_manager.disconnect(websocket)
|
||||
except Exception as e:
|
||||
logger.debug("WebSocket error: %s", e)
|
||||
await ws_manager.disconnect(websocket)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""WebSocket manager for real-time updates."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Manages WebSocket connections and broadcasts events."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: list[WebSocket] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
async with self._lock:
|
||||
self.active_connections.append(websocket)
|
||||
logger.info("WebSocket client connected (%d total)", len(self.active_connections))
|
||||
|
||||
async def disconnect(self, websocket: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
logger.info("WebSocket client disconnected (%d remaining)", len(self.active_connections))
|
||||
|
||||
async def broadcast(self, event_type: str, data: Any) -> None:
|
||||
"""Broadcast an event to all connected clients."""
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
|
||||
async with self._lock:
|
||||
disconnected = []
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_text(message)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
disconnected.append(connection)
|
||||
|
||||
# Clean up disconnected clients
|
||||
for conn in disconnected:
|
||||
if conn in self.active_connections:
|
||||
self.active_connections.remove(conn)
|
||||
|
||||
async def send_personal(self, websocket: WebSocket, event_type: str, data: Any) -> None:
|
||||
"""Send an event to a specific client."""
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
try:
|
||||
await websocket.send_text(message)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
|
||||
|
||||
# Global instance
|
||||
ws_manager = WebSocketManager()
|
||||
|
||||
|
||||
def broadcast_event(event_type: str, data: dict) -> None:
|
||||
"""Schedule a broadcast without blocking.
|
||||
|
||||
Convenience function that creates an asyncio task to broadcast
|
||||
an event to all connected WebSocket clients.
|
||||
"""
|
||||
asyncio.create_task(ws_manager.broadcast(event_type, data))
|
||||
|
||||
|
||||
def broadcast_error(message: str, details: str | None = None) -> None:
|
||||
"""Broadcast an error notification to all connected clients.
|
||||
|
||||
This appears as a toast notification in the frontend.
|
||||
"""
|
||||
data = {"message": message}
|
||||
if details:
|
||||
data["details"] = details
|
||||
asyncio.create_task(ws_manager.broadcast("error", data))
|
||||
|
||||
|
||||
def broadcast_health(radio_connected: bool, serial_port: str | None = None) -> None:
|
||||
"""Broadcast health status change to all connected clients."""
|
||||
asyncio.create_task(ws_manager.broadcast("health", {
|
||||
"status": "ok" if radio_connected else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"serial_port": serial_port,
|
||||
}))
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,497 @@
|
||||
# Frontend CLAUDE.md
|
||||
|
||||
This document provides context for AI assistants and developers working on the React frontend.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **React 18** - UI framework with hooks
|
||||
- **TypeScript** - Type safety
|
||||
- **Vite** - Build tool with HMR
|
||||
- **Vitest** - Testing framework
|
||||
- **Sonner** - Toast notifications
|
||||
- **shadcn/ui components** - Sheet, Tabs, Button (in `components/ui/`)
|
||||
- **meshcore-cracker** - WebGPU-accelerated channel key bruteforcing
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── main.tsx # Entry point, renders App
|
||||
│ ├── App.tsx # Main component, all state management
|
||||
│ ├── api.ts # REST API client
|
||||
│ ├── types.ts # TypeScript interfaces
|
||||
│ ├── useWebSocket.ts # WebSocket hook with auto-reconnect
|
||||
│ ├── styles.css # Dark theme CSS
|
||||
│ ├── utils/
|
||||
│ │ ├── messageParser.ts # Text parsing utilities
|
||||
│ │ ├── conversationState.ts # localStorage for unread tracking
|
||||
│ │ ├── pubkey.ts # Public key utilities (prefix matching, display names)
|
||||
│ │ └── contactAvatar.ts # Avatar generation (colors, initials/emoji)
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui components
|
||||
│ │ │ ├── sonner.tsx # Toast notifications (Sonner wrapper)
|
||||
│ │ │ ├── sheet.tsx # Slide-out panel
|
||||
│ │ │ ├── tabs.tsx # Tab navigation
|
||||
│ │ │ └── button.tsx # Button component
|
||||
│ │ ├── StatusBar.tsx # Radio status, reconnect button, config button
|
||||
│ │ ├── Sidebar.tsx # Contacts/channels list, search, unread badges
|
||||
│ │ ├── MessageList.tsx # Message display, avatars, clickable senders
|
||||
│ │ ├── MessageInput.tsx # Text input with imperative handle
|
||||
│ │ ├── ContactAvatar.tsx # Contact profile image component
|
||||
│ │ ├── RawPacketList.tsx # Raw packet feed display
|
||||
│ │ ├── CrackerPanel.tsx # WebGPU channel key cracker
|
||||
│ │ ├── NewMessageModal.tsx
|
||||
│ │ └── ConfigModal.tsx # Radio config + app settings
|
||||
│ └── test/
|
||||
│ ├── setup.ts # Test setup (jsdom, matchers)
|
||||
│ ├── messageParser.test.ts
|
||||
│ ├── unreadCounts.test.ts
|
||||
│ ├── contactAvatar.test.ts
|
||||
│ ├── messageDeduplication.test.ts
|
||||
│ └── websocket.test.ts
|
||||
├── index.html
|
||||
├── vite.config.ts # API proxy config
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
All application state lives in `App.tsx` using React hooks. No external state library.
|
||||
|
||||
### Core State
|
||||
|
||||
```typescript
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
|
||||
```
|
||||
|
||||
### State Flow
|
||||
|
||||
1. **WebSocket** pushes real-time updates (health, contacts, channels, messages)
|
||||
2. **REST API** fetches initial data and handles user actions
|
||||
3. **Components** receive state as props, call handlers to trigger changes
|
||||
|
||||
## WebSocket (`useWebSocket.ts`)
|
||||
|
||||
The `useWebSocket` hook manages real-time connection:
|
||||
|
||||
```typescript
|
||||
const wsHandlers = useMemo(() => ({
|
||||
onHealth: (data: HealthStatus) => setHealth(data),
|
||||
onMessage: (msg: Message) => { /* add to list, track unread */ },
|
||||
onMessageAcked: (messageId: number) => { /* update acked status */ },
|
||||
// ...
|
||||
}), []);
|
||||
|
||||
useWebSocket(wsHandlers);
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Auto-reconnect**: Reconnects after 3 seconds on disconnect
|
||||
- **Heartbeat**: Sends ping every 30 seconds
|
||||
- **Event types**: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
|
||||
- **Error handling**: `onError` handler displays toast notifications for backend errors
|
||||
|
||||
### URL Detection
|
||||
|
||||
```typescript
|
||||
const isDev = window.location.port === '5173';
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8000/api/ws'
|
||||
: `${protocol}//${window.location.host}/api/ws`;
|
||||
```
|
||||
|
||||
## API Client (`api.ts`)
|
||||
|
||||
Typed REST client with consistent error handling:
|
||||
|
||||
```typescript
|
||||
import { api } from './api';
|
||||
|
||||
// Health
|
||||
await api.getHealth();
|
||||
|
||||
// Radio
|
||||
await api.getRadioConfig();
|
||||
await api.updateRadioConfig({ name: 'MyRadio' });
|
||||
await api.sendAdvertisement(true);
|
||||
|
||||
// Contacts/Channels
|
||||
await api.getContacts();
|
||||
await api.getChannels();
|
||||
await api.createChannel('#test');
|
||||
|
||||
// Messages
|
||||
await api.getMessages({ type: 'CHAN', conversation_key: channelKey, limit: 200 });
|
||||
await api.sendChannelMessage(channelKey, 'Hello');
|
||||
await api.sendDirectMessage(publicKey, 'Hello');
|
||||
|
||||
// Historical decryption
|
||||
await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: '#test' });
|
||||
|
||||
// Radio reconnection
|
||||
await api.reconnectRadio(); // Returns { status, message, connected }
|
||||
```
|
||||
|
||||
### API Proxy (Development)
|
||||
|
||||
Vite proxies `/api/*` to backend (backend routes are already prefixed with `/api`):
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Type Definitions (`types.ts`)
|
||||
|
||||
### Key Type Aliases
|
||||
|
||||
```typescript
|
||||
type PublicKey = string; // 64-char hex identifying a contact/node
|
||||
type PubkeyPrefix = string; // 12-char hex prefix (used in message routing)
|
||||
type ChannelKey = string; // 32-char hex identifying a channel
|
||||
```
|
||||
|
||||
### Key Interfaces
|
||||
|
||||
```typescript
|
||||
interface Contact {
|
||||
public_key: PublicKey;
|
||||
name: string | null;
|
||||
type: number; // 0=unknown, 1=client, 2=repeater, 3=room
|
||||
on_radio: boolean;
|
||||
// ...
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
key: ChannelKey;
|
||||
name: string;
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
type: 'PRIV' | 'CHAN';
|
||||
conversation_key: string; // PublicKey for PRIV, ChannelKey for CHAN
|
||||
text: string;
|
||||
outgoing: boolean;
|
||||
acked: boolean;
|
||||
// ...
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
type: 'contact' | 'channel' | 'raw';
|
||||
id: string; // PublicKey for contacts, ChannelKey for channels
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### MessageInput with Imperative Handle
|
||||
|
||||
Exposes `appendText` method for click-to-mention:
|
||||
|
||||
```typescript
|
||||
export interface MessageInputHandle {
|
||||
appendText: (text: string) => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
|
||||
function MessageInput({ onSend, disabled }, ref) {
|
||||
useImperativeHandle(ref, () => ({
|
||||
appendText: (text: string) => {
|
||||
setText((prev) => prev + text);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
// ...
|
||||
}
|
||||
);
|
||||
|
||||
// Usage in App.tsx
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
```
|
||||
|
||||
### Unread Count Tracking
|
||||
|
||||
Uses refs to avoid stale closures in memoized handlers:
|
||||
|
||||
```typescript
|
||||
const activeConversationRef = useRef<Conversation | null>(null);
|
||||
|
||||
// Keep ref in sync
|
||||
useEffect(() => {
|
||||
activeConversationRef.current = activeConversation;
|
||||
}, [activeConversation]);
|
||||
|
||||
// In WebSocket handler (can safely access current value)
|
||||
const activeConv = activeConversationRef.current;
|
||||
```
|
||||
|
||||
### State Tracking Keys
|
||||
|
||||
State tracking keys (for unread counts and message times) are generated by `getStateKey()`:
|
||||
|
||||
```typescript
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
|
||||
// Channels: "channel-{channelKey}"
|
||||
getStateKey('channel', channelKey) // e.g., "channel-8B3387E9C5CDEA6AC9E5EDBAA115CD72"
|
||||
|
||||
// Contacts: "contact-{12-char-prefix}"
|
||||
getStateKey('contact', publicKey) // e.g., "contact-abc123def456"
|
||||
```
|
||||
|
||||
**Note:** `getStateKey()` is NOT the same as `Message.conversation_key`. The state key is prefixed
|
||||
for localStorage tracking, while `conversation_key` is the raw database field.
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### Message Parser (`utils/messageParser.ts`)
|
||||
|
||||
```typescript
|
||||
// Parse "sender: message" format from channel messages
|
||||
parseSenderFromText(text: string): { sender: string | null; content: string }
|
||||
|
||||
// Format Unix timestamp to time string
|
||||
formatTime(timestamp: number): string
|
||||
```
|
||||
|
||||
### Public Key Utilities (`utils/pubkey.ts`)
|
||||
|
||||
Consistent handling of 64-char full keys and 12-char prefixes:
|
||||
|
||||
```typescript
|
||||
import { getPubkeyPrefix, pubkeysMatch, getContactDisplayName } from './utils/pubkey';
|
||||
|
||||
// Extract 12-char prefix (works with full keys or existing prefixes)
|
||||
getPubkeyPrefix(key) // "abc123def456..."
|
||||
|
||||
// Compare keys by prefix (handles mixed full/prefix comparisons)
|
||||
pubkeysMatch(key1, key2) // true if prefixes match
|
||||
|
||||
// Get display name with fallback to prefix
|
||||
getContactDisplayName(name, publicKey) // name or first 12 chars of key
|
||||
```
|
||||
|
||||
### Conversation State (`utils/conversationState.ts`)
|
||||
|
||||
```typescript
|
||||
import { getStateKey, setLastMessageTime, setLastReadTime } from './utils/conversationState';
|
||||
|
||||
// Generate state tracking key (NOT the same as Message.conversation_key)
|
||||
getStateKey('channel', channelKey)
|
||||
getStateKey('contact', publicKey)
|
||||
|
||||
// Track message times for unread detection
|
||||
setLastMessageTime(stateKey, timestamp)
|
||||
setLastReadTime(stateKey, timestamp)
|
||||
```
|
||||
|
||||
### Contact Avatar (`utils/contactAvatar.ts`)
|
||||
|
||||
Generates consistent profile "images" for contacts using hash-based colors:
|
||||
|
||||
```typescript
|
||||
import { getContactAvatar, CONTACT_TYPE_REPEATER } from './utils/contactAvatar';
|
||||
|
||||
// Get avatar info for a contact
|
||||
const avatar = getContactAvatar(name, publicKey, contactType);
|
||||
// Returns: { text: 'JD', background: 'hsl(180, 60%, 40%)', textColor: '#ffffff' }
|
||||
|
||||
// Repeaters (type=2) always show 🛜 with gray background
|
||||
const repeaterAvatar = getContactAvatar('Some Repeater', key, CONTACT_TYPE_REPEATER);
|
||||
// Returns: { text: '🛜', background: '#444444', textColor: '#ffffff' }
|
||||
```
|
||||
|
||||
Avatar text priority:
|
||||
1. First emoji in name
|
||||
2. Initials (first letter + first letter after space)
|
||||
3. Single first letter
|
||||
4. First 2 chars of public key (fallback)
|
||||
|
||||
## CSS Patterns
|
||||
|
||||
The app uses a minimal dark theme in `styles.css`.
|
||||
|
||||
### Key Classes
|
||||
|
||||
```css
|
||||
.app /* Root container */
|
||||
.status-bar /* Top bar with radio info */
|
||||
.sidebar /* Left panel with contacts/channels */
|
||||
.sidebar-item /* Individual contact/channel row */
|
||||
.sidebar-item.unread /* Bold with badge */
|
||||
.message-area /* Main content area */
|
||||
.message-list /* Scrollable message container */
|
||||
.message /* Individual message */
|
||||
.message.outgoing /* Right-aligned, different color */
|
||||
.message .sender /* Clickable sender name */
|
||||
```
|
||||
|
||||
### Unread Badge
|
||||
|
||||
```css
|
||||
.sidebar-item.unread .name {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.sidebar-item .unread-badge {
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:run # Single run
|
||||
npm run test # Watch mode
|
||||
```
|
||||
|
||||
### Test Files
|
||||
|
||||
- `messageParser.test.ts` - Sender extraction, time formatting, conversation keys
|
||||
- `unreadCounts.test.ts` - Unread tracking logic
|
||||
- `contactAvatar.test.ts` - Avatar text extraction, color generation, repeater handling
|
||||
- `messageDeduplication.test.ts` - Message deduplication logic
|
||||
- `websocket.test.ts` - WebSocket message routing
|
||||
|
||||
### Test Setup
|
||||
|
||||
Tests use jsdom environment with `@testing-library/react`:
|
||||
|
||||
```typescript
|
||||
// src/test/setup.ts
|
||||
import '@testing-library/jest-dom';
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Component
|
||||
|
||||
1. Create component in `src/components/`
|
||||
2. Add TypeScript props interface
|
||||
3. Import and use in `App.tsx` or parent component
|
||||
4. Add styles to `styles.css`
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. Add method to `api.ts`
|
||||
2. Add/update types in `types.ts`
|
||||
3. Call from `App.tsx` or component
|
||||
|
||||
### Adding New WebSocket Event
|
||||
|
||||
1. Add handler option to `UseWebSocketOptions` interface in `useWebSocket.ts`
|
||||
2. Add case to `onmessage` switch
|
||||
3. Provide handler in `wsHandlers` object in `App.tsx`
|
||||
|
||||
### Adding State
|
||||
|
||||
1. Add `useState` in `App.tsx`
|
||||
2. Pass down as props to components
|
||||
3. If needed in WebSocket handler, also use a ref to avoid stale closures
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
# Start dev server (hot reload)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
|
||||
# Run tests
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
The dev server runs on port 5173 and proxies API requests to `localhost:8000`.
|
||||
|
||||
### Production Build
|
||||
|
||||
In production, the FastAPI backend serves the compiled frontend from `frontend/dist`:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# Then run backend: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## CrackerPanel
|
||||
|
||||
The `CrackerPanel` component provides WebGPU-accelerated brute-forcing of channel keys for undecrypted GROUP_TEXT packets.
|
||||
|
||||
### Features
|
||||
|
||||
- **Dictionary attack first**: Uses `words_alpha.txt` wordlist
|
||||
- **GPU bruteforce**: Falls back to character-by-character search
|
||||
- **Queue management**: Automatically processes new packets as they arrive
|
||||
- **Auto-channel creation**: Cracked channels are automatically added to the channel list
|
||||
- **Configurable max length**: Adjustable while running (default: 6)
|
||||
- **Retry failed**: Option to retry failed packets at increasing lengths
|
||||
|
||||
### Key Implementation Patterns
|
||||
|
||||
Uses refs to avoid stale closures in async callbacks:
|
||||
|
||||
```typescript
|
||||
const isRunningRef = useRef(false);
|
||||
const isProcessingRef = useRef(false); // Prevents concurrent GPU operations
|
||||
const queueRef = useRef<Map<number, QueueItem>>(new Map());
|
||||
const retryFailedRef = useRef(false);
|
||||
const maxLengthRef = useRef(6);
|
||||
```
|
||||
|
||||
Progress reporting shows rate in Mkeys/s or Gkeys/s depending on speed.
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
The app uses Sonner for toast notifications via a custom wrapper at `components/ui/sonner.tsx`:
|
||||
|
||||
```typescript
|
||||
import { toast } from './components/ui/sonner';
|
||||
|
||||
// Success toast
|
||||
toast.success('Operation completed', { description: 'Details here' });
|
||||
|
||||
// Error toast (muted red styling for readability)
|
||||
toast.error('Operation failed', { description: 'Error details' });
|
||||
```
|
||||
|
||||
Toasts are automatically shown for:
|
||||
- Radio connection/disconnection status changes
|
||||
- Backend errors received via WebSocket `error` events
|
||||
- Manual reconnection success/failure
|
||||
|
||||
The `<Toaster />` component is rendered in `App.tsx` with `position="top-right"`.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"meshcore-cracker": "file:../references/standalone_cracker",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,781 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { api } from './api';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { MessageList } from './components/MessageList';
|
||||
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
|
||||
import { NewMessageModal } from './components/NewMessageModal';
|
||||
import { ConfigModal } from './components/ConfigModal';
|
||||
import { RawPacketList } from './components/RawPacketList';
|
||||
import { CrackerPanel } from './components/CrackerPanel';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
|
||||
import { Toaster, toast } from './components/ui/sonner';
|
||||
import {
|
||||
getLastMessageTimes,
|
||||
getLastReadTimes,
|
||||
setLastMessageTime,
|
||||
setLastReadTime,
|
||||
getStateKey,
|
||||
type ConversationTimes,
|
||||
} from './utils/conversationState';
|
||||
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
Channel,
|
||||
Conversation,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
} from './types';
|
||||
|
||||
const MAX_RAW_PACKETS = 500; // Limit stored packets to prevent memory issues
|
||||
|
||||
// Generate a key for deduplicating messages by content
|
||||
function getMessageContentKey(msg: Message): string {
|
||||
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const activeConversationRef = useRef<Conversation | null>(null);
|
||||
const seenMessageContent = useRef<Set<string>>(new Set());
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [hasOlderMessages, setHasOlderMessages] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [undecryptedCount, setUndecryptedCount] = useState(0);
|
||||
// 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)
|
||||
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
|
||||
|
||||
// Track previous health status to detect changes
|
||||
const prevHealthRef = useRef<HealthStatus | null>(null);
|
||||
|
||||
// WebSocket handlers - memoized to prevent reconnection loops
|
||||
const wsHandlers = useMemo(() => ({
|
||||
onHealth: (data: HealthStatus) => {
|
||||
const prev = prevHealthRef.current;
|
||||
prevHealthRef.current = data;
|
||||
setHealth(data);
|
||||
|
||||
// Show toast on connection status change
|
||||
if (prev !== null && prev.radio_connected !== data.radio_connected) {
|
||||
if (data.radio_connected) {
|
||||
toast.success('Radio connected', {
|
||||
description: data.serial_port ? `Connected to ${data.serial_port}` : undefined,
|
||||
});
|
||||
} else {
|
||||
toast.error('Radio disconnected', {
|
||||
description: 'Check radio connection and power',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string; details?: string }) => {
|
||||
toast.error(error.message, {
|
||||
description: error.details,
|
||||
});
|
||||
},
|
||||
onContacts: (data: Contact[]) => setContacts(data),
|
||||
onChannels: (data: Channel[]) => setChannels(data),
|
||||
onMessage: (msg: Message) => {
|
||||
const activeConv = activeConversationRef.current;
|
||||
|
||||
// Skip duplicate messages (same content + timestamp)
|
||||
const contentKey = getMessageContentKey(msg);
|
||||
if (seenMessageContent.current.has(contentKey)) {
|
||||
console.debug('Duplicate message content ignored:', contentKey.slice(0, 50));
|
||||
return;
|
||||
}
|
||||
seenMessageContent.current.add(contentKey);
|
||||
// Limit set size to prevent memory issues (keep last 1000)
|
||||
if (seenMessageContent.current.size > 1000) {
|
||||
const entries = Array.from(seenMessageContent.current);
|
||||
seenMessageContent.current = new Set(entries.slice(-500));
|
||||
}
|
||||
|
||||
// Determine conversation key for this message
|
||||
let conversationKey: string | null = null;
|
||||
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||
conversationKey = getStateKey('channel', msg.conversation_key);
|
||||
} else if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
conversationKey = getStateKey('contact', msg.conversation_key);
|
||||
}
|
||||
|
||||
// Check if message belongs to the active conversation
|
||||
const isForActiveConversation = (() => {
|
||||
if (!activeConv) return false;
|
||||
if (msg.type === 'CHAN' && activeConv.type === 'channel') {
|
||||
return msg.conversation_key === activeConv.id;
|
||||
}
|
||||
if (msg.type === 'PRIV' && activeConv.type === 'contact') {
|
||||
// Match by public key or prefix (either could be full key or prefix)
|
||||
return msg.conversation_key && pubkeysMatch(activeConv.id, msg.conversation_key);
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
// Only add to message list if it's for the active conversation
|
||||
if (isForActiveConversation) {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === msg.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, msg];
|
||||
});
|
||||
}
|
||||
|
||||
// Track last message time for sorting and unread detection
|
||||
if (conversationKey) {
|
||||
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
|
||||
const updated = setLastMessageTime(conversationKey, timestamp);
|
||||
setLastMessageTimes(updated);
|
||||
|
||||
// Count unread messages during this session (for non-active, incoming messages)
|
||||
if (!msg.outgoing && !isForActiveConversation) {
|
||||
setUnreadCounts((prev) => ({
|
||||
...prev,
|
||||
[conversationKey]: (prev[conversationKey] || 0) + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
onContact: (contact: Contact) => {
|
||||
// Update or add contact, preserving existing non-null values
|
||||
setContacts((prev) => {
|
||||
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
const existing = prev[idx];
|
||||
// Merge: prefer new non-null values, but keep existing values if new is null
|
||||
updated[idx] = {
|
||||
...existing,
|
||||
...contact,
|
||||
name: contact.name ?? existing.name,
|
||||
last_path: contact.last_path ?? existing.last_path,
|
||||
lat: contact.lat ?? existing.lat,
|
||||
lon: contact.lon ?? existing.lon,
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
return [...prev, contact as Contact];
|
||||
});
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
setRawPackets((prev) => {
|
||||
// Check if packet already exists
|
||||
if (prev.some((p) => p.id === packet.id)) {
|
||||
return prev;
|
||||
}
|
||||
// Limit to MAX_RAW_PACKETS, removing oldest
|
||||
const updated = [...prev, packet];
|
||||
if (updated.length > MAX_RAW_PACKETS) {
|
||||
return updated.slice(-MAX_RAW_PACKETS);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onMessageAcked: (messageId: number) => {
|
||||
// Update message acked status
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === messageId);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...prev[idx], acked: true };
|
||||
return updated;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
}), []);
|
||||
|
||||
// Connect to WebSocket
|
||||
useWebSocket(wsHandlers);
|
||||
|
||||
// Fetch radio config (not sent via WebSocket)
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getRadioConfig();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch app settings
|
||||
const fetchAppSettings = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
setAppSettings(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch app settings:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch undecrypted packet count
|
||||
const fetchUndecryptedCount = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getUndecryptedPacketCount();
|
||||
setUndecryptedCount(data.count);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch undecrypted count:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const MESSAGE_PAGE_SIZE = 200;
|
||||
|
||||
// Fetch messages for active conversation
|
||||
const fetchMessages = useCallback(async (showLoading = false) => {
|
||||
if (!activeConversation) {
|
||||
setMessages([]);
|
||||
setHasOlderMessages(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
setMessagesLoading(true);
|
||||
}
|
||||
try {
|
||||
const data = await api.getMessages({
|
||||
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
||||
conversation_key: activeConversation.id,
|
||||
limit: MESSAGE_PAGE_SIZE,
|
||||
});
|
||||
setMessages(data);
|
||||
// If we got a full page, there might be more
|
||||
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
// Fetch older messages (pagination)
|
||||
const fetchOlderMessages = useCallback(async () => {
|
||||
if (!activeConversation || loadingOlder || !hasOlderMessages) return;
|
||||
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const data = await api.getMessages({
|
||||
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
||||
conversation_key: activeConversation.id,
|
||||
limit: MESSAGE_PAGE_SIZE,
|
||||
offset: messages.length,
|
||||
});
|
||||
|
||||
if (data.length > 0) {
|
||||
// Prepend older messages (they come sorted DESC, so older are at the end)
|
||||
setMessages(prev => [...prev, ...data]);
|
||||
}
|
||||
// If we got less than a full page, no more messages
|
||||
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch older messages:', err);
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [activeConversation, loadingOlder, hasOlderMessages, messages.length]);
|
||||
|
||||
// Initial fetch for config and settings (WebSocket handles health/contacts/channels)
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchAppSettings();
|
||||
fetchUndecryptedCount();
|
||||
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount]);
|
||||
|
||||
// Select Public channel by default when channels first load
|
||||
const hasSetDefaultConversation = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasSetDefaultConversation.current || channels.length === 0 || activeConversation) return;
|
||||
|
||||
const publicChannel = channels.find(c => c.name === 'Public');
|
||||
if (publicChannel) {
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
name: publicChannel.name,
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
}, [channels, activeConversation]);
|
||||
|
||||
// Fetch messages and count unreads for all conversations on load (single bulk request)
|
||||
const fetchedChannels = useRef<Set<string>>(new Set());
|
||||
const fetchedContacts = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Find channels and contacts we haven't fetched yet
|
||||
const newChannels = channels.filter(c => !fetchedChannels.current.has(c.key));
|
||||
const newContacts = contacts.filter(c => c.public_key && !fetchedContacts.current.has(c.public_key));
|
||||
|
||||
if (newChannels.length === 0 && newContacts.length === 0) return;
|
||||
|
||||
// Mark as fetched before starting (to avoid duplicate fetches if effect re-runs)
|
||||
newChannels.forEach(c => fetchedChannels.current.add(c.key));
|
||||
newContacts.forEach(c => fetchedContacts.current.add(c.public_key));
|
||||
|
||||
const fetchAndCountUnreads = async () => {
|
||||
// Build list of conversations to fetch
|
||||
const conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }> = [
|
||||
...newChannels.map(c => ({ type: 'CHAN' as const, conversation_key: c.key })),
|
||||
...newContacts.map(c => ({ type: 'PRIV' as const, conversation_key: c.public_key })),
|
||||
];
|
||||
|
||||
if (conversations.length === 0) return;
|
||||
|
||||
try {
|
||||
// Single bulk request for all conversations
|
||||
const bulkMessages = await api.getMessagesBulk(conversations, 100);
|
||||
|
||||
// Read lastReadTimes fresh from localStorage for accurate comparison
|
||||
const currentReadTimes = getLastReadTimes();
|
||||
const newUnreadCounts: Record<string, number> = {};
|
||||
const newLastMessageTimes: Record<string, number> = {};
|
||||
|
||||
// Process channel messages
|
||||
for (const channel of newChannels) {
|
||||
const msgs = bulkMessages[`CHAN:${channel.key}`] || [];
|
||||
if (msgs.length > 0) {
|
||||
const key = getStateKey('channel', channel.key);
|
||||
const lastRead = currentReadTimes[key] || 0;
|
||||
|
||||
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
|
||||
if (unreadCount > 0) {
|
||||
newUnreadCounts[key] = unreadCount;
|
||||
}
|
||||
|
||||
const latestTime = Math.max(...msgs.map(m => m.received_at));
|
||||
newLastMessageTimes[key] = latestTime;
|
||||
setLastMessageTime(key, latestTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Process contact messages
|
||||
for (const contact of newContacts) {
|
||||
const msgs = bulkMessages[`PRIV:${contact.public_key}`] || [];
|
||||
if (msgs.length > 0) {
|
||||
const key = getStateKey('contact', contact.public_key);
|
||||
const lastRead = currentReadTimes[key] || 0;
|
||||
|
||||
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
|
||||
if (unreadCount > 0) {
|
||||
newUnreadCounts[key] = unreadCount;
|
||||
}
|
||||
|
||||
const latestTime = Math.max(...msgs.map(m => m.received_at));
|
||||
newLastMessageTimes[key] = latestTime;
|
||||
setLastMessageTime(key, latestTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state with all the counts and times
|
||||
if (Object.keys(newUnreadCounts).length > 0) {
|
||||
setUnreadCounts(prev => ({ ...prev, ...newUnreadCounts }));
|
||||
}
|
||||
setLastMessageTimes(getLastMessageTimes());
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages bulk:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndCountUnreads();
|
||||
}, [channels, contacts]);
|
||||
|
||||
// Keep ref in sync with state and mark conversation as read when viewed
|
||||
useEffect(() => {
|
||||
activeConversationRef.current = activeConversation;
|
||||
|
||||
// Mark conversation as read when user views it
|
||||
if (activeConversation && activeConversation.type !== 'raw') {
|
||||
const key = getStateKey(
|
||||
activeConversation.type as 'channel' | 'contact',
|
||||
activeConversation.id
|
||||
);
|
||||
// Update localStorage-based read time
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
setLastReadTime(key, now);
|
||||
|
||||
// Clear unread count for this conversation
|
||||
setUnreadCounts((prev) => {
|
||||
if (prev[key]) {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
// Fetch messages when conversation changes
|
||||
useEffect(() => {
|
||||
fetchMessages(true);
|
||||
}, [fetchMessages]);
|
||||
|
||||
// Send message handler
|
||||
const handleSendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
if (!activeConversation) return;
|
||||
|
||||
if (activeConversation.type === 'channel') {
|
||||
await api.sendChannelMessage(activeConversation.id, text);
|
||||
} else {
|
||||
await api.sendDirectMessage(activeConversation.id, text);
|
||||
}
|
||||
// Message will arrive via WebSocket, but fetch to be safe
|
||||
await fetchMessages();
|
||||
},
|
||||
[activeConversation, fetchMessages]
|
||||
);
|
||||
|
||||
// Config save handler
|
||||
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
|
||||
await api.updateRadioConfig(update);
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
// App settings save handler
|
||||
const handleSaveAppSettings = useCallback(async (update: AppSettingsUpdate) => {
|
||||
await api.updateSettings(update);
|
||||
await fetchAppSettings();
|
||||
}, [fetchAppSettings]);
|
||||
|
||||
// Set private key handler
|
||||
const handleSetPrivateKey = useCallback(async (key: string) => {
|
||||
await api.setPrivateKey(key);
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
// Reboot radio handler
|
||||
const handleReboot = useCallback(async () => {
|
||||
await api.rebootRadio();
|
||||
// Immediately show disconnected state
|
||||
setHealth((prev) =>
|
||||
prev ? { ...prev, radio_connected: false } : prev
|
||||
);
|
||||
// Health updates will come via WebSocket when reconnected
|
||||
// But also poll as backup
|
||||
const pollUntilReconnected = async () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try {
|
||||
const data = await api.getHealth();
|
||||
setHealth(data);
|
||||
if (data.radio_connected) {
|
||||
fetchConfig();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling
|
||||
}
|
||||
}
|
||||
};
|
||||
pollUntilReconnected();
|
||||
}, [fetchConfig]);
|
||||
|
||||
// Send flood advertisement handler
|
||||
const handleAdvertise = useCallback(async () => {
|
||||
try {
|
||||
await api.sendAdvertisement(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to send advertisement:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle sender click to add mention
|
||||
const handleSenderClick = useCallback((sender: string) => {
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
}, []);
|
||||
|
||||
// Handle conversation selection (closes sidebar on mobile)
|
||||
const handleSelectConversation = useCallback((conv: Conversation) => {
|
||||
setActiveConversation(conv);
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
// Delete channel handler
|
||||
const handleDeleteChannel = useCallback(async (key: string) => {
|
||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||
try {
|
||||
await api.deleteChannel(key);
|
||||
setChannels((prev) => prev.filter((c) => c.key !== key));
|
||||
setActiveConversation(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete channel:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Delete contact handler
|
||||
const handleDeleteContact = useCallback(async (publicKey: string) => {
|
||||
if (!confirm('Delete this contact? Message history will be preserved.')) return;
|
||||
try {
|
||||
await api.deleteContact(publicKey);
|
||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||
setActiveConversation(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete contact:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create contact handler
|
||||
const handleCreateContact = useCallback(
|
||||
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
||||
const newContact: Contact = {
|
||||
public_key: publicKey,
|
||||
name,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
};
|
||||
setContacts((prev) => [...prev, newContact]);
|
||||
|
||||
// Open the new contact
|
||||
setActiveConversation({
|
||||
type: 'contact',
|
||||
id: publicKey,
|
||||
name: getContactDisplayName(name, publicKey),
|
||||
});
|
||||
|
||||
if (tryHistorical) {
|
||||
console.log('Contact historical decryption not yet supported');
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Create channel handler
|
||||
const handleCreateChannel = useCallback(
|
||||
async (name: string, key: string, tryHistorical: boolean) => {
|
||||
const created = await api.createChannel(name, key);
|
||||
// Channel will be broadcast via WebSocket, but fetch to be safe
|
||||
const data = await api.getChannels();
|
||||
setChannels(data);
|
||||
|
||||
// Open the new channel (use created.key as the id)
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: created.key,
|
||||
name,
|
||||
});
|
||||
|
||||
if (tryHistorical) {
|
||||
await api.decryptHistoricalPackets({
|
||||
key_type: 'channel',
|
||||
channel_key: created.key,
|
||||
});
|
||||
fetchUndecryptedCount();
|
||||
}
|
||||
},
|
||||
[fetchUndecryptedCount]
|
||||
);
|
||||
|
||||
// Create hashtag channel handler
|
||||
const handleCreateHashtagChannel = useCallback(
|
||||
async (name: string, tryHistorical: boolean) => {
|
||||
const channelName = name.startsWith('#') ? name : `#${name}`;
|
||||
|
||||
const created = await api.createChannel(channelName);
|
||||
const data = await api.getChannels();
|
||||
setChannels(data);
|
||||
|
||||
// Open the new channel (use created.key as the id)
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: created.key,
|
||||
name: channelName,
|
||||
});
|
||||
|
||||
if (tryHistorical) {
|
||||
await api.decryptHistoricalPackets({
|
||||
key_type: 'channel',
|
||||
channel_name: channelName,
|
||||
});
|
||||
fetchUndecryptedCount();
|
||||
}
|
||||
},
|
||||
[fetchUndecryptedCount]
|
||||
);
|
||||
|
||||
// Sidebar content (shared between desktop and mobile)
|
||||
const sidebarContent = (
|
||||
<Sidebar
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
activeConversation={activeConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewMessage={() => {
|
||||
setShowNewMessage(true);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
lastMessageTimes={lastMessageTimes}
|
||||
unreadCounts={unreadCounts}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<StatusBar
|
||||
health={health}
|
||||
config={config}
|
||||
onConfigClick={() => setShowConfig(true)}
|
||||
onAdvertise={handleAdvertise}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop sidebar - hidden on mobile */}
|
||||
<div className="hidden md:block">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar - Sheet that slides in */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex-1 flex flex-col bg-background">
|
||||
{activeConversation ? (
|
||||
activeConversation.type === 'raw' ? (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">Raw Packet Feed</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<RawPacketList packets={rawPackets} />
|
||||
</div>
|
||||
<div className="h-[280px] flex-shrink-0 border-t border-border overflow-hidden">
|
||||
<CrackerPanel
|
||||
packets={rawPackets}
|
||||
channels={channels}
|
||||
onChannelCreate={async (name, key) => {
|
||||
// Create channel without navigating to it
|
||||
const created = await api.createChannel(name, key);
|
||||
const data = await api.getChannels();
|
||||
setChannels(data);
|
||||
// Try to decrypt historical packets with this key
|
||||
await api.decryptHistoricalPackets({
|
||||
key_type: 'channel',
|
||||
channel_key: created.key,
|
||||
});
|
||||
fetchUndecryptedCount();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">
|
||||
<span className="flex flex-col sm:flex-row sm:items-center sm:gap-2">
|
||||
<span>
|
||||
{activeConversation.type === 'channel' && !activeConversation.name.startsWith('#') ? '#' : ''}
|
||||
{activeConversation.name}
|
||||
</span>
|
||||
<span className="font-normal text-xs text-muted-foreground font-mono">
|
||||
{activeConversation.id}
|
||||
</span>
|
||||
</span>
|
||||
{!(activeConversation.type === 'channel' && activeConversation.name === 'Public') && (
|
||||
<button
|
||||
className="py-1 px-3 bg-destructive/20 border border-destructive/30 text-destructive rounded text-xs cursor-pointer hover:bg-destructive/30"
|
||||
onClick={() => {
|
||||
if (activeConversation.type === 'channel') {
|
||||
handleDeleteChannel(activeConversation.id);
|
||||
} else {
|
||||
handleDeleteContact(activeConversation.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
onSenderClick={activeConversation.type === 'channel' ? handleSenderClick : undefined}
|
||||
onLoadOlder={fetchOlderMessages}
|
||||
/>
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={handleSendMessage}
|
||||
disabled={!health?.radio_connected}
|
||||
placeholder={
|
||||
health?.radio_connected
|
||||
? `Message ${activeConversation.name}...`
|
||||
: 'Radio not connected'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Select a conversation or start a new one
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewMessageModal
|
||||
open={showNewMessage}
|
||||
contacts={contacts}
|
||||
undecryptedCount={undecryptedCount}
|
||||
onClose={() => setShowNewMessage(false)}
|
||||
onSelectConversation={(conv) => {
|
||||
setActiveConversation(conv);
|
||||
setShowNewMessage(false);
|
||||
}}
|
||||
onCreateContact={handleCreateContact}
|
||||
onCreateChannel={handleCreateChannel}
|
||||
onCreateHashtagChannel={handleCreateHashtagChannel}
|
||||
/>
|
||||
|
||||
<ConfigModal
|
||||
open={showConfig}
|
||||
config={config}
|
||||
appSettings={appSettings}
|
||||
onClose={() => setShowConfig(false)}
|
||||
onSave={handleSaveConfig}
|
||||
onSaveAppSettings={handleSaveAppSettings}
|
||||
onSetPrivateKey={handleSetPrivateKey}
|
||||
onReboot={handleReboot}
|
||||
/>
|
||||
|
||||
<Toaster position="top-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Channel,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface DecryptResult {
|
||||
started: boolean;
|
||||
total_packets: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Health
|
||||
getHealth: () => fetchJson<HealthStatus>('/health'),
|
||||
|
||||
// Radio config
|
||||
getRadioConfig: () => fetchJson<RadioConfig>('/radio/config'),
|
||||
updateRadioConfig: (config: RadioConfigUpdate) =>
|
||||
fetchJson<RadioConfig>('/radio/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
setPrivateKey: (privateKey: string) =>
|
||||
fetchJson<{ status: string }>('/radio/private-key', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ private_key: privateKey }),
|
||||
}),
|
||||
sendAdvertisement: (flood = true) =>
|
||||
fetchJson<{ status: string; flood: boolean }>(
|
||||
`/radio/advertise?flood=${flood}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
rebootRadio: () =>
|
||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||
method: 'POST',
|
||||
}),
|
||||
reconnectRadio: () =>
|
||||
fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Contacts
|
||||
getContacts: (limit = 100, offset = 0) =>
|
||||
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
|
||||
getContact: (publicKey: string) => fetchJson<Contact>(`/contacts/${publicKey}`),
|
||||
syncContacts: () =>
|
||||
fetchJson<{ synced: number }>('/contacts/sync', { method: 'POST' }),
|
||||
addContactToRadio: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}/add-to-radio`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
removeContactFromRadio: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}/remove-from-radio`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// Channels
|
||||
getChannels: () => fetchJson<Channel[]>('/channels'),
|
||||
getChannel: (key: string) => fetchJson<Channel>(`/channels/${key}`),
|
||||
createChannel: (name: string, key?: string) =>
|
||||
fetchJson<Channel>('/channels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, key }),
|
||||
}),
|
||||
syncChannels: () =>
|
||||
fetchJson<{ synced: number }>('/channels/sync', { method: 'POST' }),
|
||||
deleteChannel: (key: string) =>
|
||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||
|
||||
// Messages
|
||||
getMessages: (params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
type?: 'PRIV' | 'CHAN';
|
||||
conversation_key?: string;
|
||||
}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.limit) searchParams.set('limit', params.limit.toString());
|
||||
if (params?.offset) searchParams.set('offset', params.offset.toString());
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
if (params?.conversation_key)
|
||||
searchParams.set('conversation_key', params.conversation_key);
|
||||
const query = searchParams.toString();
|
||||
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`);
|
||||
},
|
||||
getMessagesBulk: (
|
||||
conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }>,
|
||||
limitPerConversation: number = 100
|
||||
) =>
|
||||
fetchJson<Record<string, Message[]>>(
|
||||
`/messages/bulk?limit_per_conversation=${limitPerConversation}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(conversations),
|
||||
}
|
||||
),
|
||||
sendDirectMessage: (destination: string, text: string) =>
|
||||
fetchJson<Message>('/messages/direct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ destination, text }),
|
||||
}),
|
||||
sendChannelMessage: (channelKey: string, text: string) =>
|
||||
fetchJson<Message>('/messages/channel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel_key: channelKey, text }),
|
||||
}),
|
||||
|
||||
// Packets
|
||||
getUndecryptedPacketCount: () =>
|
||||
fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||
decryptHistoricalPackets: (params: {
|
||||
key_type: 'channel' | 'contact';
|
||||
channel_key?: string;
|
||||
channel_name?: string;
|
||||
}) =>
|
||||
fetchJson<DecryptResult>('/packets/decrypt/historical', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
|
||||
// App Settings
|
||||
getSettings: () => fetchJson<AppSettings>('/settings'),
|
||||
updateSettings: (settings: AppSettingsUpdate) =>
|
||||
fetchJson<AppSettings>('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(settings),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { AppSettings, AppSettingsUpdate, RadioConfig, RadioConfigUpdate } from '../types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
|
||||
interface ConfigModalProps {
|
||||
open: boolean;
|
||||
config: RadioConfig | null;
|
||||
appSettings: AppSettings | null;
|
||||
onClose: () => void;
|
||||
onSave: (update: RadioConfigUpdate) => Promise<void>;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onSetPrivateKey: (key: string) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfigModal({
|
||||
open,
|
||||
config,
|
||||
appSettings,
|
||||
onClose,
|
||||
onSave,
|
||||
onSaveAppSettings,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
}: ConfigModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [lat, setLat] = useState('');
|
||||
const [lon, setLon] = useState('');
|
||||
const [txPower, setTxPower] = useState('');
|
||||
const [freq, setFreq] = useState('');
|
||||
const [bw, setBw] = useState('');
|
||||
const [sf, setSf] = useState('');
|
||||
const [cr, setCr] = useState('');
|
||||
const [privateKey, setPrivateKey] = useState('');
|
||||
const [maxRadioContacts, setMaxRadioContacts] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setName(config.name);
|
||||
setLat(String(config.lat));
|
||||
setLon(String(config.lon));
|
||||
setTxPower(String(config.tx_power));
|
||||
setFreq(String(config.radio.freq));
|
||||
setBw(String(config.radio.bw));
|
||||
setSf(String(config.radio.sf));
|
||||
setCr(String(config.radio.cr));
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appSettings) {
|
||||
setMaxRadioContacts(String(appSettings.max_radio_contacts));
|
||||
}
|
||||
}, [appSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const update: RadioConfigUpdate = {
|
||||
name,
|
||||
lat: parseFloat(lat),
|
||||
lon: parseFloat(lon),
|
||||
tx_power: parseInt(txPower, 10),
|
||||
radio: {
|
||||
freq: parseFloat(freq),
|
||||
bw: parseFloat(bw),
|
||||
sf: parseInt(sf, 10),
|
||||
cr: parseInt(cr, 10),
|
||||
},
|
||||
};
|
||||
await onSave(update);
|
||||
|
||||
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
|
||||
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
|
||||
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrivateKey = async () => {
|
||||
if (!privateKey.trim()) {
|
||||
setError('Private key is required');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await onSetPrivateKey(privateKey.trim());
|
||||
setPrivateKey('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to set private key');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReboot = async () => {
|
||||
if (!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')) {
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setRebooting(true);
|
||||
|
||||
try {
|
||||
await onReboot();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
|
||||
} finally {
|
||||
setRebooting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radio Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!config ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
Loading configuration...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="public-key">Public Key</Label>
|
||||
<Input id="public-key" value={config.public_key} disabled />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lat">Latitude</Label>
|
||||
<Input
|
||||
id="lat"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lat}
|
||||
onChange={(e) => setLat(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lon">Longitude</Label>
|
||||
<Input
|
||||
id="lon"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lon}
|
||||
onChange={(e) => setLon(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="freq">Frequency (MHz)</Label>
|
||||
<Input
|
||||
id="freq"
|
||||
type="number"
|
||||
step="any"
|
||||
value={freq}
|
||||
onChange={(e) => setFreq(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bw">Bandwidth (kHz)</Label>
|
||||
<Input
|
||||
id="bw"
|
||||
type="number"
|
||||
step="any"
|
||||
value={bw}
|
||||
onChange={(e) => setBw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sf">Spreading Factor</Label>
|
||||
<Input
|
||||
id="sf"
|
||||
type="number"
|
||||
min="7"
|
||||
max="12"
|
||||
value={sf}
|
||||
onChange={(e) => setSf(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cr">Coding Rate</Label>
|
||||
<Input
|
||||
id="cr"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4"
|
||||
value={cr}
|
||||
onChange={(e) => setCr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tx-power">TX Power (dBm)</Label>
|
||||
<Input
|
||||
id="tx-power"
|
||||
type="number"
|
||||
value={txPower}
|
||||
onChange={(e) => setTxPower(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-tx">Max TX Power</Label>
|
||||
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
|
||||
<Input
|
||||
id="max-contacts"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={maxRadioContacts}
|
||||
onChange={(e) => setMaxRadioContacts(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="private-key"
|
||||
type="password"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="64-character hex private key"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSetPrivateKey}
|
||||
disabled={loading || !privateKey.trim()}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Reboot Radio</Label>
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
Some configuration changes (like name) require a radio reboot to take effect.
|
||||
The connection will temporarily drop and automatically reconnect.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReboot}
|
||||
disabled={rebooting || loading}
|
||||
className="border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
|
||||
>
|
||||
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || !config}>
|
||||
{loading ? 'Saving...' : 'Save Config'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getContactAvatar } from '../utils/contactAvatar';
|
||||
|
||||
interface ContactAvatarProps {
|
||||
name: string | null;
|
||||
publicKey: string;
|
||||
size?: number;
|
||||
contactType?: number;
|
||||
}
|
||||
|
||||
export function ContactAvatar({ name, publicKey, size = 28, contactType }: ContactAvatarProps) {
|
||||
const avatar = getContactAvatar(name, publicKey, contactType);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
|
||||
style={{
|
||||
backgroundColor: avatar.background,
|
||||
color: avatar.textColor,
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: size * 0.45,
|
||||
}}
|
||||
>
|
||||
{avatar.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { GroupTextCracker, type ProgressReport } from 'meshcore-cracker';
|
||||
import type { RawPacket, Channel } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CrackedRoom {
|
||||
roomName: string;
|
||||
key: string;
|
||||
packetId: number;
|
||||
message: string;
|
||||
crackedAt: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
packet: RawPacket;
|
||||
attempts: number;
|
||||
lastAttemptLength: number;
|
||||
status: 'pending' | 'cracking' | 'cracked' | 'failed';
|
||||
}
|
||||
|
||||
interface CrackerPanelProps {
|
||||
packets: RawPacket[];
|
||||
channels: Channel[];
|
||||
onChannelCreate: (name: string, key: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CrackerPanel({ packets, channels, onChannelCreate }: CrackerPanelProps) {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [maxLength, setMaxLength] = useState(6);
|
||||
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
||||
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
||||
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const crackerRef = useRef<GroupTextCracker | null>(null);
|
||||
const isRunningRef = useRef(false);
|
||||
const abortedRef = useRef(false);
|
||||
const isProcessingRef = useRef(false);
|
||||
const queueRef = useRef<Map<number, QueueItem>>(new Map());
|
||||
const retryFailedRef = useRef(false);
|
||||
const maxLengthRef = useRef(6);
|
||||
|
||||
// Initialize cracker
|
||||
useEffect(() => {
|
||||
const cracker = new GroupTextCracker();
|
||||
crackerRef.current = cracker;
|
||||
setGpuAvailable(cracker.isGpuAvailable());
|
||||
|
||||
// Load wordlist
|
||||
cracker.loadWordlist('/words_alpha.txt')
|
||||
.then(() => setWordlistLoaded(true))
|
||||
.catch((err) => console.error('Failed to load wordlist:', err));
|
||||
|
||||
return () => {
|
||||
cracker.destroy();
|
||||
crackerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get existing channel keys for filtering
|
||||
const existingChannelKeys = new Set(channels.map(c => c.key.toUpperCase()));
|
||||
|
||||
// Filter packets to only undecrypted GROUP_TEXT
|
||||
const undecryptedGroupText = packets.filter(
|
||||
p => p.payload_type === 'GROUP_TEXT' && !p.decrypted
|
||||
);
|
||||
|
||||
// Update queue when packets change
|
||||
useEffect(() => {
|
||||
setQueue(prev => {
|
||||
const newQueue = new Map(prev);
|
||||
let changed = false;
|
||||
|
||||
for (const packet of undecryptedGroupText) {
|
||||
if (!newQueue.has(packet.id)) {
|
||||
newQueue.set(packet.id, {
|
||||
packet,
|
||||
attempts: 0,
|
||||
lastAttemptLength: 0,
|
||||
status: 'pending',
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
queueRef.current = newQueue;
|
||||
return newQueue;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [undecryptedGroupText.length]);
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
queueRef.current = queue;
|
||||
}, [queue]);
|
||||
|
||||
useEffect(() => {
|
||||
retryFailedRef.current = retryFailedAtNextLength;
|
||||
}, [retryFailedAtNextLength]);
|
||||
|
||||
useEffect(() => {
|
||||
maxLengthRef.current = maxLength;
|
||||
}, [maxLength]);
|
||||
|
||||
// Stats (cracking count is implicit - if progress is shown, we're cracking one)
|
||||
const pendingCount = Array.from(queue.values()).filter(q => q.status === 'pending').length;
|
||||
const crackedCount = Array.from(queue.values()).filter(q => q.status === 'cracked').length;
|
||||
const failedCount = Array.from(queue.values()).filter(q => q.status === 'failed').length;
|
||||
|
||||
// Process next packet in queue
|
||||
const processNext = useCallback(async () => {
|
||||
// Prevent concurrent processing
|
||||
if (isProcessingRef.current) return;
|
||||
if (!crackerRef.current || !isRunningRef.current) return;
|
||||
|
||||
const currentQueue = queueRef.current;
|
||||
|
||||
// Find next pending packet
|
||||
let nextItem: QueueItem | null = null;
|
||||
let nextId: number | null = null;
|
||||
|
||||
for (const [id, item] of currentQueue.entries()) {
|
||||
if (item.status === 'pending') {
|
||||
nextItem = item;
|
||||
nextId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no pending and retry option is enabled, pick the failed one with lowest lastAttemptLength
|
||||
if (!nextItem && retryFailedRef.current) {
|
||||
const failedItems = Array.from(currentQueue.entries()).filter(
|
||||
([, item]) => item.status === 'failed' && item.lastAttemptLength < 10 // Hard cap at length 10
|
||||
);
|
||||
if (failedItems.length > 0) {
|
||||
// Sort by lastAttemptLength ascending and pick the first (lowest)
|
||||
failedItems.sort((a, b) => a[1].lastAttemptLength - b[1].lastAttemptLength);
|
||||
[nextId, nextItem] = failedItems[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextItem || nextId === null) {
|
||||
// Nothing to process right now, but keep running and check again later
|
||||
if (isRunningRef.current) {
|
||||
setTimeout(() => processNext(), 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock processing
|
||||
isProcessingRef.current = true;
|
||||
|
||||
const currentMaxLength = maxLengthRef.current;
|
||||
const targetLength = nextItem.lastAttemptLength > 0
|
||||
? nextItem.lastAttemptLength + 1
|
||||
: currentMaxLength;
|
||||
|
||||
try {
|
||||
const result = await crackerRef.current.crack(
|
||||
nextItem.packet.data,
|
||||
{
|
||||
maxLength: targetLength,
|
||||
useTimestampFilter: true,
|
||||
useUtf8Filter: true,
|
||||
},
|
||||
(prog) => {
|
||||
setProgress(prog);
|
||||
}
|
||||
);
|
||||
|
||||
if (abortedRef.current) {
|
||||
abortedRef.current = false;
|
||||
isProcessingRef.current = false;
|
||||
setProgress(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.found && result.roomName && result.key) {
|
||||
// Success!
|
||||
setQueue(prev => {
|
||||
const updated = new Map(prev);
|
||||
const item = updated.get(nextId!);
|
||||
if (item) {
|
||||
updated.set(nextId!, {
|
||||
...item,
|
||||
status: 'cracked',
|
||||
attempts: item.attempts + 1,
|
||||
lastAttemptLength: targetLength,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
const newRoom: CrackedRoom = {
|
||||
roomName: result.roomName,
|
||||
key: result.key,
|
||||
packetId: nextId!,
|
||||
message: result.decryptedMessage || '',
|
||||
crackedAt: Date.now(),
|
||||
};
|
||||
setCrackedRooms(prev => [...prev, newRoom]);
|
||||
|
||||
// Auto-add channel if not already exists
|
||||
const keyUpper = result.key.toUpperCase();
|
||||
if (!existingChannelKeys.has(keyUpper)) {
|
||||
try {
|
||||
await onChannelCreate('#' + result.roomName, result.key);
|
||||
} catch (err) {
|
||||
console.error('Failed to create channel:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Failed
|
||||
setQueue(prev => {
|
||||
const updated = new Map(prev);
|
||||
const item = updated.get(nextId!);
|
||||
if (item) {
|
||||
updated.set(nextId!, {
|
||||
...item,
|
||||
status: 'failed',
|
||||
attempts: item.attempts + 1,
|
||||
lastAttemptLength: targetLength,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Cracking error:', err);
|
||||
setQueue(prev => {
|
||||
const updated = new Map(prev);
|
||||
const item = updated.get(nextId!);
|
||||
if (item) {
|
||||
updated.set(nextId!, {
|
||||
...item,
|
||||
status: 'failed',
|
||||
attempts: item.attempts + 1,
|
||||
lastAttemptLength: targetLength,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Unlock processing
|
||||
isProcessingRef.current = false;
|
||||
setProgress(null);
|
||||
|
||||
// Continue processing if still running
|
||||
if (isRunningRef.current) {
|
||||
setTimeout(() => processNext(), 100);
|
||||
}
|
||||
}, [existingChannelKeys, onChannelCreate]);
|
||||
|
||||
// Start/stop handlers
|
||||
const handleStart = () => {
|
||||
if (!gpuAvailable) {
|
||||
alert('WebGPU is not available in your browser. Please use Chrome 113+ or Edge 113+.');
|
||||
return;
|
||||
}
|
||||
setIsRunning(true);
|
||||
isRunningRef.current = true;
|
||||
abortedRef.current = false;
|
||||
processNext();
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
setIsRunning(false);
|
||||
isRunningRef.current = false;
|
||||
abortedRef.current = true;
|
||||
crackerRef.current?.abort();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-3 gap-3 bg-background border-t border-border">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={!wordlistLoaded || gpuAvailable === false}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded text-sm font-medium",
|
||||
isRunning
|
||||
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isRunning ? 'Stop' : 'Start Cracking'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground">Max Length:</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLength}
|
||||
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
|
||||
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={retryFailedAtNextLength}
|
||||
onChange={(e) => setRetryFailedAtNextLength(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Retry failed at n+1
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Cracked: <span className="text-green-500 font-medium">{crackedCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Failed: <span className="text-destructive font-medium">{failedCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{progress && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{progress.phase === 'wordlist' ? 'Dictionary' : progress.phase === 'bruteforce' ? 'Bruteforce' : 'Public Key'}
|
||||
{progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}
|
||||
: {progress.currentPosition}
|
||||
</span>
|
||||
<span>
|
||||
{progress.rateKeysPerSec >= 1e9
|
||||
? `${(progress.rateKeysPerSec / 1e9).toFixed(2)} Gkeys/s`
|
||||
: `${(progress.rateKeysPerSec / 1e6).toFixed(1)} Mkeys/s`}
|
||||
{' '}• ETA: {progress.etaSeconds < 60 ? `${Math.round(progress.etaSeconds)}s` : `${Math.round(progress.etaSeconds / 60)}m`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-200"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPU status */}
|
||||
{gpuAvailable === false && (
|
||||
<div className="text-sm text-destructive">
|
||||
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
|
||||
</div>
|
||||
)}
|
||||
{!wordlistLoaded && gpuAvailable !== false && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading wordlist...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracked rooms list */}
|
||||
{crackedRooms.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
|
||||
<div className="space-y-1">
|
||||
{crackedRooms.map((room, i) => (
|
||||
<div key={i} className="text-sm bg-green-950/30 border border-green-900/50 rounded px-2 py-1">
|
||||
<span className="text-green-400 font-medium">#{room.roomName}</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
"{room.message.slice(0, 50)}{room.message.length > 50 ? '...' : ''}"
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useCallback, useImperativeHandle, forwardRef, useRef, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (text: string) => Promise<void>;
|
||||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface MessageInputHandle {
|
||||
appendText: (text: string) => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
|
||||
function MessageInput({ onSend, disabled, placeholder }, ref) {
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
appendText: (appendedText: string) => {
|
||||
setText((prev) => prev + appendedText);
|
||||
// Focus the input after appending
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || sending || disabled) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await onSend(trimmed);
|
||||
setText('');
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[text, sending, disabled, onSend]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e as unknown as FormEvent);
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="px-4 py-3 border-t border-border flex gap-2" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || 'Type a message...'}
|
||||
disabled={disabled || sending}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={disabled || sending || !text.trim()}>
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import type { Contact, Message } from '../types';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import { pubkeysMatch } from '../utils/pubkey';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
contacts: Contact[];
|
||||
loading: boolean;
|
||||
loadingOlder?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
contacts,
|
||||
loading,
|
||||
loadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
}: MessageListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const prevMessagesLengthRef = useRef<number>(0);
|
||||
const isInitialLoadRef = useRef<boolean>(true);
|
||||
|
||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
||||
const scrollStateRef = useRef({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
wasNearTop: false,
|
||||
});
|
||||
|
||||
// Handle scroll position AFTER render
|
||||
useLayoutEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
|
||||
const list = listRef.current;
|
||||
const messagesAdded = messages.length - prevMessagesLengthRef.current;
|
||||
|
||||
if (isInitialLoadRef.current && messages.length > 0) {
|
||||
// Initial load - scroll to bottom
|
||||
list.scrollTop = list.scrollHeight;
|
||||
isInitialLoadRef.current = false;
|
||||
} else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) {
|
||||
// Messages were added - use scroll state captured before the update
|
||||
const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight;
|
||||
|
||||
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
|
||||
// User was near top (loading older) - preserve position by adding the height diff
|
||||
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
|
||||
} else if (!scrollStateRef.current.wasNearTop) {
|
||||
// User was at bottom - scroll to bottom for new messages
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
prevMessagesLengthRef.current = messages.length;
|
||||
}, [messages]);
|
||||
|
||||
// Reset initial load flag when conversation changes (messages becomes empty then filled)
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
isInitialLoadRef.current = true;
|
||||
prevMessagesLengthRef.current = 0;
|
||||
scrollStateRef.current = { scrollTop: 0, scrollHeight: 0, wasNearTop: false };
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Handle scroll - capture state and detect when user is near top
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight } = listRef.current;
|
||||
|
||||
// Always capture current scroll state (needed for scroll preservation)
|
||||
scrollStateRef.current = {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
wasNearTop: scrollTop < 150,
|
||||
};
|
||||
|
||||
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
|
||||
|
||||
// Trigger load when within 100px of top
|
||||
if (scrollTop < 100) {
|
||||
onLoadOlder();
|
||||
}
|
||||
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
|
||||
|
||||
// Look up contact by public key or prefix
|
||||
const getContact = (conversationKey: string | null): Contact | null => {
|
||||
if (!conversationKey) return null;
|
||||
return contacts.find(c => pubkeysMatch(c.public_key, conversationKey)) || null;
|
||||
};
|
||||
|
||||
// Look up contact by name (for channel messages where we parse sender from text)
|
||||
const getContactByName = (name: string): Contact | null => {
|
||||
return contacts.find(c => c.name === name) || null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">Loading messages...</div>;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">No messages yet</div>;
|
||||
}
|
||||
|
||||
// Deduplicate messages by content + timestamp (same message via different paths)
|
||||
const deduplicatedMessages = messages.reduce<Message[]>((acc, msg) => {
|
||||
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
|
||||
const existing = acc.find(m =>
|
||||
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
|
||||
);
|
||||
if (!existing) {
|
||||
acc.push(msg);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
const sortedMessages = [...deduplicatedMessages].sort(
|
||||
(a, b) => a.received_at - b.received_at
|
||||
);
|
||||
|
||||
// Helper to get a unique sender key for grouping messages
|
||||
const getSenderKey = (msg: Message, sender: string | null): string => {
|
||||
if (msg.outgoing) return '__outgoing__';
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
||||
return sender || '__unknown__';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-0.5" ref={listRef} onScroll={handleScroll}>
|
||||
{loadingOlder && (
|
||||
<div className="text-center py-2 text-muted-foreground text-sm">
|
||||
Loading older messages...
|
||||
</div>
|
||||
)}
|
||||
{!loadingOlder && hasOlderMessages && (
|
||||
<div className="text-center py-2 text-muted-foreground text-xs">
|
||||
Scroll up for older messages
|
||||
</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 displaySender = msg.outgoing
|
||||
? 'You'
|
||||
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown';
|
||||
|
||||
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
|
||||
|
||||
// Determine if we should show avatar (first message in a chunk from same sender)
|
||||
const currentSenderKey = getSenderKey(msg, sender);
|
||||
const prevMsg = sortedMessages[index - 1];
|
||||
const prevSenderKey = prevMsg ? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender) : null;
|
||||
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
|
||||
const isFirstMessage = index === 0;
|
||||
|
||||
// Get avatar info for incoming messages
|
||||
let avatarName: string | null = null;
|
||||
let avatarKey: string = '';
|
||||
if (!msg.outgoing) {
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// DM: use conversation_key (sender's public key)
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
} else if (sender) {
|
||||
// Channel message: try to find contact by name, or use sender name as pseudo-key
|
||||
const senderContact = getContactByName(sender);
|
||||
avatarName = sender;
|
||||
avatarKey = senderContact?.public_key || `name:${sender}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex items-start max-w-[85%]",
|
||||
msg.outgoing && "flex-row-reverse self-end",
|
||||
showAvatar && !isFirstMessage && "mt-3"
|
||||
)}
|
||||
>
|
||||
{!msg.outgoing && (
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar && avatarKey && (
|
||||
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
"py-1.5 px-3 rounded-lg min-w-0",
|
||||
msg.outgoing ? "bg-[#1e3a29]" : "bg-muted"
|
||||
)}>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-muted-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={() => onSenderClick(displaySender)}
|
||||
title={`Mention ${displaySender}`}
|
||||
>
|
||||
{displaySender}
|
||||
</span>
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<span className="text-[10px] text-muted-foreground/50 ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
)}
|
||||
{msg.outgoing && (msg.acked ? ' ✓' : ' ?')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
|
||||
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
contacts: Contact[];
|
||||
undecryptedCount: number;
|
||||
onClose: () => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
contacts,
|
||||
undecryptedCount,
|
||||
onClose,
|
||||
onSelectConversation,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
onCreateHashtagChannel,
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('existing');
|
||||
const [name, setName] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
const [tryHistorical, setTryHistorical] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (tab === 'new-contact') {
|
||||
if (!name.trim() || !key.trim()) {
|
||||
setError('Name and public key are required');
|
||||
return;
|
||||
}
|
||||
await onCreateContact(name.trim(), key.trim(), tryHistorical);
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: key.trim(),
|
||||
name: name.trim(),
|
||||
});
|
||||
} else if (tab === 'new-room') {
|
||||
if (!name.trim() || !key.trim()) {
|
||||
setError('Room name and key are required');
|
||||
return;
|
||||
}
|
||||
await onCreateChannel(name.trim(), key.trim(), tryHistorical);
|
||||
} else if (tab === 'hashtag') {
|
||||
const channelName = name.trim();
|
||||
const validationError = validateHashtagName(channelName);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateHashtagName = (channelName: string): string | null => {
|
||||
if (!channelName) {
|
||||
return 'Channel name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreateAndAddAnother = async () => {
|
||||
setError('');
|
||||
const channelName = name.trim();
|
||||
const validationError = validateHashtagName(channelName);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
|
||||
setName('');
|
||||
hashtagInputRef.current?.focus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="existing">Existing</TabsTrigger>
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-room">Room</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
||||
{contacts.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No contacts available
|
||||
</div>
|
||||
) : (
|
||||
contacts.map((contact) => (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent"
|
||||
onClick={() => {
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{getContactDisplayName(contact.name, contact.public_key)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Name</Label>
|
||||
<Input
|
||||
id="contact-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Contact name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-key">Public Key</Label>
|
||||
<Input
|
||||
id="contact-key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="64-character hex public key"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-room" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-name">Room Name</Label>
|
||||
<Input
|
||||
id="room-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Room name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-key">Room Key</Label>
|
||||
<Input
|
||||
id="room-key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="Pre-shared key (hex)"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hashtag" className="mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hashtag-name">Hashtag Channel</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-muted-foreground">#</span>
|
||||
<Input
|
||||
ref={hashtagInputRef}
|
||||
id="hashtag-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="channel-name"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{showHistoricalOption && (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Label
|
||||
htmlFor="try-historical"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Try decrypting {undecryptedCount.toLocaleString()} stored packet{undecryptedCount !== 1 ? 's' : ''}
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="try-historical"
|
||||
checked={tryHistorical}
|
||||
onCheckedChange={(checked) => setTryHistorical(checked === true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{tab === 'hashtag' && (
|
||||
<Button variant="secondary" onClick={handleCreateAndAddAnother} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create & Add Another'}
|
||||
</Button>
|
||||
)}
|
||||
{tab !== 'existing' && (
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { RawPacket } from '../types';
|
||||
|
||||
interface RawPacketListProps {
|
||||
packets: RawPacket[];
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function formatPayloadType(type: string): string {
|
||||
// Convert SNAKE_CASE to Title Case
|
||||
return type
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getDecryptedLabel(packet: RawPacket): string {
|
||||
if (!packet.decrypted || !packet.decrypted_info) {
|
||||
return formatPayloadType(packet.payload_type);
|
||||
}
|
||||
|
||||
const info = packet.decrypted_info;
|
||||
if (packet.payload_type === 'GROUP_TEXT' && info.channel_name) {
|
||||
return `GroupText to ${info.channel_name}`;
|
||||
}
|
||||
if (packet.payload_type === 'TEXT_MESSAGE' && info.sender) {
|
||||
return `TextMessage from ${info.sender}`;
|
||||
}
|
||||
|
||||
return formatPayloadType(packet.payload_type);
|
||||
}
|
||||
|
||||
function formatSignalInfo(packet: RawPacket): string {
|
||||
const parts: string[] = [];
|
||||
if (packet.snr !== null && packet.snr !== undefined) {
|
||||
parts.push(`SNR: ${packet.snr.toFixed(1)} dB`);
|
||||
}
|
||||
if (packet.rssi !== null && packet.rssi !== undefined) {
|
||||
parts.push(`RSSI: ${packet.rssi} dBm`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [packets]);
|
||||
|
||||
if (packets.length === 0) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-5 text-center text-muted-foreground">
|
||||
No packets received yet. Packets will appear here in real-time.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort packets by timestamp ascending (oldest first)
|
||||
const sortedPackets = [...packets].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 flex flex-col gap-3" ref={listRef}>
|
||||
{sortedPackets.map((packet) => (
|
||||
<div key={packet.id} className="py-2 px-3 bg-muted rounded">
|
||||
<div className={packet.decrypted ? 'text-primary' : 'text-destructive'}>
|
||||
{!packet.decrypted && <span className="mr-1">🔒</span>}
|
||||
{getDecryptedLabel(packet)}
|
||||
{' • '}
|
||||
{formatTime(packet.timestamp)}
|
||||
</div>
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{formatSignalInfo(packet)}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-[11px] break-all text-muted-foreground/70 mt-1">
|
||||
{packet.data.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState } from 'react';
|
||||
import type { Contact, Channel, Conversation } from '../types';
|
||||
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
|
||||
import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SortOrder = 'alpha' | 'recent';
|
||||
|
||||
interface SidebarProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
activeConversation: Conversation | null;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onNewMessage: () => void;
|
||||
lastMessageTimes: ConversationTimes;
|
||||
unreadCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
// Load sort preference from localStorage
|
||||
function loadSortOrder(): SortOrder {
|
||||
try {
|
||||
const stored = localStorage.getItem('remoteterm-sortOrder');
|
||||
return stored === 'recent' ? 'recent' : 'alpha';
|
||||
} catch {
|
||||
return 'alpha';
|
||||
}
|
||||
}
|
||||
|
||||
// Save sort preference to localStorage
|
||||
function saveSortOrder(order: SortOrder): void {
|
||||
try {
|
||||
localStorage.setItem('remoteterm-sortOrder', order);
|
||||
} catch {
|
||||
// localStorage might be full or disabled
|
||||
}
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
contacts,
|
||||
channels,
|
||||
activeConversation,
|
||||
onSelectConversation,
|
||||
onNewMessage,
|
||||
lastMessageTimes,
|
||||
unreadCounts,
|
||||
}: SidebarProps) {
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const handleSortToggle = () => {
|
||||
const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha';
|
||||
setSortOrder(newOrder);
|
||||
saveSortOrder(newOrder);
|
||||
};
|
||||
|
||||
const handleSelectConversation = (conversation: Conversation) => {
|
||||
setSearchQuery('');
|
||||
onSelectConversation(conversation);
|
||||
};
|
||||
|
||||
const isActive = (type: 'contact' | 'channel' | 'raw', id: string) =>
|
||||
activeConversation?.type === type && activeConversation?.id === id;
|
||||
|
||||
// Get unread count for a conversation
|
||||
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
|
||||
const key = getStateKey(type, id);
|
||||
return unreadCounts[key] || 0;
|
||||
};
|
||||
|
||||
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
|
||||
const key = getStateKey(type, id);
|
||||
return lastMessageTimes[key] || 0;
|
||||
};
|
||||
|
||||
// Deduplicate channels by name, keeping the first (lowest index)
|
||||
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
|
||||
if (!acc.some((c) => c.name === channel.name)) {
|
||||
acc.push(channel);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Deduplicate contacts by 12-char prefix, preferring ones with names
|
||||
// Also filter out any contacts with empty public keys
|
||||
const uniqueContacts = contacts
|
||||
.filter((c) => c.public_key && c.public_key.length > 0)
|
||||
.sort((a, b) => {
|
||||
// Sort contacts with names first
|
||||
if (a.name && !b.name) return -1;
|
||||
if (!a.name && b.name) return 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
})
|
||||
.reduce<Contact[]>((acc, contact) => {
|
||||
const prefix = getPubkeyPrefix(contact.public_key);
|
||||
if (!acc.some((c) => getPubkeyPrefix(c.public_key) === prefix)) {
|
||||
acc.push(contact);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Sort channels based on sort order, with Public always first
|
||||
const sortedChannels = [...uniqueChannels].sort((a, b) => {
|
||||
// Public channel always sorts to the top
|
||||
if (a.name === 'Public') return -1;
|
||||
if (b.name === 'Public') return 1;
|
||||
|
||||
if (sortOrder === 'recent') {
|
||||
const timeA = getLastMessageTime('channel', a.key);
|
||||
const timeB = getLastMessageTime('channel', b.key);
|
||||
// If both have messages, sort by most recent first
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
// Items with messages come before items without
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
// Fall back to alpha for items without messages
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha)
|
||||
const sortedContacts = [...uniqueContacts].sort((a, b) => {
|
||||
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
|
||||
const bIsRepeater = b.type === CONTACT_TYPE_REPEATER;
|
||||
|
||||
// Repeaters always go to the bottom
|
||||
if (aIsRepeater && !bIsRepeater) return 1;
|
||||
if (!aIsRepeater && bIsRepeater) return -1;
|
||||
|
||||
// Both repeaters: always sort alphabetically
|
||||
if (aIsRepeater && bIsRepeater) {
|
||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||
}
|
||||
|
||||
// Both non-repeaters: use selected sort order
|
||||
if (sortOrder === 'recent') {
|
||||
const timeA = getLastMessageTime('contact', a.public_key);
|
||||
const timeB = getLastMessageTime('contact', b.public_key);
|
||||
// If both have messages, sort by most recent first
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
// Items with messages come before items without
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
// Fall back to alpha for items without messages
|
||||
}
|
||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||
});
|
||||
|
||||
// Filter by search query
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
const filteredChannels = query
|
||||
? sortedChannels.filter((c) => c.name.toLowerCase().includes(query))
|
||||
: sortedChannels;
|
||||
const filteredContacts = query
|
||||
? sortedContacts.filter((c) =>
|
||||
(c.name?.toLowerCase().includes(query)) ||
|
||||
c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedContacts;
|
||||
|
||||
return (
|
||||
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-3 py-3 border-b border-border">
|
||||
<h2 className="text-xs uppercase text-muted-foreground font-medium">Conversations</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onNewMessage}
|
||||
title="New Message"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative px-3 py-2 border-b border-border">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 text-sm pr-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Raw Packet Feed */}
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
|
||||
isActive('raw', 'raw') && "bg-accent border-l-primary"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'raw',
|
||||
id: 'raw',
|
||||
name: 'Raw Packet Feed',
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">📡</span>
|
||||
<span className="flex-1 truncate">Packet Feed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channels */}
|
||||
{filteredChannels.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
||||
<span className="text-[11px] uppercase text-muted-foreground">Channels</span>
|
||||
<button
|
||||
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
|
||||
onClick={handleSortToggle}
|
||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
>
|
||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||||
</button>
|
||||
</div>
|
||||
{filteredChannels.map((channel) => {
|
||||
const unreadCount = getUnreadCount('channel', channel.key);
|
||||
return (
|
||||
<div
|
||||
key={`chan-${channel.key}`}
|
||||
className={cn(
|
||||
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
|
||||
isActive('channel', channel.key) && "bg-accent border-l-primary",
|
||||
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">#</span>
|
||||
<span className="name flex-1 truncate">{channel.name}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contacts */}
|
||||
{filteredContacts.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
||||
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
|
||||
{filteredChannels.length === 0 && (
|
||||
<button
|
||||
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
|
||||
onClick={handleSortToggle}
|
||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
>
|
||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredContacts.map((contact) => {
|
||||
const unreadCount = getUnreadCount('contact', contact.public_key);
|
||||
return (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className={cn(
|
||||
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
|
||||
isActive('contact', contact.public_key) && "bg-accent border-l-primary",
|
||||
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
})
|
||||
}
|
||||
>
|
||||
<ContactAvatar name={contact.name} publicKey={contact.public_key} size={24} contactType={contact.type} />
|
||||
<span className="name flex-1 truncate">
|
||||
{getContactDisplayName(contact.name, contact.public_key)}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredContacts.length === 0 && filteredChannels.length === 0 && (
|
||||
<div className="p-5 text-center text-muted-foreground">
|
||||
{query ? 'No matches found' : 'No conversations yet'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import type { HealthStatus, RadioConfig } from '../types';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
interface StatusBarProps {
|
||||
health: HealthStatus | null;
|
||||
config: RadioConfig | null;
|
||||
onConfigClick: () => void;
|
||||
onAdvertise: () => void;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export function StatusBar({ health, config, onConfigClick, onAdvertise, onMenuClick }: StatusBarProps) {
|
||||
const connected = health?.radio_connected ?? false;
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
setReconnecting(true);
|
||||
try {
|
||||
const result = await api.reconnectRadio();
|
||||
if (result.connected) {
|
||||
toast.success('Reconnected', { description: result.message });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Reconnection failed', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection and power',
|
||||
});
|
||||
} finally {
|
||||
setReconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-4 py-2 bg-[#252525] border-b border-[#333] text-xs">
|
||||
{/* Mobile menu button - only visible on small screens */}
|
||||
{onMenuClick && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-1 bg-transparent border-none text-[#e0e0e0] cursor-pointer"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h1 className="hidden lg:block text-base font-semibold mr-auto">RemoteTerm</h1>
|
||||
|
||||
<div className="flex items-center gap-1 text-[#888]">
|
||||
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-[#4caf50]' : 'bg-[#666]'}`} />
|
||||
<span className="hidden lg:inline text-[#e0e0e0]">{connected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
|
||||
{health?.serial_port && (
|
||||
<div className="hidden xl:flex items-center gap-1 text-[#888]">
|
||||
Port: <span className="text-[#e0e0e0]">{health.serial_port}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<>
|
||||
<div className="hidden lg:flex items-center gap-1 text-[#888]">
|
||||
Name: <span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1 text-[#888]">
|
||||
Freq: <span className="text-[#e0e0e0]">{config.radio.freq} MHz</span>
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1 text-[#888]">
|
||||
SF{config.radio.sf}/CR{config.radio.cr}
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1 text-[#888]">
|
||||
TX: <span className="text-[#e0e0e0]">{config.tx_power} dBm</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spacer to push buttons right on mobile */}
|
||||
<div className="flex-1 lg:hidden" />
|
||||
|
||||
{!connected && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={reconnecting}
|
||||
className="px-3 py-1 bg-[#4a3000] border border-[#6b4500] text-[#ffa500] rounded text-xs cursor-pointer hover:bg-[#5a3a00] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onAdvertise}
|
||||
disabled={!connected}
|
||||
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444] disabled:bg-[#333] disabled:text-[#666] disabled:cursor-not-allowed"
|
||||
>
|
||||
Advertise
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfigClick}
|
||||
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
|
||||
>
|
||||
Config
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
warning:
|
||||
"border-yellow-500/50 bg-yellow-500/10 text-yellow-200 [&>svg]:text-yellow-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
hideCloseButton?: boolean
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, hideCloseButton = false, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{!hideCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Toaster as Sonner, toast } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
// Muted error style - dark red-tinted background with readable text
|
||||
error: "group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#b08080]",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster, toast }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 10%;
|
||||
--foreground: 0 0% 88%;
|
||||
--card: 0 0% 14%;
|
||||
--card-foreground: 0 0% 88%;
|
||||
--popover: 0 0% 14%;
|
||||
--popover-foreground: 0 0% 88%;
|
||||
--primary: 122 39% 49%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 20%;
|
||||
--secondary-foreground: 0 0% 88%;
|
||||
--muted: 0 0% 20%;
|
||||
--muted-foreground: 0 0% 53%;
|
||||
--accent: 0 0% 20%;
|
||||
--accent-foreground: 0 0% 88%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 0 0% 20%;
|
||||
--input: 0 0% 20%;
|
||||
--ring: 122 39% 49%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Base styles - minimal CSS that Tailwind/shadcn can't handle */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Mobile sidebar override - ensures sidebar fills Sheet container */
|
||||
[data-state] .sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAvatarText, getAvatarColor, getContactAvatar, CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
|
||||
|
||||
describe('getAvatarText', () => {
|
||||
it('returns first emoji when name contains emoji', () => {
|
||||
expect(getAvatarText('John 🚀 Doe', 'abc123')).toBe('🚀');
|
||||
expect(getAvatarText('🎉 Party', 'abc123')).toBe('🎉');
|
||||
expect(getAvatarText('Test 😀 More 🎯', 'abc123')).toBe('😀');
|
||||
});
|
||||
|
||||
it('returns initials when name has space', () => {
|
||||
expect(getAvatarText('John Doe', 'abc123')).toBe('JD');
|
||||
expect(getAvatarText('Alice Bob Charlie', 'abc123')).toBe('AB');
|
||||
expect(getAvatarText('jane smith', 'abc123')).toBe('JS');
|
||||
});
|
||||
|
||||
it('returns single letter when no space', () => {
|
||||
expect(getAvatarText('John', 'abc123')).toBe('J');
|
||||
expect(getAvatarText('alice', 'abc123')).toBe('A');
|
||||
});
|
||||
|
||||
it('falls back to public key when name is null', () => {
|
||||
expect(getAvatarText(null, 'abc123def456')).toBe('AB');
|
||||
});
|
||||
|
||||
it('falls back to public key when name has no letters', () => {
|
||||
expect(getAvatarText('123 456', 'xyz789')).toBe('XY');
|
||||
expect(getAvatarText('---', 'def456')).toBe('DE');
|
||||
});
|
||||
|
||||
it('handles space but no letter after', () => {
|
||||
expect(getAvatarText('John ', 'abc123')).toBe('J');
|
||||
expect(getAvatarText('A 123', 'abc123')).toBe('A');
|
||||
});
|
||||
|
||||
it('emoji takes priority over initials', () => {
|
||||
expect(getAvatarText('John 🎯 Doe', 'abc123')).toBe('🎯');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvatarColor', () => {
|
||||
it('returns consistent colors for same public key', () => {
|
||||
const color1 = getAvatarColor('abc123def456');
|
||||
const color2 = getAvatarColor('abc123def456');
|
||||
expect(color1).toEqual(color2);
|
||||
});
|
||||
|
||||
it('returns different colors for different public keys', () => {
|
||||
const color1 = getAvatarColor('abc123def456');
|
||||
const color2 = getAvatarColor('xyz789uvw012');
|
||||
expect(color1.background).not.toBe(color2.background);
|
||||
});
|
||||
|
||||
it('returns valid HSL background color', () => {
|
||||
const color = getAvatarColor('test123');
|
||||
expect(color.background).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
|
||||
});
|
||||
|
||||
it('returns white or black text color', () => {
|
||||
const color = getAvatarColor('test123');
|
||||
expect(['#ffffff', '#000000']).toContain(color.text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactAvatar', () => {
|
||||
it('returns complete avatar info', () => {
|
||||
const avatar = getContactAvatar('John Doe', 'abc123def456');
|
||||
expect(avatar.text).toBe('JD');
|
||||
expect(avatar.background).toMatch(/^hsl\(/);
|
||||
expect(['#ffffff', '#000000']).toContain(avatar.textColor);
|
||||
});
|
||||
|
||||
it('handles null name', () => {
|
||||
const avatar = getContactAvatar(null, 'abc123def456');
|
||||
expect(avatar.text).toBe('AB');
|
||||
});
|
||||
|
||||
it('returns repeater avatar for type=2', () => {
|
||||
const avatar = getContactAvatar('Some Repeater', 'abc123def456', CONTACT_TYPE_REPEATER);
|
||||
expect(avatar.text).toBe('🛜');
|
||||
expect(avatar.background).toBe('#444444');
|
||||
expect(avatar.textColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('repeater avatar ignores name', () => {
|
||||
const avatar1 = getContactAvatar('🚀 Rocket', 'abc123', CONTACT_TYPE_REPEATER);
|
||||
const avatar2 = getContactAvatar(null, 'xyz789', CONTACT_TYPE_REPEATER);
|
||||
expect(avatar1.text).toBe('🛜');
|
||||
expect(avatar2.text).toBe('🛜');
|
||||
expect(avatar1.background).toBe(avatar2.background);
|
||||
});
|
||||
|
||||
it('non-repeater types use normal avatar', () => {
|
||||
const avatar0 = getContactAvatar('John', 'abc123', 0);
|
||||
const avatar1 = getContactAvatar('John', 'abc123', 1);
|
||||
expect(avatar0.text).toBe('J');
|
||||
expect(avatar1.text).toBe('J');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for message deduplication in MessageList.
|
||||
*
|
||||
* Messages arriving via different packet paths should be deduplicated
|
||||
* based on (type, conversation_key, text, sender_timestamp).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Message } from '../types';
|
||||
|
||||
/**
|
||||
* Deduplication logic extracted from MessageList for testing.
|
||||
* Same message via different paths = same (type, conversation_key, text, timestamp)
|
||||
*/
|
||||
function deduplicateMessages(messages: Message[]): Message[] {
|
||||
return messages.reduce<Message[]>((acc, msg) => {
|
||||
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
|
||||
const existing = acc.find(m =>
|
||||
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
|
||||
);
|
||||
if (!existing) {
|
||||
acc.push(msg);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function createMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
|
||||
text: 'Test message',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
path_len: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Message Deduplication', () => {
|
||||
it('keeps unique messages', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, text: 'Message 1', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, text: 'Message 2', sender_timestamp: 2000 }),
|
||||
createMessage({ id: 3, text: 'Message 3', sender_timestamp: 3000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('deduplicates same channel message via different paths', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
|
||||
createMessage({ id: 3, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1); // keeps first occurrence
|
||||
});
|
||||
|
||||
it('keeps messages with same text but different timestamps', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, text: 'Hello', sender_timestamp: 2000 }), // different timestamp
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps messages with same text but different channels', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, conversation_key: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', text: 'Hello', sender_timestamp: 1000 }), // different channel
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deduplicates same DM via different paths', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }), // duplicate
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps DMs from different senders with same text', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'def456789012345678901234567890123456789012345678901234567890', text: 'Hi', sender_timestamp: 1000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps channel message and DM with same text', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hello', sender_timestamp: 1000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = deduplicateMessages([]);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles single message', () => {
|
||||
const messages = [createMessage({ id: 1 })];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for message parsing utilities.
|
||||
*
|
||||
* These tests verify the sender extraction logic used to parse
|
||||
* channel messages in "sender: message" format.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSenderFromText, formatTime } from '../utils/messageParser';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
describe('parseSenderFromText', () => {
|
||||
it('extracts sender and content from "sender: message" format', () => {
|
||||
const result = parseSenderFromText('Alice: Hello everyone!');
|
||||
|
||||
expect(result.sender).toBe('Alice');
|
||||
expect(result.content).toBe('Hello everyone!');
|
||||
});
|
||||
|
||||
it('handles sender names with spaces', () => {
|
||||
const result = parseSenderFromText('Bob Smith: How are you?');
|
||||
|
||||
expect(result.sender).toBe('Bob Smith');
|
||||
expect(result.content).toBe('How are you?');
|
||||
});
|
||||
|
||||
it('returns null sender for plain messages without colon-space', () => {
|
||||
const result = parseSenderFromText('Just a plain message');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('Just a plain message');
|
||||
});
|
||||
|
||||
it('returns null sender when colon has no space after', () => {
|
||||
const result = parseSenderFromText('Note:this is not a sender');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
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');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('12:30: Time announcement');
|
||||
});
|
||||
|
||||
it('rejects sender names longer than 50 characters', () => {
|
||||
const longName = 'A'.repeat(60);
|
||||
const result = parseSenderFromText(`${longName}: message`);
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const result = parseSenderFromText('');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('');
|
||||
});
|
||||
|
||||
it('handles message with multiple colons', () => {
|
||||
const result = parseSenderFromText('User: Check this URL: https://example.com');
|
||||
|
||||
expect(result.sender).toBe('User');
|
||||
expect(result.content).toBe('Check this URL: https://example.com');
|
||||
});
|
||||
|
||||
it('handles colon at start of message', () => {
|
||||
const result = parseSenderFromText(': no sender here');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe(': no sender here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats today timestamp as time only', () => {
|
||||
// Use current time to ensure it's "today"
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const result = formatTime(now);
|
||||
|
||||
// Should be just time (HH:MM format)
|
||||
expect(result).toMatch(/^\d{1,2}:\d{2}( [AP]M)?$/);
|
||||
});
|
||||
|
||||
it('formats older timestamp with date and time', () => {
|
||||
// Use a timestamp from 2023 (definitely not today)
|
||||
const timestamp = 1700000000; // 2023-11-14
|
||||
|
||||
const result = formatTime(timestamp);
|
||||
|
||||
// Should contain month, day, and time
|
||||
expect(result).toMatch(/\w+ \d{1,2}/); // e.g., "Nov 14"
|
||||
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStateKey', () => {
|
||||
it('creates channel state key with full id', () => {
|
||||
const key = getStateKey('channel', '5');
|
||||
|
||||
expect(key).toBe('channel-5');
|
||||
});
|
||||
|
||||
it('creates contact state key with 12-char prefix', () => {
|
||||
const fullKey = 'abcdef123456789012345678901234567890';
|
||||
const key = getStateKey('contact', fullKey);
|
||||
|
||||
expect(key).toBe('contact-abcdef123456');
|
||||
});
|
||||
|
||||
it('handles contact key shorter than 12 chars', () => {
|
||||
const shortKey = 'abc123';
|
||||
const key = getStateKey('contact', shortKey);
|
||||
|
||||
expect(key).toBe('contact-abc123');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for unread count tracking logic.
|
||||
*
|
||||
* These tests verify the unread message counting behavior
|
||||
* without involving React component rendering.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Message, Conversation } from '../types';
|
||||
import { getPubkeyPrefix, pubkeysMatch } from '../utils/pubkey';
|
||||
|
||||
/**
|
||||
* Determine if a message should increment unread count.
|
||||
* Extracted logic from App.tsx for testing.
|
||||
*/
|
||||
function shouldIncrementUnread(
|
||||
msg: Message,
|
||||
activeConversation: Conversation | null
|
||||
): { key: string } | null {
|
||||
// Only count incoming messages
|
||||
if (msg.outgoing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||
const key = `channel-${msg.conversation_key}`;
|
||||
// Don't count if this channel is active
|
||||
if (activeConversation?.type === 'channel' && activeConversation?.id === msg.conversation_key) {
|
||||
return null;
|
||||
}
|
||||
return { key };
|
||||
}
|
||||
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// Use 12-char prefix for contact key
|
||||
const key = `contact-${getPubkeyPrefix(msg.conversation_key)}`;
|
||||
// Don't count if this contact is active (compare by prefix)
|
||||
if (activeConversation?.type === 'contact' && pubkeysMatch(activeConversation.id, msg.conversation_key)) {
|
||||
return null;
|
||||
}
|
||||
return { key };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a conversation from the counts map.
|
||||
* Extracted logic from Sidebar.tsx for testing.
|
||||
*/
|
||||
function getUnreadCount(
|
||||
type: 'channel' | 'contact',
|
||||
id: string,
|
||||
unreadCounts: Record<string, number>
|
||||
): number {
|
||||
if (type === 'channel') {
|
||||
return unreadCounts[`channel-${id}`] || 0;
|
||||
}
|
||||
// For contacts, use prefix
|
||||
const prefix = `contact-${getPubkeyPrefix(id)}`;
|
||||
return unreadCounts[prefix] || 0;
|
||||
}
|
||||
|
||||
describe('shouldIncrementUnread', () => {
|
||||
const createMessage = (overrides: Partial<Message>): Message => ({
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
|
||||
text: 'Test',
|
||||
sender_timestamp: null,
|
||||
received_at: Date.now(),
|
||||
path_len: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns key for incoming channel message when not viewing that channel', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
|
||||
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', name: 'other' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
|
||||
});
|
||||
|
||||
it('returns null for incoming channel message when viewing that channel', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
|
||||
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', name: '#test' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for outgoing messages', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', outgoing: true });
|
||||
|
||||
const result = shouldIncrementUnread(msg, null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns key for incoming direct message when not viewing that contact', () => {
|
||||
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
|
||||
const activeConversation: Conversation = { type: 'contact', id: 'xyz999999999012345678901234567890123456789012345678901234567', name: 'other' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'contact-abc123456789' });
|
||||
});
|
||||
|
||||
it('returns null for incoming direct message when viewing that contact', () => {
|
||||
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
|
||||
const activeConversation: Conversation = {
|
||||
type: 'contact',
|
||||
id: 'abc123456789fullkey12345678901234567890123456789012345678',
|
||||
name: 'Alice',
|
||||
};
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns key when no conversation is active', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
|
||||
|
||||
const result = shouldIncrementUnread(msg, null);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
|
||||
});
|
||||
|
||||
it('returns key when viewing raw packet feed', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
|
||||
const activeConversation: Conversation = { type: 'raw', id: 'raw', name: 'Packets' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('returns count for channel by exact key match', () => {
|
||||
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
|
||||
|
||||
expect(getUnreadCount('channel', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', counts)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 0 for channel with no unread', () => {
|
||||
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
|
||||
|
||||
expect(getUnreadCount('channel', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB9', counts)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns count for contact using 12-char prefix', () => {
|
||||
const counts = { 'contact-abc123456789': 5 };
|
||||
|
||||
// Full public key lookup should match the prefix
|
||||
expect(getUnreadCount('contact', 'abc123456789fullpublickey123456789012345678901234', counts)).toBe(5);
|
||||
});
|
||||
|
||||
it('handles contact key shorter than 12 chars', () => {
|
||||
const counts = { 'contact-short': 2 };
|
||||
|
||||
expect(getUnreadCount('contact', 'short', counts)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 for contact with no unread', () => {
|
||||
const counts = { 'contact-abc123456789': 5 };
|
||||
|
||||
expect(getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Tests for WebSocket message parsing.
|
||||
*
|
||||
* These tests verify that WebSocket messages are correctly parsed
|
||||
* and routed to the appropriate handlers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { HealthStatus, Contact, Channel, Message, RawPacket } from '../types';
|
||||
|
||||
/**
|
||||
* Parse and route a WebSocket message.
|
||||
* Extracted logic from useWebSocket.ts for testing.
|
||||
*/
|
||||
function parseWebSocketMessage(
|
||||
data: string,
|
||||
handlers: {
|
||||
onHealth?: (health: HealthStatus) => void;
|
||||
onContacts?: (contacts: Contact[]) => void;
|
||||
onChannels?: (channels: Channel[]) => void;
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number) => void;
|
||||
}
|
||||
): { type: string; handled: boolean } {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'health':
|
||||
handlers.onHealth?.(msg.data as HealthStatus);
|
||||
return { type: msg.type, handled: !!handlers.onHealth };
|
||||
case 'contacts':
|
||||
handlers.onContacts?.(msg.data as Contact[]);
|
||||
return { type: msg.type, handled: !!handlers.onContacts };
|
||||
case 'channels':
|
||||
handlers.onChannels?.(msg.data as Channel[]);
|
||||
return { type: msg.type, handled: !!handlers.onChannels };
|
||||
case 'message':
|
||||
handlers.onMessage?.(msg.data as Message);
|
||||
return { type: msg.type, handled: !!handlers.onMessage };
|
||||
case 'contact':
|
||||
handlers.onContact?.(msg.data as Contact);
|
||||
return { type: msg.type, handled: !!handlers.onContact };
|
||||
case 'raw_packet':
|
||||
handlers.onRawPacket?.(msg.data as RawPacket);
|
||||
return { type: msg.type, handled: !!handlers.onRawPacket };
|
||||
case 'message_acked':
|
||||
handlers.onMessageAcked?.((msg.data as { message_id: number }).message_id);
|
||||
return { type: msg.type, handled: !!handlers.onMessageAcked };
|
||||
case 'pong':
|
||||
return { type: msg.type, handled: true };
|
||||
default:
|
||||
return { type: msg.type, handled: false };
|
||||
}
|
||||
} catch {
|
||||
return { type: 'error', handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseWebSocketMessage', () => {
|
||||
it('routes health message to onHealth handler', () => {
|
||||
const onHealth = vi.fn();
|
||||
const data = JSON.stringify({
|
||||
type: 'health',
|
||||
data: { radio_connected: true, serial_port: '/dev/ttyUSB0' },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, { onHealth });
|
||||
|
||||
expect(result.type).toBe('health');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onHealth).toHaveBeenCalledWith({
|
||||
radio_connected: true,
|
||||
serial_port: '/dev/ttyUSB0',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes message_acked to onMessageAcked with message ID', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
const data = JSON.stringify({
|
||||
type: 'message_acked',
|
||||
data: { message_id: 42 },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, { onMessageAcked });
|
||||
|
||||
expect(result.type).toBe('message_acked');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('routes new message to onMessage handler', () => {
|
||||
const onMessage = vi.fn();
|
||||
const messageData = {
|
||||
id: 123,
|
||||
type: 'CHAN',
|
||||
channel_idx: 0,
|
||||
text: 'Hello',
|
||||
received_at: 1700000000,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
};
|
||||
const data = JSON.stringify({ type: 'message', data: messageData });
|
||||
|
||||
const result = parseWebSocketMessage(data, { onMessage });
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onMessage).toHaveBeenCalledWith(messageData);
|
||||
});
|
||||
|
||||
it('handles pong messages silently', () => {
|
||||
const data = JSON.stringify({ type: 'pong' });
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('pong');
|
||||
expect(result.handled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unhandled for unknown message types', () => {
|
||||
const data = JSON.stringify({ type: 'unknown_type', data: {} });
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('unknown_type');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully', () => {
|
||||
const data = 'not valid json {';
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('error');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call handler when not provided', () => {
|
||||
const data = JSON.stringify({
|
||||
type: 'health',
|
||||
data: { radio_connected: true },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('health');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('routes raw_packet to onRawPacket handler', () => {
|
||||
const onRawPacket = vi.fn();
|
||||
const packetData = {
|
||||
id: 1,
|
||||
timestamp: 1700000000,
|
||||
data: 'deadbeef',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
decrypted: true,
|
||||
decrypted_info: { channel_name: '#test', sender: 'Alice' },
|
||||
};
|
||||
const data = JSON.stringify({ type: 'raw_packet', data: packetData });
|
||||
|
||||
const result = parseWebSocketMessage(data, { onRawPacket });
|
||||
|
||||
expect(result.type).toBe('raw_packet');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onRawPacket).toHaveBeenCalledWith(packetData);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Type aliases for key types used throughout the application.
|
||||
* These are all hex strings but serve different purposes.
|
||||
*/
|
||||
|
||||
/** 64-character hex string identifying a contact/node */
|
||||
export type PublicKey = string;
|
||||
|
||||
/** 12-character hex prefix of a public key (used in message routing) */
|
||||
export type PubkeyPrefix = string;
|
||||
|
||||
/** 32-character hex string identifying a channel */
|
||||
export type ChannelKey = string;
|
||||
|
||||
export interface RadioSettings {
|
||||
freq: number;
|
||||
bw: number;
|
||||
sf: number;
|
||||
cr: number;
|
||||
}
|
||||
|
||||
export interface RadioConfig {
|
||||
public_key: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
tx_power: number;
|
||||
max_tx_power: number;
|
||||
radio: RadioSettings;
|
||||
}
|
||||
|
||||
export interface RadioConfigUpdate {
|
||||
name?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
tx_power?: number;
|
||||
radio?: RadioSettings;
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
status: string;
|
||||
radio_connected: boolean;
|
||||
serial_port: string | null;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
public_key: PublicKey;
|
||||
name: string | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
last_advert: number | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
last_seen: number | null;
|
||||
on_radio: boolean;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
key: ChannelKey;
|
||||
name: string;
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
type: 'PRIV' | 'CHAN';
|
||||
/** For PRIV: sender's PublicKey (or prefix). For CHAN: ChannelKey */
|
||||
conversation_key: string;
|
||||
text: string;
|
||||
sender_timestamp: number | null;
|
||||
received_at: number;
|
||||
path_len: number | null;
|
||||
txt_type: number;
|
||||
signature: string | null;
|
||||
outgoing: boolean;
|
||||
acked: boolean;
|
||||
}
|
||||
|
||||
export type ConversationType = 'contact' | 'channel' | 'raw';
|
||||
|
||||
export interface Conversation {
|
||||
type: ConversationType;
|
||||
/** PublicKey for contacts, ChannelKey for channels, 'raw' for raw feed */
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RawPacket {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
data: string; // hex
|
||||
payload_type: string;
|
||||
snr: number | null; // Signal-to-noise ratio in dB
|
||||
rssi: number | null; // Received signal strength in dBm
|
||||
decrypted: boolean;
|
||||
decrypted_info: {
|
||||
channel_name: string | null;
|
||||
sender: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
max_radio_contacts?: number;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type { HealthStatus, Contact, Channel, Message, RawPacket } from './types';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface ErrorEvent {
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
onHealth?: (health: HealthStatus) => void;
|
||||
onContacts?: (contacts: Contact[]) => void;
|
||||
onChannels?: (channels: Channel[]) => void;
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number) => void;
|
||||
onError?: (error: ErrorEvent) => void;
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// Determine WebSocket URL based on current location
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// In development, connect directly to backend; in production, use same host
|
||||
const isDev = window.location.port === '5173';
|
||||
const wsUrl = isDev
|
||||
? `ws://localhost:8000/api/ws`
|
||||
: `${protocol}//${window.location.host}/api/ws`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
// Reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
console.log('Attempting WebSocket reconnect...');
|
||||
connect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: WebSocketMessage = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'health':
|
||||
options.onHealth?.(msg.data as HealthStatus);
|
||||
break;
|
||||
case 'contacts':
|
||||
options.onContacts?.(msg.data as Contact[]);
|
||||
break;
|
||||
case 'channels':
|
||||
options.onChannels?.(msg.data as Channel[]);
|
||||
break;
|
||||
case 'message':
|
||||
options.onMessage?.(msg.data as Message);
|
||||
break;
|
||||
case 'contact':
|
||||
options.onContact?.(msg.data as Contact);
|
||||
break;
|
||||
case 'raw_packet':
|
||||
options.onRawPacket?.(msg.data as RawPacket);
|
||||
break;
|
||||
case 'message_acked':
|
||||
options.onMessageAcked?.((msg.data as { message_id: number }).message_id);
|
||||
break;
|
||||
case 'error':
|
||||
options.onError?.(msg.data as ErrorEvent);
|
||||
break;
|
||||
case 'pong':
|
||||
// Heartbeat response, ignore
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', msg.type);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
// Ping every 30 seconds to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send('ping');
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pingInterval);
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Generate consistent profile "images" for contacts.
|
||||
*
|
||||
* Uses the contact's public key to generate a consistent background color,
|
||||
* and extracts initials or emoji from the name for display.
|
||||
* Repeaters (type=2) always show 🛜 with a gray background.
|
||||
*/
|
||||
|
||||
// Contact type constants (matches backend)
|
||||
export const CONTACT_TYPE_REPEATER = 2;
|
||||
|
||||
// Repeater avatar styling
|
||||
const REPEATER_AVATAR = {
|
||||
text: '🛜',
|
||||
background: '#444444',
|
||||
textColor: '#ffffff',
|
||||
};
|
||||
|
||||
// Simple hash function for strings
|
||||
function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
// Regex to match emoji (covers most common emoji ranges)
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
|
||||
|
||||
/**
|
||||
* Extract display characters from a contact name.
|
||||
* Priority:
|
||||
* 1. First emoji in the name
|
||||
* 2. First letter + first letter after first space (initials)
|
||||
* 3. First letter only
|
||||
*/
|
||||
export function getAvatarText(name: string | null, publicKey: string): string {
|
||||
if (!name) {
|
||||
// Use first 2 chars of public key as fallback
|
||||
return publicKey.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// Check for emoji first
|
||||
const emojiMatch = name.match(emojiRegex);
|
||||
if (emojiMatch) {
|
||||
return emojiMatch[0];
|
||||
}
|
||||
|
||||
// Find first letter
|
||||
const letters = name.match(/[a-zA-Z]/g);
|
||||
if (!letters || letters.length === 0) {
|
||||
// No letters, use first 2 chars of public key
|
||||
return publicKey.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// Check for space - get initials
|
||||
const spaceIndex = name.indexOf(' ');
|
||||
if (spaceIndex !== -1) {
|
||||
const firstLetter = letters[0];
|
||||
// Find first letter after the space
|
||||
const afterSpace = name.slice(spaceIndex + 1).match(/[a-zA-Z]/);
|
||||
if (afterSpace) {
|
||||
return (firstLetter + afterSpace[0]).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Single letter
|
||||
return letters[0].toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a consistent HSL color from a public key.
|
||||
* Uses saturation and lightness ranges that work well for backgrounds.
|
||||
*/
|
||||
export function getAvatarColor(publicKey: string): {
|
||||
background: string;
|
||||
text: string;
|
||||
} {
|
||||
const hash = hashString(publicKey);
|
||||
|
||||
// Use hash to generate hue (0-360)
|
||||
const hue = hash % 360;
|
||||
|
||||
// Use different bits of hash for saturation variation (50-80%)
|
||||
const saturation = 50 + ((hash >> 8) % 30);
|
||||
|
||||
// Lightness in a range that allows readable text (35-55%)
|
||||
const lightness = 35 + ((hash >> 16) % 20);
|
||||
|
||||
const background = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
|
||||
// Calculate perceived luminance to determine text color
|
||||
// For HSL, we can approximate: if lightness < 50%, use white text
|
||||
// We'll use a slightly lower threshold since saturated colors appear darker
|
||||
const textColor = lightness < 45 ? '#ffffff' : '#000000';
|
||||
|
||||
return { background, text: textColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all avatar properties for a contact.
|
||||
* Repeaters (type=2) always get a special gray avatar with 🛜.
|
||||
*/
|
||||
export function getContactAvatar(
|
||||
name: string | null,
|
||||
publicKey: string,
|
||||
contactType?: number
|
||||
): {
|
||||
text: string;
|
||||
background: string;
|
||||
textColor: string;
|
||||
} {
|
||||
// Repeaters always get the repeater avatar
|
||||
if (contactType === CONTACT_TYPE_REPEATER) {
|
||||
return REPEATER_AVATAR;
|
||||
}
|
||||
|
||||
const text = getAvatarText(name, publicKey);
|
||||
const colors = getAvatarColor(publicKey);
|
||||
|
||||
return {
|
||||
text,
|
||||
background: colors.background,
|
||||
textColor: colors.text,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* localStorage utilities for tracking conversation read/message state.
|
||||
*
|
||||
* Stores two maps:
|
||||
* - lastMessageTime: when each conversation last received a message
|
||||
* - lastReadTime: when the user last viewed each conversation
|
||||
*
|
||||
* A conversation has unread messages if lastMessageTime > lastReadTime.
|
||||
*/
|
||||
|
||||
import { getPubkeyPrefix } from './pubkey';
|
||||
|
||||
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
|
||||
const LAST_READ_KEY = 'remoteterm-lastReadTime';
|
||||
|
||||
export type ConversationTimes = Record<string, number>;
|
||||
|
||||
function loadTimes(key: string): ConversationTimes {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveTimes(key: string, times: ConversationTimes): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(times));
|
||||
} catch {
|
||||
// localStorage might be full or disabled
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastMessageTimes(): ConversationTimes {
|
||||
return loadTimes(LAST_MESSAGE_KEY);
|
||||
}
|
||||
|
||||
export function getLastReadTimes(): ConversationTimes {
|
||||
return loadTimes(LAST_READ_KEY);
|
||||
}
|
||||
|
||||
export function setLastMessageTime(stateKey: string, timestamp: number): ConversationTimes {
|
||||
const times = loadTimes(LAST_MESSAGE_KEY);
|
||||
// Only update if this is a newer message
|
||||
if (!times[stateKey] || timestamp > times[stateKey]) {
|
||||
times[stateKey] = timestamp;
|
||||
saveTimes(LAST_MESSAGE_KEY, times);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
export function setLastReadTime(stateKey: string, timestamp: number): ConversationTimes {
|
||||
const times = loadTimes(LAST_READ_KEY);
|
||||
times[stateKey] = timestamp;
|
||||
saveTimes(LAST_READ_KEY, times);
|
||||
return times;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a state tracking key for unread counts and message times.
|
||||
*
|
||||
* This is NOT the same as Message.conversation_key (the database field).
|
||||
* This creates prefixed keys for localStorage/state tracking:
|
||||
* - Channels: "channel-{channelKey}"
|
||||
* - Contacts: "contact-{12-char-pubkey-prefix}"
|
||||
*
|
||||
* The 12-char prefix for contacts ensures consistent matching regardless
|
||||
* of whether we have a full 64-char pubkey or just a prefix.
|
||||
*/
|
||||
export function getStateKey(
|
||||
type: 'channel' | 'contact',
|
||||
id: string
|
||||
): string {
|
||||
if (type === 'channel') {
|
||||
return `channel-${id}`;
|
||||
}
|
||||
// For contacts, use 12-char prefix for consistent matching
|
||||
return `contact-${getPubkeyPrefix(id)}`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Parse sender from channel message text.
|
||||
* Channel messages have format "sender: message".
|
||||
*/
|
||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||
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)) {
|
||||
return {
|
||||
sender: potentialSender,
|
||||
content: text.substring(colonIndex + 2),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { sender: null, content: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp to a time string.
|
||||
* Shows date for messages not from today.
|
||||
*/
|
||||
export function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
if (isToday) {
|
||||
return time;
|
||||
}
|
||||
|
||||
// Show short date for older messages
|
||||
const dateStr = date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return `${dateStr} ${time}`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Public key utilities for consistent handling of 64-char full keys
|
||||
* and 12-char prefixes throughout the application.
|
||||
*
|
||||
* MeshCore uses 64-character hex strings for public keys, but messages
|
||||
* and some radio operations only provide 12-character prefixes. This
|
||||
* module provides utilities for working with both formats consistently.
|
||||
*/
|
||||
|
||||
/** Length of a full public key in hex characters */
|
||||
export const PUBKEY_FULL_LENGTH = 64;
|
||||
|
||||
/** Length of a public key prefix in hex characters */
|
||||
export const PUBKEY_PREFIX_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* Extract the 12-character prefix from a public key.
|
||||
* Works with both full keys and existing prefixes.
|
||||
*/
|
||||
export function getPubkeyPrefix(key: string): string {
|
||||
return key.slice(0, PUBKEY_PREFIX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two public keys match by comparing their prefixes.
|
||||
* This handles the case where one key is full (64 chars) and
|
||||
* the other is a prefix (12 chars).
|
||||
*/
|
||||
export function pubkeysMatch(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
return getPubkeyPrefix(a) === getPubkeyPrefix(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a public key starts with the given prefix.
|
||||
* More explicit than using .startsWith() directly.
|
||||
*/
|
||||
export function pubkeyMatchesPrefix(fullKey: string, prefix: string): boolean {
|
||||
if (!fullKey || !prefix) return false;
|
||||
return fullKey.startsWith(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a display name for a contact, falling back to pubkey prefix.
|
||||
*/
|
||||
export function getContactDisplayName(name: string | null | undefined, pubkey: string): string {
|
||||
return name || getPubkeyPrefix(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is a full 64-character public key.
|
||||
*/
|
||||
export function isFullPubkey(key: string): boolean {
|
||||
return key.length === PUBKEY_FULL_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is a 12-character prefix.
|
||||
*/
|
||||
export function isPubkeyPrefix(key: string): boolean {
|
||||
return key.length === PUBKEY_PREFIX_LENGTH;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import path from "path"
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "0.1.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
"pynacl>=1.5.0",
|
||||
"meshcore",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Pytest configuration and shared fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_channel_key():
|
||||
"""A sample 16-byte channel key for testing."""
|
||||
return bytes.fromhex("0123456789abcdef0123456789abcdef")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_hashtag_key():
|
||||
"""A channel key derived from hashtag name '#test'."""
|
||||
import hashlib
|
||||
return hashlib.sha256(b"#test").digest()[:16]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for API endpoints.
|
||||
|
||||
These tests verify the REST API behavior for critical operations.
|
||||
Uses FastAPI's TestClient for synchronous testing.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Test the health check endpoint."""
|
||||
|
||||
def test_health_returns_connection_status(self):
|
||||
"""Health endpoint returns radio connection status."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.routers.health.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.port = "/dev/ttyUSB0"
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["radio_connected"] is True
|
||||
assert data["serial_port"] == "/dev/ttyUSB0"
|
||||
|
||||
def test_health_disconnected_state(self):
|
||||
"""Health endpoint reflects disconnected radio."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.routers.health.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.port = None
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["radio_connected"] is False
|
||||
assert data["serial_port"] is None
|
||||
|
||||
|
||||
class TestMessagesEndpoint:
|
||||
"""Test message-related endpoints."""
|
||||
|
||||
def test_send_direct_message_requires_connection(self):
|
||||
"""Sending message when disconnected returns 503."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.meshcore = None
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/messages/direct",
|
||||
json={"destination": "abc123", "text": "Hello"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "not connected" in response.json()["detail"].lower()
|
||||
|
||||
def test_send_channel_message_requires_connection(self):
|
||||
"""Sending channel message when disconnected returns 503."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.meshcore = None
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/messages/channel",
|
||||
json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
def test_send_direct_message_contact_not_found(self):
|
||||
"""Sending to unknown contact returns 404."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix.return_value = None
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm, \
|
||||
patch("app.repository.ContactRepository.get_by_key_or_prefix", new_callable=AsyncMock) as mock_get:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
mock_get.return_value = None
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/messages/direct",
|
||||
json={"destination": "nonexistent", "text": "Hello"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestChannelsEndpoint:
|
||||
"""Test channel-related endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_hashtag_channel_derives_key(self):
|
||||
"""Creating hashtag channel derives key from name and stores in DB."""
|
||||
import hashlib
|
||||
from app.routers.channels import create_channel, CreateChannelRequest
|
||||
|
||||
with patch("app.routers.channels.ChannelRepository") as mock_repo:
|
||||
mock_repo.upsert = AsyncMock()
|
||||
|
||||
request = CreateChannelRequest(name="#mychannel")
|
||||
|
||||
result = await create_channel(request)
|
||||
|
||||
# Verify the key derivation - channel stored in DB, not pushed to radio
|
||||
expected_key_hex = hashlib.sha256(b"#mychannel").digest()[:16].hex().upper()
|
||||
mock_repo.upsert.assert_called_once()
|
||||
call_args = mock_repo.upsert.call_args
|
||||
assert call_args[1]["key"] == expected_key_hex
|
||||
assert call_args[1]["name"] == "#mychannel"
|
||||
assert call_args[1]["is_hashtag"] is True
|
||||
assert call_args[1]["on_radio"] is False # Not pushed to radio on create
|
||||
|
||||
# Verify response
|
||||
assert result.key == expected_key_hex
|
||||
assert result.name == "#mychannel"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_channel_with_explicit_key(self):
|
||||
"""Creating channel with explicit key uses provided key."""
|
||||
from app.routers.channels import create_channel, CreateChannelRequest
|
||||
|
||||
with patch("app.routers.channels.ChannelRepository") as mock_repo:
|
||||
mock_repo.upsert = AsyncMock()
|
||||
|
||||
explicit_key = "0123456789abcdef0123456789abcdef" # 32 hex chars = 16 bytes
|
||||
request = CreateChannelRequest(name="private", key=explicit_key)
|
||||
|
||||
result = await create_channel(request)
|
||||
|
||||
# Verify key stored in DB correctly (stored as uppercase hex)
|
||||
mock_repo.upsert.assert_called_once()
|
||||
call_args = mock_repo.upsert.call_args
|
||||
assert call_args[1]["key"] == explicit_key.upper()
|
||||
assert call_args[1]["name"] == "private"
|
||||
assert call_args[1]["on_radio"] is False
|
||||
|
||||
# Verify response
|
||||
assert result.key == explicit_key.upper()
|
||||
|
||||
|
||||
class TestPacketsEndpoint:
|
||||
"""Test packet decryption endpoints."""
|
||||
|
||||
def test_get_undecrypted_count(self):
|
||||
"""Get undecrypted packet count returns correct value."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.routers.packets.RawPacketRepository") as mock_repo:
|
||||
mock_repo.get_undecrypted_count = AsyncMock(return_value=42)
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/packets/undecrypted/count")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 42
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Tests for the packet decoder module.
|
||||
|
||||
These tests verify the cryptographic operations for MeshCore packet decryption,
|
||||
which is critical for correctly interpreting mesh network messages.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
import pytest
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from app.decoder import (
|
||||
DecryptedGroupText,
|
||||
PacketInfo,
|
||||
PayloadType,
|
||||
RouteType,
|
||||
calculate_channel_hash,
|
||||
decrypt_group_text,
|
||||
parse_packet,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
)
|
||||
|
||||
|
||||
class TestChannelKeyDerivation:
|
||||
"""Test channel key derivation from hashtag names."""
|
||||
|
||||
def test_hashtag_key_derivation(self):
|
||||
"""Hashtag channel keys are derived as SHA256(name)[:16]."""
|
||||
channel_name = "#test"
|
||||
expected_key = hashlib.sha256(channel_name.encode("utf-8")).digest()[:16]
|
||||
|
||||
# This matches the meshcore_py implementation
|
||||
assert len(expected_key) == 16
|
||||
|
||||
def test_channel_hash_calculation(self):
|
||||
"""Channel hash is the first byte of SHA256(key) as hex."""
|
||||
key = bytes(16) # All zeros
|
||||
expected_hash = format(hashlib.sha256(key).digest()[0], "02x")
|
||||
|
||||
result = calculate_channel_hash(key)
|
||||
|
||||
assert result == expected_hash
|
||||
assert len(result) == 2 # Two hex chars
|
||||
|
||||
|
||||
class TestPacketParsing:
|
||||
"""Test raw packet header parsing."""
|
||||
|
||||
def test_parse_flood_packet(self):
|
||||
"""Parse a FLOOD route type GROUP_TEXT packet."""
|
||||
# Header: route_type=FLOOD(1), payload_type=GROUP_TEXT(5), version=0
|
||||
# Header byte = (0 << 6) | (5 << 2) | 1 = 0x15
|
||||
# Path length = 0
|
||||
header = bytes([0x15, 0x00]) + b"payload_data"
|
||||
|
||||
result = parse_packet(header)
|
||||
|
||||
assert result is not None
|
||||
assert result.route_type == RouteType.FLOOD
|
||||
assert result.payload_type == PayloadType.GROUP_TEXT
|
||||
assert result.path_length == 0
|
||||
assert result.payload == b"payload_data"
|
||||
|
||||
def test_parse_direct_packet_with_path(self):
|
||||
"""Parse a DIRECT route type packet with path data."""
|
||||
# Header: route_type=DIRECT(2), payload_type=TEXT_MESSAGE(2), version=0
|
||||
# Header byte = (0 << 6) | (2 << 2) | 2 = 0x0A
|
||||
# Path length = 3, path = [0x01, 0x02, 0x03]
|
||||
header = bytes([0x0A, 0x03, 0x01, 0x02, 0x03]) + b"msg"
|
||||
|
||||
result = parse_packet(header)
|
||||
|
||||
assert result is not None
|
||||
assert result.route_type == RouteType.DIRECT
|
||||
assert result.payload_type == PayloadType.TEXT_MESSAGE
|
||||
assert result.path_length == 3
|
||||
assert result.payload == b"msg"
|
||||
|
||||
def test_parse_transport_flood_skips_transport_code(self):
|
||||
"""TRANSPORT_FLOOD packets have 4-byte transport code to skip."""
|
||||
# Header: route_type=TRANSPORT_FLOOD(0), payload_type=GROUP_TEXT(5)
|
||||
# Header byte = (0 << 6) | (5 << 2) | 0 = 0x14
|
||||
# Transport code (4 bytes) + path_length + payload
|
||||
header = bytes([0x14, 0xAA, 0xBB, 0xCC, 0xDD, 0x00]) + b"data"
|
||||
|
||||
result = parse_packet(header)
|
||||
|
||||
assert result is not None
|
||||
assert result.route_type == RouteType.TRANSPORT_FLOOD
|
||||
assert result.payload_type == PayloadType.GROUP_TEXT
|
||||
assert result.payload == b"data"
|
||||
|
||||
def test_parse_empty_packet_returns_none(self):
|
||||
"""Empty packets return None."""
|
||||
assert parse_packet(b"") is None
|
||||
assert parse_packet(b"\x00") is None
|
||||
|
||||
def test_parse_truncated_packet_returns_none(self):
|
||||
"""Truncated packets return None."""
|
||||
# Packet claiming path_length=10 but no path data
|
||||
header = bytes([0x15, 0x0A])
|
||||
|
||||
assert parse_packet(header) is None
|
||||
|
||||
|
||||
class TestGroupTextDecryption:
|
||||
"""Test GROUP_TEXT (channel message) decryption."""
|
||||
|
||||
def _create_encrypted_payload(
|
||||
self, channel_key: bytes, timestamp: int, flags: int, message: str
|
||||
) -> bytes:
|
||||
"""Helper to create a valid encrypted GROUP_TEXT payload."""
|
||||
# Build plaintext: timestamp(4) + flags(1) + message + null terminator
|
||||
plaintext = (
|
||||
timestamp.to_bytes(4, "little")
|
||||
+ bytes([flags])
|
||||
+ message.encode("utf-8")
|
||||
+ b"\x00"
|
||||
)
|
||||
|
||||
# Pad to 16-byte boundary
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
if pad_len == 0:
|
||||
pad_len = 16
|
||||
plaintext += bytes(pad_len)
|
||||
|
||||
# Encrypt with AES-128 ECB
|
||||
cipher = AES.new(channel_key, AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
|
||||
# Calculate MAC: HMAC-SHA256(channel_secret, ciphertext)[:2]
|
||||
channel_secret = channel_key + bytes(16)
|
||||
mac = hmac.new(channel_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
|
||||
# Build payload: channel_hash(1) + mac(2) + ciphertext
|
||||
channel_hash = hashlib.sha256(channel_key).digest()[0:1]
|
||||
|
||||
return channel_hash + mac + ciphertext
|
||||
|
||||
def test_decrypt_valid_message(self):
|
||||
"""Decrypt a valid GROUP_TEXT message."""
|
||||
channel_key = hashlib.sha256(b"#testchannel").digest()[:16]
|
||||
timestamp = 1700000000
|
||||
message = "TestUser: Hello world"
|
||||
|
||||
payload = self._create_encrypted_payload(channel_key, timestamp, 0, message)
|
||||
|
||||
result = decrypt_group_text(payload, channel_key)
|
||||
|
||||
assert result is not None
|
||||
assert result.timestamp == timestamp
|
||||
assert result.sender == "TestUser"
|
||||
assert result.message == "Hello world"
|
||||
|
||||
def test_decrypt_message_without_sender_prefix(self):
|
||||
"""Messages without 'sender: ' format have no parsed sender."""
|
||||
channel_key = hashlib.sha256(b"#test").digest()[:16]
|
||||
message = "Just a plain message"
|
||||
|
||||
payload = self._create_encrypted_payload(channel_key, 1234567890, 0, message)
|
||||
|
||||
result = decrypt_group_text(payload, channel_key)
|
||||
|
||||
assert result is not None
|
||||
assert result.sender is None
|
||||
assert result.message == "Just a plain message"
|
||||
|
||||
def test_decrypt_with_wrong_key_fails(self):
|
||||
"""Decryption with wrong key fails MAC verification."""
|
||||
correct_key = hashlib.sha256(b"#correct").digest()[:16]
|
||||
wrong_key = hashlib.sha256(b"#wrong").digest()[:16]
|
||||
|
||||
payload = self._create_encrypted_payload(correct_key, 1234567890, 0, "test")
|
||||
|
||||
result = decrypt_group_text(payload, wrong_key)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_decrypt_corrupted_mac_fails(self):
|
||||
"""Corrupted MAC causes decryption to fail."""
|
||||
channel_key = hashlib.sha256(b"#test").digest()[:16]
|
||||
payload = self._create_encrypted_payload(channel_key, 1234567890, 0, "test")
|
||||
|
||||
# Corrupt the MAC (bytes 1-2)
|
||||
corrupted = payload[:1] + bytes([payload[1] ^ 0xFF, payload[2] ^ 0xFF]) + payload[3:]
|
||||
|
||||
result = decrypt_group_text(corrupted, channel_key)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestTryDecryptPacket:
|
||||
"""Test the full packet decryption pipeline."""
|
||||
|
||||
def test_only_group_text_packets_decrypted(self):
|
||||
"""Non-GROUP_TEXT packets return None."""
|
||||
# TEXT_MESSAGE packet (payload_type=2)
|
||||
# Header: route_type=FLOOD(1), payload_type=TEXT_MESSAGE(2)
|
||||
# Header byte = (0 << 6) | (2 << 2) | 1 = 0x09
|
||||
packet = bytes([0x09, 0x00]) + b"some_data"
|
||||
key = bytes(16)
|
||||
|
||||
result = try_decrypt_packet_with_channel_key(packet, key)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_channel_hash_mismatch_returns_none(self):
|
||||
"""Packets with non-matching channel hash return None early."""
|
||||
# GROUP_TEXT packet with channel_hash that doesn't match our key
|
||||
# Header: route_type=FLOOD(1), payload_type=GROUP_TEXT(5)
|
||||
# Header byte = 0x15
|
||||
wrong_hash = bytes([0xFF]) # Unlikely to match any real key
|
||||
packet = bytes([0x15, 0x00]) + wrong_hash + bytes(20)
|
||||
|
||||
key = hashlib.sha256(b"#test").digest()[:16]
|
||||
|
||||
result = try_decrypt_packet_with_channel_key(packet, key)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRealWorldPackets:
|
||||
"""Test with real captured packets to ensure decoder matches protocol."""
|
||||
|
||||
def test_decrypt_six77_channel_message(self):
|
||||
"""Decrypt a real packet from #six77 channel."""
|
||||
# Real packet captured from #six77 hashtag channel
|
||||
packet_hex = (
|
||||
"1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D"
|
||||
"99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D35182"
|
||||
"83156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF"
|
||||
"0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D"
|
||||
"07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818"
|
||||
)
|
||||
packet = bytes.fromhex(packet_hex)
|
||||
|
||||
# Verify key derivation: SHA256("#six77")[:16]
|
||||
channel_key = hashlib.sha256(b"#six77").digest()[:16]
|
||||
assert channel_key.hex() == "7aba109edcf304a84433cb71d0f3ab73"
|
||||
|
||||
# Decrypt the packet
|
||||
result = try_decrypt_packet_with_channel_key(packet, channel_key)
|
||||
|
||||
assert result is not None
|
||||
assert result.sender == "Flightless🥝"
|
||||
assert "hashtag room is essentially public" in result.message
|
||||
assert result.channel_hash == "e6"
|
||||
assert result.timestamp == 1766604717
|
||||
|
||||
|
||||
class TestAdvertisementParsing:
|
||||
"""Test parsing of advertisement packets."""
|
||||
|
||||
def test_parse_real_advertisement(self):
|
||||
"""Parse a real advertisement packet from 'Flightless 🥝'."""
|
||||
from app.decoder import try_parse_advertisement
|
||||
|
||||
# Real advertisement packet
|
||||
packet_hex = (
|
||||
"1200AE92564C5C9884854F04F469BBB2BAB8871A078053AF6CF4AA2C014B18CE8A83"
|
||||
"54B55C6934EAC9C9BD98A99788B1725379BB25863731ADAB605BCD62F0BA0E467483"
|
||||
"E0A21E81C9279665D117B265B192890B8E0C2AE03E48DA5AA28C3EFB842EF656670B"
|
||||
"915128D902B72DB5F8466C696768746C65737320F09FA59D"
|
||||
)
|
||||
packet = bytes.fromhex(packet_hex)
|
||||
|
||||
result = try_parse_advertisement(packet)
|
||||
|
||||
assert result is not None
|
||||
# Public key is the first 32 bytes of payload
|
||||
assert result.public_key == "ae92564c5c9884854f04f469bbb2bab8871a078053af6cf4aa2c014b18ce8a83"
|
||||
# Name should be extracted from the end
|
||||
assert result.name == "Flightless 🥝"
|
||||
|
||||
def test_parse_advertisement_extracts_public_key(self):
|
||||
"""Advertisement parsing extracts the public key correctly."""
|
||||
from app.decoder import parse_packet, PayloadType
|
||||
|
||||
packet_hex = (
|
||||
"1200AE92564C5C9884854F04F469BBB2BAB8871A078053AF6CF4AA2C014B18CE8A83"
|
||||
"54B55C6934EAC9C9BD98A99788B1725379BB25863731ADAB605BCD62F0BA0E467483"
|
||||
"E0A21E81C9279665D117B265B192890B8E0C2AE03E48DA5AA28C3EFB842EF656670B"
|
||||
"915128D902B72DB5F8466C696768746C65737320F09FA59D"
|
||||
)
|
||||
packet = bytes.fromhex(packet_hex)
|
||||
|
||||
# Verify packet is recognized as ADVERT type
|
||||
info = parse_packet(packet)
|
||||
assert info is not None
|
||||
assert info.payload_type == PayloadType.ADVERT
|
||||
|
||||
def test_non_advertisement_returns_none(self):
|
||||
"""Non-advertisement packets return None from try_parse_advertisement."""
|
||||
from app.decoder import try_parse_advertisement
|
||||
|
||||
# GROUP_TEXT packet, not an advertisement
|
||||
packet = bytes([0x15, 0x00]) + bytes(50)
|
||||
|
||||
result = try_parse_advertisement(packet)
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tests for event handler logic.
|
||||
|
||||
These tests verify the ACK tracking and repeat detection mechanisms
|
||||
that determine message delivery confirmation.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.event_handlers import (
|
||||
_cleanup_expired_acks,
|
||||
_pending_acks,
|
||||
track_pending_ack,
|
||||
)
|
||||
from app.packet_processor import (
|
||||
_cleanup_expired_repeats,
|
||||
_pending_repeat_expiry,
|
||||
_pending_repeats,
|
||||
track_pending_repeat,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_pending_state():
|
||||
"""Clear pending ACKs and repeats before each test."""
|
||||
_pending_acks.clear()
|
||||
_pending_repeats.clear()
|
||||
_pending_repeat_expiry.clear()
|
||||
yield
|
||||
_pending_acks.clear()
|
||||
_pending_repeats.clear()
|
||||
_pending_repeat_expiry.clear()
|
||||
|
||||
|
||||
class TestAckTracking:
|
||||
"""Test ACK tracking for direct messages."""
|
||||
|
||||
def test_track_pending_ack_stores_correctly(self):
|
||||
"""Pending ACKs are stored with message ID and timeout."""
|
||||
track_pending_ack("abc123", message_id=42, timeout_ms=5000)
|
||||
|
||||
assert "abc123" in _pending_acks
|
||||
msg_id, created_at, timeout = _pending_acks["abc123"]
|
||||
assert msg_id == 42
|
||||
assert timeout == 5000
|
||||
assert created_at <= time.time()
|
||||
|
||||
def test_multiple_acks_tracked_independently(self):
|
||||
"""Multiple pending ACKs can be tracked simultaneously."""
|
||||
track_pending_ack("ack1", message_id=1, timeout_ms=1000)
|
||||
track_pending_ack("ack2", message_id=2, timeout_ms=2000)
|
||||
track_pending_ack("ack3", message_id=3, timeout_ms=3000)
|
||||
|
||||
assert len(_pending_acks) == 3
|
||||
assert _pending_acks["ack1"][0] == 1
|
||||
assert _pending_acks["ack2"][0] == 2
|
||||
assert _pending_acks["ack3"][0] == 3
|
||||
|
||||
def test_cleanup_removes_expired_acks(self):
|
||||
"""Expired ACKs are removed during cleanup."""
|
||||
# Add an ACK that's "expired" (created in the past with short timeout)
|
||||
_pending_acks["expired"] = (1, time.time() - 100, 1000) # Created 100s ago, 1s timeout
|
||||
_pending_acks["valid"] = (2, time.time(), 60000) # Created now, 60s timeout
|
||||
|
||||
_cleanup_expired_acks()
|
||||
|
||||
assert "expired" not in _pending_acks
|
||||
assert "valid" in _pending_acks
|
||||
|
||||
def test_cleanup_uses_2x_timeout_buffer(self):
|
||||
"""Cleanup uses 2x timeout as buffer before expiring."""
|
||||
# ACK created 5 seconds ago with 10 second timeout
|
||||
# 2x buffer = 20 seconds, so should NOT be expired yet
|
||||
_pending_acks["recent"] = (1, time.time() - 5, 10000)
|
||||
|
||||
_cleanup_expired_acks()
|
||||
|
||||
assert "recent" in _pending_acks
|
||||
|
||||
|
||||
class TestRepeatTracking:
|
||||
"""Test repeat tracking for channel/flood messages."""
|
||||
|
||||
def test_track_pending_repeat_stores_correctly(self):
|
||||
"""Pending repeats are stored with channel key, text hash, and timestamp."""
|
||||
channel_key = "0123456789ABCDEF0123456789ABCDEF"
|
||||
track_pending_repeat(channel_key=channel_key, text="Hello", timestamp=1700000000, message_id=99)
|
||||
|
||||
# Key is (channel_key, text_hash, timestamp)
|
||||
text_hash = str(hash("Hello"))
|
||||
key = (channel_key, text_hash, 1700000000)
|
||||
|
||||
assert key in _pending_repeats
|
||||
assert _pending_repeats[key] == 99
|
||||
|
||||
def test_same_message_different_channels_tracked_separately(self):
|
||||
"""Same message on different channels creates separate entries."""
|
||||
track_pending_repeat(channel_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1", text="Test", timestamp=1000, message_id=1)
|
||||
track_pending_repeat(channel_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2", text="Test", timestamp=1000, message_id=2)
|
||||
|
||||
assert len(_pending_repeats) == 2
|
||||
|
||||
def test_same_message_different_timestamps_tracked_separately(self):
|
||||
"""Same message with different timestamps creates separate entries."""
|
||||
channel_key = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
track_pending_repeat(channel_key=channel_key, text="Test", timestamp=1000, message_id=1)
|
||||
track_pending_repeat(channel_key=channel_key, text="Test", timestamp=1001, message_id=2)
|
||||
|
||||
assert len(_pending_repeats) == 2
|
||||
|
||||
def test_cleanup_removes_old_repeats(self):
|
||||
"""Expired repeats are removed during cleanup."""
|
||||
channel_key = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
|
||||
text_hash = str(hash("test"))
|
||||
old_key = (channel_key, text_hash, 1000)
|
||||
new_key = (channel_key, text_hash, 2000)
|
||||
|
||||
# Set up entries with expiry times
|
||||
_pending_repeats[old_key] = 1
|
||||
_pending_repeats[new_key] = 2
|
||||
_pending_repeat_expiry[old_key] = time.time() - 10 # Already expired
|
||||
_pending_repeat_expiry[new_key] = time.time() + 30 # Still valid
|
||||
|
||||
_cleanup_expired_repeats()
|
||||
|
||||
assert old_key not in _pending_repeats
|
||||
assert new_key in _pending_repeats
|
||||
|
||||
|
||||
class TestAckEventHandler:
|
||||
"""Test the on_ack event handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_matches_pending_message(self):
|
||||
"""Matching ACK code updates message and broadcasts."""
|
||||
from app.event_handlers import on_ack
|
||||
|
||||
# Setup pending ACK
|
||||
track_pending_ack("deadbeef", message_id=123, timeout_ms=10000)
|
||||
|
||||
# Mock dependencies
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo, \
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
mock_repo.mark_acked = AsyncMock()
|
||||
|
||||
# Create mock event
|
||||
class MockEvent:
|
||||
payload = {"code": "deadbeef"}
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
# Verify message marked as acked
|
||||
mock_repo.mark_acked.assert_called_once_with(123)
|
||||
|
||||
# Verify broadcast sent
|
||||
mock_broadcast.assert_called_once_with("message_acked", {"message_id": 123})
|
||||
|
||||
# Verify pending ACK removed
|
||||
assert "deadbeef" not in _pending_acks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_no_match_does_nothing(self):
|
||||
"""Non-matching ACK code is ignored."""
|
||||
from app.event_handlers import on_ack
|
||||
|
||||
track_pending_ack("expected", message_id=1, timeout_ms=10000)
|
||||
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo, \
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {"code": "different"}
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
mock_repo.mark_acked.assert_not_called()
|
||||
mock_broadcast.assert_not_called()
|
||||
assert "expected" in _pending_acks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_empty_code_ignored(self):
|
||||
"""ACK with empty code is ignored."""
|
||||
from app.event_handlers import on_ack
|
||||
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo:
|
||||
mock_repo.mark_acked = AsyncMock()
|
||||
|
||||
class MockEvent:
|
||||
payload = {"code": ""}
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
mock_repo.mark_acked.assert_not_called()
|
||||
Reference in New Issue
Block a user