Add MQTT removal migration and fix tests + docs

This commit is contained in:
Jack Kingsman
2026-03-05 21:21:08 -08:00
parent e99fed2e76
commit adfb4addb7
30 changed files with 352 additions and 1630 deletions

View File

@@ -21,7 +21,7 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
- `app/AGENTS_MQTT.md` - MQTT architecture (private broker, community analytics, JWT auth, packet format protocol)
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise)
- `frontend/src/components/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
## Architecture Overview
@@ -97,7 +97,7 @@ The following are **deliberate design choices**, not bugs. They are documented i
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
3. **Arbitrary bot code execution**: The bot system (`app/bot.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
## Intentional Packet Handling Decision
@@ -147,17 +147,14 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
.
├── app/ # FastAPI backend
│ ├── AGENTS.md # Backend documentation
│ ├── bot.py # Bot execution and outbound bot sends
│ ├── main.py # App entry, lifespan
│ ├── routers/ # API endpoints
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings)
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout)
│ ├── event_handlers.py # Radio events
│ ├── decoder.py # Packet decryption
│ ├── websocket.py # Real-time broadcasts
── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
│ ├── mqtt.py # Private MQTT publisher
│ └── community_mqtt.py # Community MQTT publisher (raw packet sharing)
── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── frontend/ # React frontend
│ ├── AGENTS.md # Frontend documentation
│ ├── src/
@@ -360,33 +357,11 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
### MQTT Publishing
### Fanout Bus (MQTT, Bots, Webhooks, Apprise)
Optional MQTT integration forwards mesh events to an external broker for home automation, logging, or alerting. All MQTT config is stored in the database (`app_settings`), not env vars — configured from the Settings pane, no server restart needed.
All external integrations are managed through the fanout bus (`app/fanout/`). Each integration is a `FanoutModule` with scope-based event filtering, stored in the `fanout_configs` table and managed via `GET/POST/PATCH/DELETE /api/fanout`.
**Two independent toggles**: publish decrypted messages, publish raw packets.
**Topic structure** (default prefix `meshcore`):
- `meshcore/dm:<contact_public_key>` — decrypted DM
- `meshcore/gm:<channel_key>` — decrypted channel message
- `meshcore/raw/dm:<contact_key>` — raw packet attributed to a DM contact
- `meshcore/raw/gm:<channel_key>` — raw packet attributed to a channel
- `meshcore/raw/unrouted` — raw packets that couldn't be attributed
**Architecture**: `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — a single hook covering all message and raw_packet broadcasts. The `MqttPublisher` in `app/mqtt.py` manages a background connection loop with auto-reconnect and backoff. Publishes are fire-and-forget (silent drop if disconnected). Connection state changes trigger toasts via `broadcast_error`/`broadcast_success`. The health endpoint includes `mqtt_status` (`disabled` when no broker host is set, or when both publish toggles are off).
**Security**: MQTT password stored in plaintext in SQLite, consistent with the project's trusted-network design.
### Community MQTT Sharing
Separate from private MQTT, the community publisher (`app/community_mqtt.py`) shares raw packets with the MeshCore community aggregator for coverage mapping and analysis. Only raw packets are shared — never decrypted messages.
- Connects to community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
- Authentication via Ed25519 JWT signed with the radio's private key. Tokens auto-renew before 24h expiry.
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code.
- JWT `email` claim enables node claiming on the community aggregator.
- Config: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` in `app_settings`.
`broadcast_event()` in `websocket.py` dispatches `message` and `raw_packet` events to the fanout manager. See `app/fanout/AGENTS_fanout.md` for full architecture details.
### Server-Side Decryption
@@ -430,7 +405,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`), `flood_scope`, `blocked_keys`, and `blocked_names`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.

View File

@@ -27,10 +27,7 @@ app/
├── packet_processor.py # Raw packet pipeline, dedup, path handling
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
├── websocket.py # WS manager + broadcast helpers
├── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
├── mqtt.py # Private MQTT publisher (fire-and-forget forwarding)
├── community_mqtt.py # Community MQTT publisher (raw packet sharing)
├── bot.py # Bot execution and outbound bot sends
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── keystore.py # Ephemeral private/public key storage for DM decryption
├── frontend_static.py # Mount/serve built frontend (production)
@@ -43,6 +40,7 @@ app/
├── packets.py
├── read_state.py
├── settings.py
├── fanout.py
├── repeaters.py
├── statistics.py
└── ws.py
@@ -103,33 +101,13 @@ app/
- `0` means disabled.
- Last send time tracked in `app_settings.last_advert_time`.
### MQTT publishing
### Fanout bus
- Optional forwarding of mesh events to an external MQTT broker.
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
- Disabled when `mqtt_broker_host` is empty, or when both publish toggles are off (`mqtt_publish_messages=false` and `mqtt_publish_raw_packets=false`).
- `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — single hook covers all message and raw_packet events.
- `MqttPublisher` (`app/mqtt.py`) runs a background connection loop with auto-reconnect and exponential backoff (5s → 30s).
- Publishes are fire-and-forget; individual publish failures logged but not surfaced to users.
- Connection state changes surface via `broadcast_error`/`broadcast_success` toasts.
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`), where `disabled` covers both "no broker host configured" and "nothing enabled to publish".
- Settings changes trigger `mqtt_publisher.restart()` — no server restart needed.
- Topics: `{prefix}/dm:{key}`, `{prefix}/gm:{key}`, `{prefix}/raw/dm:{key}`, `{prefix}/raw/gm:{key}`, `{prefix}/raw/unrouted`.
### Community MQTT
- Separate publisher (`app/community_mqtt.py`) for sharing raw packets with the MeshCore community aggregator.
- Implementation intent: keep functional parity with the reference implementation at `https://github.com/agessaman/meshcore-packet-capture` unless this repository explicitly documents a deliberate deviation.
- Independent from the private `MqttPublisher` — different broker, authentication, and topic structure.
- Connects to the community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
- Authentication: Ed25519 JWT tokens signed with the radio's expanded "orlp" private key. Tokens expire after 24 hours; proactive renewal at 23 hours.
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
- JWT claims include `publicKey`, `owner` (radio pubkey), `client` (app identifier), and optional `email` (for node claiming on the community aggregator).
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code (required to enable; no default).
- Only raw packets are published — never decrypted messages.
- Publishes are fire-and-forget. The connection loop detects publish failures via `connected` flag and reconnects within 60 seconds.
- Health endpoint includes `community_mqtt_status` field.
- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`.
- All external integrations (MQTT, bots, webhooks, Apprise) are managed through the fanout bus (`app/fanout/`).
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
- Each integration is a `FanoutModule` with scope-based filtering.
- See `app/fanout/AGENTS_fanout.md` for full architecture details.
## API Surface (all under `/api`)
@@ -242,13 +220,11 @@ Main tables:
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `bots`
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
- `flood_scope`
- `blocked_keys`, `blocked_names`
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
## Security Posture (intentional)
- No authn/authz.
@@ -279,6 +255,8 @@ tests/
├── test_decoder.py # Packet parsing/decryption
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
├── test_fanout_integration.py # Fanout integration tests
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving
├── test_health_mqtt_status.py # Health endpoint MQTT status field

View File

@@ -1,377 +0,0 @@
# MQTT Architecture
RemoteTerm implements two independent MQTT publishing systems that share a common base class:
1. **Private MQTT** — forwards mesh events to a user-configured broker (home automation, logging, alerting)
2. **Community MQTT** — shares raw RF packets with the MeshCore community aggregator for coverage mapping
Both are optional, configured entirely through the Settings UI, and require no server restart.
## File Map
```
app/
├── mqtt_base.py # BaseMqttPublisher — shared lifecycle, connection loop, reconnect
├── mqtt.py # MqttPublisher — private broker forwarding
├── community_mqtt.py # CommunityMqttPublisher — community aggregator integration
├── keystore.py # In-memory Ed25519 key storage (community auth)
├── models.py # AppSettings — all MQTT fields (14 total)
├── repository/settings.py # Database CRUD for MQTT settings
├── routers/settings.py # PATCH /api/settings — validates + restarts publishers
├── routers/health.py # GET /api/health — mqtt_status, community_mqtt_status
├── websocket.py # broadcast_event() — fans out to WS + both MQTT publishers
└── migrations.py # Migration 031 (private fields), 032 (community fields)
frontend/src/
├── components/settings/SettingsMqttSection.tsx # Dual collapsible settings UI
└── types.ts # AppSettings, AppSettingsUpdate, HealthStatus
tests/
├── test_mqtt.py # Topic routing, lifecycle
├── test_community_mqtt.py # JWT generation, packet format, hash, broadcast
└── test_health_mqtt_status.py # Health endpoint status reporting
```
## Base Publisher (`app/mqtt_base.py`)
`BaseMqttPublisher` is an abstract class that manages the full MQTT client lifecycle for both publishers. Subclasses implement hooks; the base class owns the connection loop.
### Connection Loop
The `_connection_loop()` runs as a background `asyncio.Task` and never exits unless cancelled:
```
loop:
├─ _is_configured()? No → call _on_not_configured(), wait for settings change, loop
├─ _pre_connect()? False → wait and retry
├─ Build client via _build_client_kwargs()
├─ Connect with aiomqtt.Client
├─ Set connected=True, broadcast success toast via _on_connected()
├─ Wait in 60s intervals:
│ ├─ _on_periodic_wake(elapsed) → subclass hook (e.g., periodic status republish)
│ ├─ Settings version changed? → break, reconnect with new settings
│ ├─ _should_break_wait()? → break (e.g., JWT expiry)
│ └─ Otherwise keep waiting (paho-mqtt handles keepalive internally)
├─ On error: set connected=False, broadcast error toast, exponential backoff
└─ On cancel: cleanup and exit
```
### Abstract Hooks
| Hook | Returns | Purpose |
|------|---------|---------|
| `_is_configured()` | `bool` | Should the publisher attempt to connect? |
| `_build_client_kwargs(settings)` | `dict` | Arguments for `aiomqtt.Client(...)` |
| `_on_connected(settings)` | `(title, detail)` | Success toast content |
| `_on_error()` | `(title, detail)` | Error toast content |
### Optional Hooks
| Hook | Default | Purpose |
|------|---------|---------|
| `_pre_connect(settings)` | `return True` | Async setup before connect; return `False` to retry |
| `_should_break_wait(elapsed)` | `return False` | Force reconnect while connected (e.g., token renewal) |
| `_on_not_configured()` | no-op | Called repeatedly while waiting for configuration |
| `_on_periodic_wake(elapsed)` | no-op | Called every ~60s while connected (e.g., periodic status republish) |
### Lifecycle Methods
- `start(settings)` — stores settings, starts the background loop task
- `stop()` — cancels the task, disconnects the client
- `restart(settings)``stop()` then `start()` (called when settings change)
- `publish(topic, payload)` — JSON-serializes and publishes; silently drops if disconnected
### Backoff
Reconnect delay: 5 seconds minimum, exponential growth, capped at `_backoff_max` (30s for private, 60s for community). Resets on successful connect.
### QoS
All publishing uses QoS 0 (at-most-once delivery), the aiomqtt default.
## Private MQTT (`app/mqtt.py`)
### When It Connects
`_is_configured()` returns `True` when all of:
- `mqtt_broker_host` is non-empty
- At least one of `mqtt_publish_messages` or `mqtt_publish_raw_packets` is enabled
If the user unchecks both publish toggles and saves, the publisher disconnects and the health status shows "Disabled".
### Client Configuration
```python
hostname: settings.mqtt_broker_host
port: settings.mqtt_broker_port (default 1883)
username: settings.mqtt_username or None
password: settings.mqtt_password or None
tls_context: ssl.create_default_context() if mqtt_use_tls, else None
# mqtt_tls_insecure=True disables hostname check + cert verification
```
TLS is opt-in. When enabled with `mqtt_tls_insecure`, both `check_hostname` and `verify_mode` are relaxed for self-signed certificates.
### Topic Structure
Default prefix: `meshcore` (configurable via `mqtt_topic_prefix`).
**Decrypted messages** (when `mqtt_publish_messages` is on):
- `{prefix}/dm:{contact_key}` — private DM
- `{prefix}/gm:{channel_key}` — channel message
- `{prefix}/message:{conversation_key}` — fallback for unknown type
**Raw packets** (when `mqtt_publish_raw_packets` is on):
- `{prefix}/raw/dm:{contact_key}` — attributed to a DM contact
- `{prefix}/raw/gm:{channel_key}` — attributed to a channel
- `{prefix}/raw/unrouted` — unattributed
Topic routing uses `decrypted_info.contact_key` and `decrypted_info.channel_key` from the raw packet data.
### Fire-and-Forget Pattern
`mqtt_broadcast(event_type, data)` is called synchronously from `broadcast_event()` in `websocket.py`. It filters to only `"message"` and `"raw_packet"` events, then creates an `asyncio.Task` for the actual publish. No awaiting — failures are logged at WARNING level and silently dropped.
## Community MQTT (`app/community_mqtt.py`)
Implements the [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) protocol for sharing raw RF packets with the MeshCore community aggregator.
### When It Connects
`_is_configured()` returns `True` when all of:
- `community_mqtt_enabled` is `True`
- The radio's private key is available in the keystore (`has_private_key()`)
The private key is exported from the radio firmware on startup via `export_and_store_private_key()` in `app/keystore.py`. This requires `ENABLE_PRIVATE_KEY_EXPORT` to be enabled in the radio firmware. If unavailable, the publisher broadcasts a warning and waits.
### Client Configuration
```python
hostname: community_mqtt_broker_host or "mqtt-us-v1.letsmesh.net"
port: community_mqtt_broker_port or 443
transport: "websockets"
tls_context: ssl.create_default_context() # always enforced, not user-configurable
websocket_path: "/"
username: "v1_{pubkey_hex}"
password: {jwt_token}
```
TLS is always on — the community connection uses WebSocket Secure (WSS) with full certificate verification. There is no option to disable it.
### JWT Authentication
The community broker authenticates via Ed25519-signed JWT tokens.
**Token format:** `header_b64url.payload_b64url.signature_hex`
**Header:**
```json
{"alg": "Ed25519", "typ": "JWT"}
```
**Payload:**
```json
{
"publicKey": "{PUBKEY_HEX_UPPER}",
"iat": 1234567890,
"exp": 1234654290,
"aud": "{broker_host}",
"owner": "{PUBKEY_HEX_UPPER}",
"client": "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)",
"email": "user@example.com" // optional, only if configured
}
```
**Signing:** MeshCore uses an "expanded" 64-byte Ed25519 key format (`scalar[32] || prefix[32]`, the "orlp" format). Standard Ed25519 libraries expect seed format and would re-hash the key. The `_ed25519_sign_expanded()` function performs signing manually using `nacl.bindings.crypto_scalarmult_ed25519_base_noclamp()` — a direct port of meshcore-packet-capture's `ed25519_sign_with_expanded_key()`.
**Token lifetime:** 24 hours. The `_should_break_wait()` hook forces a reconnect at the 23-hour mark to renew before expiry.
### Status Messages
On connect and every 5 minutes thereafter, the community publisher sends a retained status message to `meshcore/{IATA}/{PUBKEY}/status` with device info and radio telemetry:
```json
{
"status": "online",
"timestamp": "2024-01-15T10:30:00.000000",
"origin": "NodeName",
"origin_id": "PUBKEY_HEX_UPPER",
"model": "T-Deck",
"firmware_version": "v2.2.2 (Build: 2025-01-15)",
"radio": "915.0,250.0,10,8",
"client_version": "RemoteTerm 2.4.0",
"stats": {
"battery_mv": 4200,
"uptime_secs": 3600,
"errors": 0,
"queue_len": 0,
"noise_floor": -120,
"last_rssi": -85,
"last_snr": 10.5,
"tx_air_secs": 42,
"rx_air_secs": 150
}
}
```
- `model` and `firmware_version` are fetched once per connection via `send_device_query()` (requires firmware version >= 3)
- `radio` is comma-separated raw values from `self_info` (freq, BW, SF, CR) matching the reference format
- `client_version` is read from Python package metadata (`remoteterm-meshcore`)
- `stats` is fetched from `get_stats_core()` + `get_stats_radio()` every 5 minutes; omitted if firmware doesn't support stats commands
- All radio queries use `blocking=False` — if the radio is busy, cached values are used. No user-facing operations are ever blocked.
- LWT (Last Will and Testament) publishes `{"status": "offline", ...}` on the same topic with retain
### Packet Formatting
`_format_raw_packet()` converts raw packet broadcast data into the meshcore-packet-capture JSON format:
```json
{
"origin": "NodeName",
"origin_id": "PUBKEY_HEX_UPPER",
"timestamp": "2024-01-15T10:30:00.000000",
"type": "PACKET",
"direction": "rx",
"time": "10:30:00",
"date": "15/01/2024",
"len": "42",
"packet_type": "5",
"route": "F",
"payload_len": "30",
"raw": "AABBCCDD...",
"SNR": "10.5",
"RSSI": "-85",
"hash": "A1B2C3D4E5F6G7H8",
"path": "ab,cd,ef"
}
```
- `origin` is the radio's device name from `meshcore.self_info`
- `route` is derived from the header's bottom 2 bits: `0,1→"F"` (Flood), `2→"D"` (Direct), `3→"T"` (Trace)
- `path` is only present when `route=="D"`
- `hash` matches MeshCore's C++ `Packet::calculatePacketHash()`: SHA-256 of `payload_type[1 byte] + [path_len as uint16 LE, TRACE only] + payload_data`, truncated to first 16 hex characters
### Topic Structure
```
meshcore/{IATA}/{PUBKEY_HEX}/packets
```
IATA must be exactly 3 uppercase letters (e.g., `DEN`, `LAX`). Validated both client-side (input maxLength + uppercase conversion) and server-side (regex `^[A-Z]{3}$`, returns HTTP 400 on failure).
### Only Raw Packets
The community publisher only handles `"raw_packet"` events. Decrypted messages are never shared with the community — `community_mqtt_broadcast()` explicitly filters `event_type != "raw_packet"`.
## Event Flow
```
Radio RF event
meshcore_py library callback
app/event_handlers.py (on_contact_message, on_rx_log_data, etc.)
Store to SQLite database
broadcast_event(event_type, data) ← app/websocket.py
├─ WebSocket → browser clients
├─ mqtt_broadcast() ← app/mqtt.py (messages + raw packets)
│ └─ asyncio.create_task(_mqtt_maybe_publish())
└─ community_mqtt_broadcast() ← app/community_mqtt.py (raw packets only)
└─ asyncio.create_task(_community_maybe_publish())
```
## Settings & Persistence
### Database Fields (`app_settings` table)
**Private MQTT** (Migration 031):
| Column | Type | Default |
|--------|------|---------|
| `mqtt_broker_host` | TEXT | `''` |
| `mqtt_broker_port` | INTEGER | `1883` |
| `mqtt_username` | TEXT | `''` |
| `mqtt_password` | TEXT | `''` |
| `mqtt_use_tls` | INTEGER | `0` |
| `mqtt_tls_insecure` | INTEGER | `0` |
| `mqtt_topic_prefix` | TEXT | `'meshcore'` |
| `mqtt_publish_messages` | INTEGER | `0` |
| `mqtt_publish_raw_packets` | INTEGER | `0` |
**Community MQTT** (Migration 032):
| Column | Type | Default |
|--------|------|---------|
| `community_mqtt_enabled` | INTEGER | `0` |
| `community_mqtt_iata` | TEXT | `''` |
| `community_mqtt_broker_host` | TEXT | `'mqtt-us-v1.letsmesh.net'` |
| `community_mqtt_broker_port` | INTEGER | `443` |
| `community_mqtt_email` | TEXT | `''` |
### Settings API
`PATCH /api/settings` accepts any subset of MQTT fields. The router tracks whether private or community fields changed independently:
- If any private MQTT field changed → `await mqtt_publisher.restart(result)`
- If any community MQTT field changed → `await community_publisher.restart(result)`
This means toggling a publish checkbox triggers a full disconnect/reconnect cycle.
### Health API
`GET /api/health` reports both statuses:
```json
{
"mqtt_status": "connected | disconnected | disabled",
"community_mqtt_status": "connected | disconnected | disabled"
}
```
Status logic for each publisher:
- `_is_configured()` returns `True` → report `"connected"` or `"disconnected"` based on `publisher.connected`
- `_is_configured()` returns `False` → report `"disabled"`
## App Lifecycle
**Startup** (in `app/main.py` lifespan):
1. Database connects, radio connects
2. `export_and_store_private_key()` — export Ed25519 key from radio (needed for community auth)
3. Load `AppSettings` from database
4. `mqtt_publisher.start(settings)` — spawns background connection loop
5. `community_publisher.start(settings)` — spawns background connection loop
**Shutdown:**
1. `community_publisher.stop()`
2. `mqtt_publisher.stop()`
3. Radio and database cleanup
## Frontend (`SettingsMqttSection.tsx`)
The MQTT settings UI is a single React component with two collapsible sections (both collapsed by default):
### Private MQTT Broker Section
- Header shows connection status indicator (green/red/gray dot + label)
- Always visible when expanded: Publish Messages and Publish Raw Packets checkboxes
- Broker configuration (host, port, username, password, TLS, topic prefix) only revealed when at least one publish checkbox is checked
- Responsive grid layout (`grid-cols-1 sm:grid-cols-2`) for host+port and username+password pairs
### Community Analytics Section
- Header shows connection status indicator
- Enable Community Analytics checkbox
- When enabled: broker host/port, IATA code input (3 chars, auto-uppercase), owner email
- Broker host shows "MQTT over TLS (WebSocket Secure) only" note
### Shared
- Beta warning banner at the top (links to GitHub issues)
- Single "Save MQTT Settings" button outside both collapsibles
- Save constructs an `AppSettingsUpdate` and calls `PATCH /api/settings`
- Success/error feedback via toast notifications
## Security Notes
- **Private MQTT password** is stored in plaintext in SQLite, consistent with the project's trusted-network design.
- **Community MQTT** always uses TLS with full certificate verification. The Ed25519 private key is held in memory only (never persisted to disk) and is used solely for JWT signing.
- **Community data** is limited to raw RF packets — decrypted message content is never shared.

View File

@@ -46,15 +46,31 @@ Setting `realtime=False` (used during historical decryption) skips fanout dispat
## Current Module Types
### mqtt_private (mqtt_private.py)
Wraps `MqttPublisher` from `app/mqtt.py`. Config blob:
Wraps `MqttPublisher` from `app/fanout/mqtt.py`. Config blob:
- `broker_host`, `broker_port`, `username`, `password`
- `use_tls`, `tls_insecure`, `topic_prefix`
### mqtt_community (mqtt_community.py)
Wraps `CommunityMqttPublisher` from `app/community_mqtt.py`. Config blob:
Wraps `CommunityMqttPublisher` from `app/fanout/community_mqtt.py`. Config blob:
- `broker_host`, `broker_port`, `iata`, `email`
- Only publishes raw packets (on_message is a no-op)
### bot (bot.py)
Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
- `code` — Python bot function source code
- Executes in a thread pool with timeout and semaphore concurrency control
- Rate-limits outgoing messages for repeater compatibility
### webhook (webhook.py)
HTTP POST webhook delivery. Config blob:
- `url`, `secret` (optional HMAC signing key)
- Delivers messages and raw packets as JSON payloads
### apprise (apprise_mod.py)
Push notifications via Apprise library. Config blob:
- `urls` — list of Apprise notification service URLs
- Formats messages for human-readable notification delivery
## Adding a New Integration Type
1. Create `app/fanout/my_type.py` with a class extending `FanoutModule`
@@ -73,19 +89,29 @@ Wraps `CommunityMqttPublisher` from `app/community_mqtt.py`. Config blob:
## Database
`fanout_configs` table (created in migration 36):
`fanout_configs` table:
- `id` TEXT PRIMARY KEY
- `type`, `name`, `enabled`, `config` (JSON), `scope` (JSON)
- `sort_order`, `created_at`
Migration 36 also migrates existing `app_settings` MQTT columns into fanout rows.
Migrations:
- **36**: Creates `fanout_configs` table, migrates existing MQTT settings from `app_settings`
- **37**: Migrates bot configs from `app_settings.bots` JSON column into fanout rows
- **38**: Drops legacy `mqtt_*`, `community_mqtt_*`, and `bots` columns from `app_settings`
## Key Files
- `app/fanout/base.py` — FanoutModule ABC
- `app/fanout/manager.py` — FanoutManager singleton
- `app/fanout/mqtt_private.py`Private MQTT module
- `app/fanout/mqtt_community.py` — Community MQTT module
- `app/fanout/mqtt_base.py`BaseMqttPublisher ABC (shared MQTT connection loop)
- `app/fanout/mqtt.py` — MqttPublisher (private MQTT publishing)
- `app/fanout/community_mqtt.py` — CommunityMqttPublisher (community MQTT with JWT auth)
- `app/fanout/mqtt_private.py` — Private MQTT fanout module
- `app/fanout/mqtt_community.py` — Community MQTT fanout module
- `app/fanout/bot.py` — Bot fanout module
- `app/fanout/bot_exec.py` — Bot code execution, response processing, rate limiting
- `app/fanout/webhook.py` — Webhook fanout module
- `app/fanout/apprise_mod.py` — Apprise fanout module
- `app/repository/fanout.py` — Database CRUD
- `app/routers/fanout.py` — REST API
- `app/websocket.py``broadcast_event()` dispatches to fanout

View File

@@ -28,7 +28,11 @@ class BotModule(FanoutModule):
asyncio.create_task(self._run_for_message(data))
async def _run_for_message(self, data: dict) -> None:
from app.bot import BOT_EXECUTION_TIMEOUT, execute_bot_code, process_bot_response
from app.fanout.bot_exec import (
BOT_EXECUTION_TIMEOUT,
execute_bot_code,
process_bot_response,
)
code = self.config.get("code", "")
if not code or not code.strip():
@@ -83,7 +87,7 @@ class BotModule(FanoutModule):
await asyncio.sleep(2)
# Execute bot code in thread pool with timeout
from app.bot import _bot_executor, _bot_semaphore
from app.fanout.bot_exec import _bot_executor, _bot_semaphore
async with _bot_semaphore:
loop = asyncio.get_event_loop()

View File

@@ -19,8 +19,6 @@ from typing import Any
from fastapi import HTTPException
from app.config import settings as server_settings
logger = logging.getLogger(__name__)
# Limit concurrent bot executions to prevent resource exhaustion
@@ -259,97 +257,3 @@ async def _send_single_bot_message(
# Update last send time after successful send
_last_bot_send_time = time.monotonic()
async def run_bot_for_message(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None = None,
sender_timestamp: int | None = None,
path: str | None = None,
is_outgoing: bool = False,
) -> None:
"""
Run all enabled bots for a message (incoming or outgoing).
This is the main entry point called by message handlers after
a message is successfully decrypted and stored. Bots run serially,
and errors in one bot don't prevent others from running.
Args:
sender_name: Display name of the sender
sender_key: 64-char hex public key of sender (DMs only, None for channels)
message_text: The message content
is_dm: True for direct messages, False for channel messages
channel_key: Channel key for channel messages
channel_name: Channel name (e.g. "#general"), None for DMs
sender_timestamp: Sender's timestamp from the message
path: Hex-encoded routing path
is_outgoing: Whether this is our own outgoing message
"""
if server_settings.disable_bots:
return
# Early check if any bots are enabled (will re-check after sleep)
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
return
async with _bot_semaphore:
logger.debug(
"Running %d bot(s) for message from %s (is_dm=%s)",
len(enabled_bots),
sender_name or (sender_key[:12] if sender_key else "unknown"),
is_dm,
)
# Wait for the initiating message's retransmissions to propagate through the mesh
await asyncio.sleep(2)
# Re-check settings after sleep (user may have changed bot config)
settings = await AppSettingsRepository.get()
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
logger.debug("All bots disabled during wait, skipping")
return
# Run each enabled bot serially
loop = asyncio.get_event_loop()
for bot in enabled_bots:
logger.debug("Executing bot '%s'", bot.name)
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
bot.code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning(
"Bot '%s' execution timed out after %ds", bot.name, BOT_EXECUTION_TIMEOUT
)
continue # Continue to next bot
except Exception as e:
logger.warning("Bot '%s' execution error: %s", bot.name, e)
continue # Continue to next bot
# Send response if any
if response:
await process_bot_response(response, is_dm, sender_key or "", channel_key)

View File

@@ -19,13 +19,12 @@ import re
import ssl
import time
from datetime import datetime
from typing import Any
from typing import Any, Protocol
import aiomqtt
import nacl.bindings
from app.models import AppSettings
from app.mqtt_base import BaseMqttPublisher
from app.fanout.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
@@ -49,6 +48,16 @@ _IATA_RE = re.compile(r"^[A-Z]{3}$")
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
class CommunityMqttSettings(Protocol):
"""Attributes expected on the settings object for the community MQTT publisher."""
community_mqtt_enabled: bool
community_mqtt_broker_host: str
community_mqtt_broker_port: int
community_mqtt_iata: str
community_mqtt_email: str
def _base64url_encode(data: bytes) -> str:
"""Base64url encode without padding."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
@@ -258,7 +267,7 @@ def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: s
return packet
def _build_status_topic(settings: AppSettings, pubkey_hex: str) -> str:
def _build_status_topic(settings: CommunityMqttSettings, pubkey_hex: str) -> str:
"""Build the ``meshcore/{IATA}/{PUBKEY}/status`` topic string."""
iata = settings.community_mqtt_iata.upper().strip()
return f"meshcore/{iata}/{pubkey_hex}/status"
@@ -310,7 +319,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
self._last_stats_fetch: float = 0.0
self._last_status_publish: float = 0.0
async def start(self, settings: AppSettings) -> None:
async def start(self, settings: object) -> None:
self._key_unavailable_warned = False
self._cached_device_info = None
self._cached_stats = None
@@ -323,9 +332,10 @@ class CommunityMqttPublisher(BaseMqttPublisher):
from app.keystore import has_private_key
from app.websocket import broadcast_error
s: CommunityMqttSettings | None = self._settings
if (
self._settings
and self._settings.community_mqtt_enabled
s
and s.community_mqtt_enabled
and not has_private_key()
and not self._key_unavailable_warned
):
@@ -339,9 +349,11 @@ class CommunityMqttPublisher(BaseMqttPublisher):
"""Check if community MQTT is enabled and keys are available."""
from app.keystore import has_private_key
return bool(self._settings and self._settings.community_mqtt_enabled and has_private_key())
s: CommunityMqttSettings | None = self._settings
return bool(s and s.community_mqtt_enabled and has_private_key())
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
s: CommunityMqttSettings = settings # type: ignore[assignment]
from app.keystore import get_private_key, get_public_key
from app.radio import radio_manager
@@ -350,13 +362,13 @@ class CommunityMqttPublisher(BaseMqttPublisher):
assert private_key is not None and public_key is not None # guaranteed by _pre_connect
pubkey_hex = public_key.hex().upper()
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
jwt_token = _generate_jwt_token(
private_key,
public_key,
audience=broker_host,
email=settings.community_mqtt_email or "",
email=s.community_mqtt_email or "",
)
tls_context = ssl.create_default_context()
@@ -365,7 +377,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
status_topic = _build_status_topic(settings, pubkey_hex)
status_topic = _build_status_topic(s, pubkey_hex)
offline_payload = json.dumps(
{
"status": "offline",
@@ -386,9 +398,10 @@ class CommunityMqttPublisher(BaseMqttPublisher):
"will": aiomqtt.Will(status_topic, offline_payload, retain=True),
}
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
def _on_connected(self, settings: object) -> tuple[str, str]:
s: CommunityMqttSettings = settings # type: ignore[assignment]
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
async def _fetch_device_info(self) -> dict[str, str]:
@@ -479,7 +492,9 @@ class CommunityMqttPublisher(BaseMqttPublisher):
return self._cached_stats
async def _publish_status(self, settings: AppSettings, *, refresh_stats: bool = True) -> None:
async def _publish_status(
self, settings: CommunityMqttSettings, *, refresh_stats: bool = True
) -> None:
"""Build and publish the enriched retained status message."""
from app.keystore import get_public_key
from app.radio import radio_manager
@@ -514,9 +529,9 @@ class CommunityMqttPublisher(BaseMqttPublisher):
await self.publish(status_topic, payload, retain=True)
self._last_status_publish = time.monotonic()
async def _on_connected_async(self, settings: AppSettings) -> None:
async def _on_connected_async(self, settings: object) -> None:
"""Publish a retained online status message after connecting."""
await self._publish_status(settings)
await self._publish_status(settings) # type: ignore[arg-type]
async def _on_periodic_wake(self, elapsed: float) -> None:
if not self._settings:
@@ -540,7 +555,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
return True
return False
async def _pre_connect(self, settings: AppSettings) -> bool:
async def _pre_connect(self, settings: object) -> bool:
from app.keystore import get_private_key, get_public_key
private_key = get_private_key()

View File

@@ -4,14 +4,26 @@ from __future__ import annotations
import logging
import ssl
from typing import Any
from typing import Any, Protocol
from app.models import AppSettings
from app.mqtt_base import BaseMqttPublisher
from app.fanout.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
class PrivateMqttSettings(Protocol):
"""Attributes expected on the settings object for the private MQTT publisher."""
mqtt_broker_host: str
mqtt_broker_port: int
mqtt_username: str
mqtt_password: str
mqtt_use_tls: bool
mqtt_tls_insecure: bool
mqtt_publish_messages: bool
mqtt_publish_raw_packets: bool
class MqttPublisher(BaseMqttPublisher):
"""Manages an MQTT connection and publishes mesh network events."""
@@ -20,29 +32,30 @@ class MqttPublisher(BaseMqttPublisher):
def _is_configured(self) -> bool:
"""Check if MQTT is configured and has something to publish."""
s: PrivateMqttSettings | None = self._settings
return bool(
self._settings
and self._settings.mqtt_broker_host
and (self._settings.mqtt_publish_messages or self._settings.mqtt_publish_raw_packets)
s and s.mqtt_broker_host and (s.mqtt_publish_messages or s.mqtt_publish_raw_packets)
)
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
s: PrivateMqttSettings = settings # type: ignore[assignment]
return {
"hostname": settings.mqtt_broker_host,
"port": settings.mqtt_broker_port,
"username": settings.mqtt_username or None,
"password": settings.mqtt_password or None,
"tls_context": self._build_tls_context(settings),
"hostname": s.mqtt_broker_host,
"port": s.mqtt_broker_port,
"username": s.mqtt_username or None,
"password": s.mqtt_password or None,
"tls_context": self._build_tls_context(s),
}
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
return ("MQTT connected", f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}")
def _on_connected(self, settings: object) -> tuple[str, str]:
s: PrivateMqttSettings = settings # type: ignore[assignment]
return ("MQTT connected", f"{s.mqtt_broker_host}:{s.mqtt_broker_port}")
def _on_error(self) -> tuple[str, str]:
return ("MQTT connection failure", "Please correct the settings or disable.")
@staticmethod
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
def _build_tls_context(settings: PrivateMqttSettings) -> ssl.SSLContext | None:
"""Build TLS context from settings, or None if TLS is disabled."""
if not settings.mqtt_use_tls:
return None

View File

@@ -18,8 +18,6 @@ from typing import Any
import aiomqtt
from app.models import AppSettings
logger = logging.getLogger(__name__)
_BACKOFF_MIN = 5
@@ -38,6 +36,11 @@ class BaseMqttPublisher(ABC):
Subclasses implement the abstract hooks to control configuration checks,
client construction, toast messages, and optional wait-loop behavior.
The settings type is duck-typed each subclass defines a Protocol
describing the attributes it expects (e.g. ``PrivateMqttSettings``,
``CommunityMqttSettings``). Callers pass ``SimpleNamespace`` instances
that satisfy the protocol.
"""
_backoff_max: int = 30
@@ -47,14 +50,14 @@ class BaseMqttPublisher(ABC):
def __init__(self) -> None:
self._client: aiomqtt.Client | None = None
self._task: asyncio.Task[None] | None = None
self._settings: AppSettings | None = None
self._settings: Any = None
self._settings_version: int = 0
self._version_event: asyncio.Event = asyncio.Event()
self.connected: bool = False
# ── Lifecycle ──────────────────────────────────────────────────────
async def start(self, settings: AppSettings) -> None:
async def start(self, settings: object) -> None:
"""Start the background connection loop."""
self._settings = settings
self._settings_version += 1
@@ -74,7 +77,7 @@ class BaseMqttPublisher(ABC):
self._client = None
self.connected = False
async def restart(self, settings: AppSettings) -> None:
async def restart(self, settings: object) -> None:
"""Called when settings change — stop + start."""
await self.stop()
await self.start(settings)
@@ -99,11 +102,11 @@ class BaseMqttPublisher(ABC):
"""Return True when this publisher should attempt to connect."""
@abstractmethod
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
"""Return the keyword arguments for ``aiomqtt.Client(...)``."""
@abstractmethod
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
def _on_connected(self, settings: object) -> tuple[str, str]:
"""Return ``(title, detail)`` for the success toast on connect."""
@abstractmethod
@@ -116,7 +119,7 @@ class BaseMqttPublisher(ABC):
"""Return True to break the inner wait (e.g. token expiry)."""
return False
async def _pre_connect(self, settings: AppSettings) -> bool:
async def _pre_connect(self, settings: object) -> bool:
"""Called before connecting. Return True to proceed, False to retry."""
return True
@@ -124,7 +127,7 @@ class BaseMqttPublisher(ABC):
"""Called each time the loop finds the publisher not configured."""
return # no-op by default; subclasses may override
async def _on_connected_async(self, settings: AppSettings) -> None:
async def _on_connected_async(self, settings: object) -> None:
"""Async hook called after connection succeeds (before health broadcast).
Subclasses can override to publish messages immediately after connecting.

View File

@@ -4,20 +4,20 @@ from __future__ import annotations
import logging
import re
from types import SimpleNamespace
from typing import Any
from app.community_mqtt import CommunityMqttPublisher, _format_raw_packet
from app.fanout.base import FanoutModule
from app.models import AppSettings
from app.fanout.community_mqtt import CommunityMqttPublisher, _format_raw_packet
logger = logging.getLogger(__name__)
_IATA_RE = re.compile(r"^[A-Z]{3}$")
def _config_to_settings(config: dict) -> AppSettings:
"""Map a fanout config blob to AppSettings for the CommunityMqttPublisher."""
return AppSettings(
def _config_to_settings(config: dict) -> SimpleNamespace:
"""Map a fanout config blob to a settings namespace for the CommunityMqttPublisher."""
return SimpleNamespace(
community_mqtt_enabled=True,
community_mqtt_broker_host=config.get("broker_host", "mqtt-us-v1.letsmesh.net"),
community_mqtt_broker_port=config.get("broker_port", 443),

View File

@@ -3,17 +3,17 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from app.fanout.base import FanoutModule
from app.models import AppSettings
from app.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
from app.fanout.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
logger = logging.getLogger(__name__)
def _config_to_settings(config: dict) -> AppSettings:
"""Map a fanout config blob to AppSettings for the MqttPublisher."""
return AppSettings(
def _config_to_settings(config: dict) -> SimpleNamespace:
"""Map a fanout config blob to a settings namespace for the MqttPublisher."""
return SimpleNamespace(
mqtt_broker_host=config.get("broker_host", ""),
mqtt_broker_port=config.get("broker_port", 1883),
mqtt_username=config.get("username", ""),
@@ -21,7 +21,6 @@ def _config_to_settings(config: dict) -> AppSettings:
mqtt_use_tls=config.get("use_tls", False),
mqtt_tls_insecure=config.get("tls_insecure", False),
mqtt_topic_prefix=config.get("topic_prefix", "meshcore"),
# Always enable both publish flags; the fanout scope controls delivery.
mqtt_publish_messages=True,
mqtt_publish_raw_packets=True,
)

View File

@@ -296,6 +296,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 37)
applied += 1
# Migration 38: Drop legacy MQTT, community MQTT, and bots columns from app_settings
if version < 38:
logger.info("Applying migration 38: drop legacy MQTT/bot columns from app_settings")
await _migrate_038_drop_legacy_columns(conn)
await set_version(conn, 38)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2214,3 +2221,52 @@ async def _migrate_037_bots_to_fanout(conn: aiosqlite.Connection) -> None:
logger.info("Migrated bot '%s' to fanout_configs (enabled=%s)", bot_name, bot_enabled)
await conn.commit()
async def _migrate_038_drop_legacy_columns(conn: aiosqlite.Connection) -> None:
"""Drop legacy MQTT, community MQTT, and bots columns from app_settings.
These columns were migrated to fanout_configs in migrations 36 and 37.
SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN. For older versions,
the columns remain but are harmless (no longer read or written).
"""
# Check if app_settings table exists (some test DBs may not have it)
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
columns_to_drop = [
"bots",
"mqtt_broker_host",
"mqtt_broker_port",
"mqtt_username",
"mqtt_password",
"mqtt_use_tls",
"mqtt_tls_insecure",
"mqtt_topic_prefix",
"mqtt_publish_messages",
"mqtt_publish_raw_packets",
"community_mqtt_enabled",
"community_mqtt_iata",
"community_mqtt_broker_host",
"community_mqtt_broker_port",
"community_mqtt_email",
]
for column in columns_to_drop:
try:
await conn.execute(f"ALTER TABLE app_settings DROP COLUMN {column}")
logger.debug("Dropped %s from app_settings", column)
except aiosqlite.OperationalError as e:
error_msg = str(e).lower()
if "no such column" in error_msg:
logger.debug("app_settings.%s already dropped, skipping", column)
elif "syntax error" in error_msg or "drop column" in error_msg:
logger.debug("SQLite doesn't support DROP COLUMN, %s column will remain", column)
else:
raise
await conn.commit()

View File

@@ -399,15 +399,6 @@ class Favorite(BaseModel):
id: str = Field(description="Channel key or contact public key")
class BotConfig(BaseModel):
"""Configuration for a single bot."""
id: str = Field(description="UUID for stable identity across renames/reorders")
name: str = Field(description="User-editable name")
enabled: bool = Field(default=False, description="Whether this bot is enabled")
code: str = Field(default="", description="Python code for this bot")
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
@@ -459,66 +450,6 @@ class AppSettings(BaseModel):
default=0,
description="Unix timestamp of last advertisement sent (0 = never)",
)
bots: list[BotConfig] = Field(
default_factory=list,
description="List of bot configurations",
)
mqtt_broker_host: str = Field(
default="",
description="MQTT broker hostname (empty = disabled)",
)
mqtt_broker_port: int = Field(
default=1883,
description="MQTT broker port",
)
mqtt_username: str = Field(
default="",
description="MQTT username (optional)",
)
mqtt_password: str = Field(
default="",
description="MQTT password (optional)",
)
mqtt_use_tls: bool = Field(
default=False,
description="Whether to use TLS for MQTT connection",
)
mqtt_tls_insecure: bool = Field(
default=False,
description="Skip TLS certificate verification (for self-signed certs)",
)
mqtt_topic_prefix: str = Field(
default="meshcore",
description="MQTT topic prefix",
)
mqtt_publish_messages: bool = Field(
default=False,
description="Whether to publish decrypted messages to MQTT",
)
mqtt_publish_raw_packets: bool = Field(
default=False,
description="Whether to publish raw packets to MQTT",
)
community_mqtt_enabled: bool = Field(
default=False,
description="Whether to publish raw packets to the community MQTT broker (letsmesh.net)",
)
community_mqtt_iata: str = Field(
default="",
description="IATA region code for community MQTT topic routing (3 alpha chars)",
)
community_mqtt_broker_host: str = Field(
default="mqtt-us-v1.letsmesh.net",
description="Community MQTT broker hostname",
)
community_mqtt_broker_port: int = Field(
default=443,
description="Community MQTT broker port",
)
community_mqtt_email: str = Field(
default="",
description="Email address for node claiming on the community aggregator (optional)",
)
flood_scope: str = Field(
default="",
description="Outbound flood scope / region name (empty = disabled, no tagging)",
@@ -537,7 +468,7 @@ class FanoutConfig(BaseModel):
"""Configuration for a single fanout integration."""
id: str
type: str # 'mqtt_private' | 'mqtt_community'
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise'
name: str
enabled: bool
config: dict

View File

@@ -4,7 +4,7 @@ import time
from typing import Any, Literal
from app.database import db
from app.models import AppSettings, BotConfig, Favorite
from app.models import AppSettings, Favorite
logger = logging.getLogger(__name__)
@@ -26,13 +26,7 @@ class AppSettingsRepository:
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, bots,
mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
mqtt_publish_messages, mqtt_publish_raw_packets,
community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port,
community_mqtt_email, flood_scope,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names
FROM app_settings WHERE id = 1
"""
@@ -69,20 +63,6 @@ class AppSettingsRepository:
)
last_message_times = {}
# Parse bots JSON
bots: list[BotConfig] = []
if row["bots"]:
try:
bots_data = json.loads(row["bots"])
bots = [BotConfig(**b) for b in bots_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse bots JSON, using empty list: %s (data=%r)",
e,
row["bots"][:100] if row["bots"] else None,
)
bots = []
# Parse blocked_keys JSON
blocked_keys: list[str] = []
if row["blocked_keys"]:
@@ -113,22 +93,6 @@ class AppSettingsRepository:
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0,
bots=bots,
mqtt_broker_host=row["mqtt_broker_host"] or "",
mqtt_broker_port=row["mqtt_broker_port"] or 1883,
mqtt_username=row["mqtt_username"] or "",
mqtt_password=row["mqtt_password"] or "",
mqtt_use_tls=bool(row["mqtt_use_tls"]),
mqtt_tls_insecure=bool(row["mqtt_tls_insecure"]),
mqtt_topic_prefix=row["mqtt_topic_prefix"] or "meshcore",
mqtt_publish_messages=bool(row["mqtt_publish_messages"]),
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
community_mqtt_enabled=bool(row["community_mqtt_enabled"]),
community_mqtt_iata=row["community_mqtt_iata"] or "",
community_mqtt_broker_host=row["community_mqtt_broker_host"]
or "mqtt-us-v1.letsmesh.net",
community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443,
community_mqtt_email=row["community_mqtt_email"] or "",
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
@@ -144,21 +108,6 @@ class AppSettingsRepository:
preferences_migrated: bool | None = None,
advert_interval: int | None = None,
last_advert_time: int | None = None,
bots: list[BotConfig] | None = None,
mqtt_broker_host: str | None = None,
mqtt_broker_port: int | None = None,
mqtt_username: str | None = None,
mqtt_password: str | None = None,
mqtt_use_tls: bool | None = None,
mqtt_tls_insecure: bool | None = None,
mqtt_topic_prefix: str | None = None,
mqtt_publish_messages: bool | None = None,
mqtt_publish_raw_packets: bool | None = None,
community_mqtt_enabled: bool | None = None,
community_mqtt_iata: str | None = None,
community_mqtt_broker_host: str | None = None,
community_mqtt_broker_port: int | None = None,
community_mqtt_email: str | None = None,
flood_scope: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
@@ -200,67 +149,6 @@ class AppSettingsRepository:
updates.append("last_advert_time = ?")
params.append(last_advert_time)
if bots is not None:
updates.append("bots = ?")
bots_json = json.dumps([b.model_dump() for b in bots])
params.append(bots_json)
if mqtt_broker_host is not None:
updates.append("mqtt_broker_host = ?")
params.append(mqtt_broker_host)
if mqtt_broker_port is not None:
updates.append("mqtt_broker_port = ?")
params.append(mqtt_broker_port)
if mqtt_username is not None:
updates.append("mqtt_username = ?")
params.append(mqtt_username)
if mqtt_password is not None:
updates.append("mqtt_password = ?")
params.append(mqtt_password)
if mqtt_use_tls is not None:
updates.append("mqtt_use_tls = ?")
params.append(1 if mqtt_use_tls else 0)
if mqtt_tls_insecure is not None:
updates.append("mqtt_tls_insecure = ?")
params.append(1 if mqtt_tls_insecure else 0)
if mqtt_topic_prefix is not None:
updates.append("mqtt_topic_prefix = ?")
params.append(mqtt_topic_prefix)
if mqtt_publish_messages is not None:
updates.append("mqtt_publish_messages = ?")
params.append(1 if mqtt_publish_messages else 0)
if mqtt_publish_raw_packets is not None:
updates.append("mqtt_publish_raw_packets = ?")
params.append(1 if mqtt_publish_raw_packets else 0)
if community_mqtt_enabled is not None:
updates.append("community_mqtt_enabled = ?")
params.append(1 if community_mqtt_enabled else 0)
if community_mqtt_iata is not None:
updates.append("community_mqtt_iata = ?")
params.append(community_mqtt_iata)
if community_mqtt_broker_host is not None:
updates.append("community_mqtt_broker_host = ?")
params.append(community_mqtt_broker_host)
if community_mqtt_broker_port is not None:
updates.append("community_mqtt_broker_port = ?")
params.append(community_mqtt_broker_port)
if community_mqtt_email is not None:
updates.append("community_mqtt_email = ?")
params.append(community_mqtt_email)
if flood_scope is not None:
updates.append("flood_scope = ?")
params.append(flood_scope)

View File

@@ -185,7 +185,6 @@ const baseSettings = {
preferences_migrated: false,
advert_interval: 0,
last_advert_time: 0,
bots: [],
};
const publicChannel = {

View File

@@ -212,7 +212,6 @@ describe('App search jump target handling', () => {
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
bots: [],
});
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
mocks.api.getChannels.mockResolvedValue([

View File

@@ -168,7 +168,6 @@ describe('App startup hash resolution', () => {
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
bots: [],
});
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
mocks.api.getChannels.mockResolvedValue([publicChannel]);

View File

@@ -51,21 +51,6 @@ const baseSettings: AppSettings = {
preferences_migrated: false,
advert_interval: 0,
last_advert_time: 0,
bots: [],
mqtt_broker_host: '',
mqtt_broker_port: 1883,
mqtt_username: '',
mqtt_password: '',
mqtt_use_tls: false,
mqtt_tls_insecure: false,
mqtt_topic_prefix: 'meshcore',
mqtt_publish_messages: false,
mqtt_publish_raw_packets: false,
community_mqtt_enabled: false,
community_mqtt_iata: '',
community_mqtt_broker_host: 'mqtt-us-v1.letsmesh.net',
community_mqtt_broker_port: 443,
community_mqtt_email: '',
flood_scope: '',
blocked_keys: [],
blocked_names: [],

View File

@@ -214,13 +214,6 @@ export interface Favorite {
id: string; // channel key or contact public key
}
export interface BotConfig {
id: string; // UUID for stable identity across renames/reorders
name: string; // User-editable name
enabled: boolean; // Whether this bot is enabled
code: string; // Python code for this bot
}
export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
@@ -230,21 +223,6 @@ export interface AppSettings {
preferences_migrated: boolean;
advert_interval: number;
last_advert_time: number;
bots: BotConfig[];
mqtt_broker_host: string;
mqtt_broker_port: number;
mqtt_username: string;
mqtt_password: string;
mqtt_use_tls: boolean;
mqtt_tls_insecure: boolean;
mqtt_topic_prefix: string;
mqtt_publish_messages: boolean;
mqtt_publish_raw_packets: boolean;
community_mqtt_enabled: boolean;
community_mqtt_iata: string;
community_mqtt_broker_host: string;
community_mqtt_broker_port: number;
community_mqtt_email: string;
flood_scope: string;
blocked_keys: string[];
blocked_names: string[];

View File

@@ -31,7 +31,7 @@ test.describe('Apprise integration settings', () => {
await page.getByRole('button', { name: 'Apprise' }).click();
// Should navigate to the detail/edit view with default name
await expect(page.getByDisplayValue('Apprise')).toBeVisible();
await expect(page.locator('#fanout-edit-name')).toHaveValue('Apprise');
// Fill in notification URL
const urlsTextarea = page.locator('#fanout-apprise-urls');
@@ -135,7 +135,7 @@ test.describe('Apprise integration settings', () => {
await page.getByText('All except listed channels/contacts').click();
// Should show channel and contact lists with exclude label
await expect(page.getByText('(exclude)')).toBeVisible();
await expect(page.getByText('Channels (exclude)')).toBeVisible();
// Go back
await page.getByText('← Back to list').click();
@@ -158,9 +158,9 @@ test.describe('Apprise integration settings', () => {
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
// Should show "Disabled" text
// Should show "Disabled" status text
const row = page.getByText('Disabled Apprise').locator('..');
await expect(row.getByText('Disabled')).toBeVisible();
await expect(row.getByText('Disabled', { exact: true })).toBeVisible();
// Edit it
await row.getByRole('button', { name: 'Edit' }).click();

View File

@@ -31,7 +31,7 @@ test.describe('Webhook integration settings', () => {
await page.getByRole('button', { name: 'Webhook' }).click();
// Should navigate to the detail/edit view with default name
await expect(page.getByDisplayValue('Webhook')).toBeVisible();
await expect(page.locator('#fanout-edit-name')).toHaveValue('Webhook');
// Fill in webhook URL
const urlInput = page.locator('#fanout-webhook-url');
@@ -85,7 +85,7 @@ test.describe('Webhook integration settings', () => {
await row.getByRole('button', { name: 'Edit' }).click();
// Should be in edit view
await expect(page.getByDisplayValue('API Webhook')).toBeVisible();
await expect(page.locator('#fanout-edit-name')).toHaveValue('API Webhook');
// Change method to PUT
await page.locator('#fanout-webhook-method').selectOption('PUT');
@@ -129,9 +129,8 @@ test.describe('Webhook integration settings', () => {
// Select "Only listed" to see channel/contact checkboxes
await page.getByText('Only listed channels/contacts').click();
// Should show Channels and Contacts sections
await expect(page.getByText('Channels')).toBeVisible();
await expect(page.getByText('Contacts')).toBeVisible();
// Should show Channels section (Contacts only appears if non-repeater contacts exist)
await expect(page.getByText('Channels (include)')).toBeVisible();
// Go back without saving
await page.getByText('← Back to list').click();

View File

@@ -79,7 +79,6 @@ class TestDMAckTrackingWiring:
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)
@@ -112,7 +111,6 @@ class TestDMAckTrackingWiring:
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)
@@ -142,7 +140,6 @@ class TestDMAckTrackingWiring:
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
await send_direct_message(request)
@@ -171,7 +168,6 @@ class TestDMAckTrackingWiring:
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)

View File

@@ -5,15 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import app.bot as bot_module
from app.bot import (
import app.fanout.bot_exec as bot_module
from app.fanout.bot_exec import (
BOT_MESSAGE_SPACING,
_bot_semaphore,
execute_bot_code,
process_bot_response,
run_bot_for_message,
)
from app.models import BotConfig
class TestExecuteBotCode:
@@ -414,336 +411,6 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name,
assert result is None
class TestRunBotForMessage:
"""Test the main bot entry point."""
@pytest.fixture(autouse=True)
def reset_semaphore(self):
"""Reset semaphore state between tests."""
# Ensure semaphore is fully released
while _bot_semaphore.locked():
_bot_semaphore.release()
yield
@pytest.mark.asyncio
async def test_runs_for_outgoing_messages(self):
"""Bot is triggered for outgoing messages (user can trigger their own bots)."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Echo", enabled=True, code="def bot(**k): return 'echo'")
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", return_value="echo") as mock_exec,
patch("app.bot.process_bot_response", new_callable=AsyncMock),
):
await run_bot_for_message(
sender_name="Me",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
is_outgoing=True,
)
# Bot should actually execute for outgoing messages
mock_exec.assert_called_once()
@pytest.mark.asyncio
async def test_skips_when_no_enabled_bots(self):
"""Bot is not triggered when no bots are enabled."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=False, code="def bot(): pass")
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with patch("app.bot.execute_bot_code") as mock_exec:
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_bots_array_empty(self):
"""Bot is not triggered when bots array is empty."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = []
mock_repo.get = AsyncMock(return_value=mock_settings)
with patch("app.bot.execute_bot_code") as mock_exec:
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_skips_bot_with_empty_code(self):
"""Bot with empty code is skipped even if enabled."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Empty Bot", enabled=True, code=""),
BotConfig(id="2", name="Whitespace Bot", enabled=True, code=" "),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with patch("app.bot.execute_bot_code") as mock_exec:
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_rechecks_settings_after_sleep(self):
"""Settings are re-checked after 2 second sleep."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
# First call: bot enabled
# Second call (after sleep): bot disabled
mock_settings_enabled = MagicMock()
mock_settings_enabled.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="def bot(): return 'hi'")
]
mock_settings_disabled = MagicMock()
mock_settings_disabled.bots = [
BotConfig(id="1", name="Bot 1", enabled=False, code="def bot(): return 'hi'")
]
mock_repo.get = AsyncMock(side_effect=[mock_settings_enabled, mock_settings_disabled])
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.bot.execute_bot_code") as mock_exec,
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Should have slept
mock_sleep.assert_called_once_with(2)
# Should NOT have executed bot (disabled after sleep)
mock_exec.assert_not_called()
class TestMultipleBots:
"""Test multiple bots functionality."""
@pytest.fixture(autouse=True)
def reset_semaphore(self):
"""Reset semaphore state between tests."""
while _bot_semaphore.locked():
_bot_semaphore.release()
yield
@pytest.fixture(autouse=True)
def reset_rate_limit_state(self):
"""Reset rate limiting state between tests."""
bot_module._last_bot_send_time = 0.0
yield
bot_module._last_bot_send_time = 0.0
@pytest.mark.asyncio
async def test_multiple_bots_execute_serially(self):
"""Multiple enabled bots execute serially in order."""
executed_bots = []
def mock_execute(code, *args, **kwargs):
# Extract bot identifier from the code
if "Bot 1" in code:
executed_bots.append("Bot 1")
return "Response 1"
elif "Bot 2" in code:
executed_bots.append("Bot 2")
return "Response 2"
return None
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", side_effect=mock_execute),
patch("app.bot.process_bot_response", new_callable=AsyncMock),
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Both bots should have executed in order
assert executed_bots == ["Bot 1", "Bot 2"]
@pytest.mark.asyncio
async def test_disabled_bots_are_skipped(self):
"""Disabled bots in the array are skipped."""
executed_bots = []
def mock_execute(code, *args, **kwargs):
if "Bot 1" in code:
executed_bots.append("Bot 1")
elif "Bot 2" in code:
executed_bots.append("Bot 2")
elif "Bot 3" in code:
executed_bots.append("Bot 3")
return None
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
BotConfig(id="2", name="Bot 2", enabled=False, code="# Bot 2\ndef bot(): pass"),
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", side_effect=mock_execute),
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Only enabled bots should have executed
assert executed_bots == ["Bot 1", "Bot 3"]
@pytest.mark.asyncio
async def test_error_in_one_bot_doesnt_stop_others(self):
"""Error in one bot doesn't prevent other bots from running."""
executed_bots = []
def mock_execute(code, *args, **kwargs):
if "Bot 1" in code:
executed_bots.append("Bot 1")
raise ValueError("Bot 1 crashed!")
elif "Bot 2" in code:
executed_bots.append("Bot 2")
return "Response 2"
elif "Bot 3" in code:
executed_bots.append("Bot 3")
return "Response 3"
return None
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", side_effect=mock_execute),
patch("app.bot.process_bot_response", new_callable=AsyncMock) as mock_respond,
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# All bots should have been attempted
assert executed_bots == ["Bot 1", "Bot 2", "Bot 3"]
# Responses from successful bots should have been sent
assert mock_respond.call_count == 2
@pytest.mark.asyncio
async def test_timeout_in_one_bot_doesnt_stop_others(self):
"""Timeout in one bot doesn't prevent other bots from running."""
executed_bots = []
async def mock_wait_for(coro, timeout):
result = await coro
# Simulate timeout for Bot 2
if len(executed_bots) == 2 and executed_bots[-1] == "Bot 2":
raise asyncio.TimeoutError()
return result
def mock_execute(code, *args, **kwargs):
if "Bot 1" in code:
executed_bots.append("Bot 1")
return "Response 1"
elif "Bot 2" in code:
executed_bots.append("Bot 2")
return "Response 2" # This will be "timed out"
elif "Bot 3" in code:
executed_bots.append("Bot 3")
return "Response 3"
return None
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", side_effect=mock_execute),
patch("app.bot.asyncio.wait_for", side_effect=mock_wait_for),
patch("app.bot.process_bot_response", new_callable=AsyncMock) as mock_respond,
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# All bots should have been attempted
assert executed_bots == ["Bot 1", "Bot 2", "Bot 3"]
# Only responses from non-timed-out bots (Bot 1 and Bot 3)
assert mock_respond.call_count == 2
class TestBotCodeValidation:
"""Test bot code syntax validation via fanout router."""
@@ -804,8 +471,8 @@ class TestBotMessageRateLimiting:
async def test_first_send_does_not_wait(self):
"""First bot send should not wait (no previous send)."""
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.routers.messages.send_direct_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -832,8 +499,8 @@ class TestBotMessageRateLimiting:
bot_module._last_bot_send_time = 100.0
with (
patch("app.bot.time.monotonic", return_value=100.5),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.bot_exec.time.monotonic", return_value=100.5),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.routers.messages.send_direct_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -860,8 +527,8 @@ class TestBotMessageRateLimiting:
bot_module._last_bot_send_time = 97.0
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.routers.messages.send_direct_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -883,7 +550,7 @@ class TestBotMessageRateLimiting:
async def test_timestamp_updated_after_successful_send(self):
"""Last send timestamp should be updated after successful send."""
with (
patch("app.bot.time.monotonic", return_value=150.0),
patch("app.fanout.bot_exec.time.monotonic", return_value=150.0),
patch("app.routers.messages.send_direct_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -908,7 +575,7 @@ class TestBotMessageRateLimiting:
bot_module._last_bot_send_time = 50.0 # Previous timestamp
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch(
"app.routers.messages.send_direct_message",
new_callable=AsyncMock,
@@ -930,7 +597,7 @@ class TestBotMessageRateLimiting:
"""Last send timestamp should NOT be updated if no destination."""
bot_module._last_bot_send_time = 50.0
with patch("app.bot.time.monotonic", return_value=100.0):
with patch("app.fanout.bot_exec.time.monotonic", return_value=100.0):
await process_bot_response(
response="Hello!",
is_dm=False, # Not a DM
@@ -964,8 +631,8 @@ class TestBotMessageRateLimiting:
time_counter[0] += duration
with (
patch("app.bot.time.monotonic", side_effect=mock_monotonic),
patch("app.bot.asyncio.sleep", side_effect=mock_sleep),
patch("app.fanout.bot_exec.time.monotonic", side_effect=mock_monotonic),
patch("app.fanout.bot_exec.asyncio.sleep", side_effect=mock_sleep),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
@@ -990,8 +657,8 @@ class TestBotMessageRateLimiting:
bot_module._last_bot_send_time = 99.0 # 1 second ago
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.routers.messages.send_channel_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -1035,8 +702,8 @@ class TestBotListResponses:
return mock_message
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
@@ -1069,8 +736,8 @@ class TestBotListResponses:
return mock_message
with (
patch("app.bot.time.monotonic", side_effect=mock_monotonic),
patch("app.bot.asyncio.sleep", side_effect=mock_sleep),
patch("app.fanout.bot_exec.time.monotonic", side_effect=mock_monotonic),
patch("app.fanout.bot_exec.asyncio.sleep", side_effect=mock_sleep),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
@@ -1098,8 +765,8 @@ class TestBotListResponses:
return mock_message
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):

View File

@@ -3,12 +3,13 @@
import json
import time
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import nacl.bindings
import pytest
from app.community_mqtt import (
from app.fanout.community_mqtt import (
_CLIENT_ID,
_DEFAULT_BROKER,
_STATS_REFRESH_INTERVAL,
@@ -22,7 +23,6 @@ from app.community_mqtt import (
_generate_jwt_token,
_get_client_version,
)
from app.models import AppSettings
def _make_test_keys() -> tuple[bytes, bytes]:
@@ -49,6 +49,19 @@ def _make_test_keys() -> tuple[bytes, bytes]:
return private_key, public_key
def _make_community_settings(**overrides) -> SimpleNamespace:
"""Create a settings namespace with all community MQTT fields."""
defaults = {
"community_mqtt_enabled": True,
"community_mqtt_broker_host": "mqtt-us-v1.letsmesh.net",
"community_mqtt_broker_port": 443,
"community_mqtt_iata": "",
"community_mqtt_email": "",
}
defaults.update(overrides)
return SimpleNamespace(**defaults)
class TestBase64UrlEncode:
def test_encodes_without_padding(self):
result = _base64url_encode(b"\x00\x01\x02")
@@ -376,19 +389,19 @@ class TestCommunityMqttPublisher:
def test_is_configured_false_when_disabled(self):
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=False)
pub._settings = SimpleNamespace(community_mqtt_enabled=False)
with patch("app.keystore.has_private_key", return_value=True):
assert pub._is_configured() is False
def test_is_configured_false_when_no_private_key(self):
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=True)
pub._settings = SimpleNamespace(community_mqtt_enabled=True)
with patch("app.keystore.has_private_key", return_value=False):
assert pub._is_configured() is False
def test_is_configured_true_when_enabled_with_key(self):
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=True)
pub._settings = SimpleNamespace(community_mqtt_enabled=True)
with patch("app.keystore.has_private_key", return_value=True):
assert pub._is_configured() is True
@@ -408,12 +421,12 @@ class TestPublishFailureSetsDisconnected:
class TestBuildStatusTopic:
def test_builds_correct_topic(self):
settings = AppSettings(community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_iata="LAX")
topic = _build_status_topic(settings, "AABB1122")
assert topic == "meshcore/LAX/AABB1122/status"
def test_iata_uppercased_and_stripped(self):
settings = AppSettings(community_mqtt_iata=" lax ")
settings = SimpleNamespace(community_mqtt_iata=" lax ")
topic = _build_status_topic(settings, "PUBKEY")
assert topic == "meshcore/LAX/PUBKEY/status"
@@ -424,10 +437,7 @@ class TestLwtAndStatusPublish:
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
pubkey_hex = public_key.hex().upper()
settings = AppSettings(
community_mqtt_enabled=True,
community_mqtt_iata="SFO",
)
settings = _make_community_settings(community_mqtt_iata="SFO")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
@@ -457,7 +467,7 @@ class TestLwtAndStatusPublish:
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
pubkey_hex = public_key.hex().upper()
settings = AppSettings(
settings = SimpleNamespace(
community_mqtt_enabled=True,
community_mqtt_iata="LAX",
)
@@ -478,8 +488,8 @@ class TestLwtAndStatusPublish:
patch.object(
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
),
patch("app.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._on_connected_async(settings)
@@ -507,10 +517,7 @@ class TestLwtAndStatusPublish:
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
pubkey_hex = public_key.hex().upper()
settings = AppSettings(
community_mqtt_enabled=True,
community_mqtt_iata="JFK",
)
settings = _make_community_settings(community_mqtt_iata="JFK")
mock_radio = MagicMock()
mock_radio.meshcore = None
@@ -530,7 +537,7 @@ class TestLwtAndStatusPublish:
async def test_on_connected_async_skips_when_no_public_key(self):
"""_on_connected_async should no-op when public key is unavailable."""
pub = CommunityMqttPublisher()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
with (
patch("app.keystore.get_public_key", return_value=None),
@@ -545,7 +552,7 @@ class TestLwtAndStatusPublish:
"""Should use 'MeshCore Device' when radio name is unavailable."""
pub = CommunityMqttPublisher()
_, public_key = _make_test_keys()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = None
@@ -560,8 +567,10 @@ class TestLwtAndStatusPublish:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm unknown"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._on_connected_async(settings)
@@ -844,14 +853,15 @@ class TestGetClientVersion:
def test_returns_version_from_metadata(self):
"""Should use importlib.metadata to get version."""
with patch("app.community_mqtt.importlib.metadata.version", return_value="1.2.3"):
with patch("app.fanout.community_mqtt.importlib.metadata.version", return_value="1.2.3"):
result = _get_client_version()
assert result == "RemoteTerm 1.2.3"
def test_fallback_on_error(self):
"""Should return 'RemoteTerm unknown' if metadata lookup fails."""
with patch(
"app.community_mqtt.importlib.metadata.version", side_effect=Exception("not found")
"app.fanout.community_mqtt.importlib.metadata.version",
side_effect=Exception("not found"),
):
result = _get_client_version()
assert result == "RemoteTerm unknown"
@@ -864,7 +874,7 @@ class TestPublishStatus:
pub = CommunityMqttPublisher()
_, public_key = _make_test_keys()
pubkey_hex = public_key.hex().upper()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
@@ -882,8 +892,8 @@ class TestPublishStatus:
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
patch("app.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
@@ -904,7 +914,7 @@ class TestPublishStatus:
"""Should not include 'stats' key when stats are None."""
pub = CommunityMqttPublisher()
_, public_key = _make_test_keys()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = None
@@ -919,8 +929,10 @@ class TestPublishStatus:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm unknown"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
@@ -933,7 +945,7 @@ class TestPublishStatus:
"""Should update _last_status_publish after publishing."""
pub = CommunityMqttPublisher()
_, public_key = _make_test_keys()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = None
@@ -950,8 +962,10 @@ class TestPublishStatus:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm unknown"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock),
):
await pub._publish_status(settings)
@@ -962,7 +976,7 @@ class TestPublishStatus:
async def test_no_publish_key_returns_none(self):
"""Should skip publish when public key is unavailable."""
pub = CommunityMqttPublisher()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
with (
patch("app.keystore.get_public_key", return_value=None),
@@ -978,7 +992,7 @@ class TestPeriodicWake:
async def test_skips_before_interval(self):
"""Should not republish before _STATS_REFRESH_INTERVAL."""
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
pub._settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
pub._last_status_publish = time.monotonic() # Just published
with patch.object(pub, "_publish_status", new_callable=AsyncMock) as mock_ps:
@@ -990,7 +1004,7 @@ class TestPeriodicWake:
async def test_publishes_after_interval(self):
"""Should republish after _STATS_REFRESH_INTERVAL elapsed."""
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
pub._settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
pub._last_status_publish = time.monotonic() - _STATS_REFRESH_INTERVAL - 1
with patch.object(pub, "_publish_status", new_callable=AsyncMock) as mock_ps:

View File

@@ -1,19 +1,16 @@
"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag.
Verifies that when disable_bots=True:
- run_bot_for_message() exits immediately without any work
- POST /api/fanout with type=bot returns 403
- Health endpoint includes bots_disabled=True
"""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from app.bot import run_bot_for_message
from app.config import Settings
from app.models import BotConfig
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
from app.routers.health import build_health_data
@@ -30,54 +27,6 @@ class TestDisableBotsConfig:
assert s.disable_bots is True
class TestDisableBotsBotExecution:
"""Test that run_bot_for_message exits immediately when bots are disabled."""
@pytest.mark.asyncio
async def test_returns_immediately_when_disabled(self):
"""No settings load, no semaphore, no bot execution."""
with patch("app.bot.server_settings", MagicMock(disable_bots=True)):
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_repo.get = AsyncMock()
await run_bot_for_message(
sender_name="Alice",
sender_key="ab" * 32,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Should never even load settings
mock_repo.get.assert_not_called()
@pytest.mark.asyncio
async def test_runs_normally_when_not_disabled(self):
"""Bots execute normally when disable_bots is False."""
with patch("app.bot.server_settings", MagicMock(disable_bots=False)):
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Echo", enabled=True, code="def bot(**k): return 'echo'")
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", return_value="echo") as mock_exec,
patch("app.bot.process_bot_response", new_callable=AsyncMock),
):
await run_bot_for_message(
sender_name="Alice",
sender_key="ab" * 32,
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_called_once()
class TestDisableBotsFanoutEndpoint:
"""Test that bot creation via fanout router is rejected when bots are disabled."""

View File

@@ -1,6 +1,5 @@
"""Tests for fanout bus: manager, scope matching, repository, and modules."""
import json
from unittest.mock import AsyncMock, patch
import pytest
@@ -394,260 +393,6 @@ class TestBroadcastEventRealtime:
mock_fm.broadcast_message.assert_called_once()
# ---------------------------------------------------------------------------
# Migration test
# ---------------------------------------------------------------------------
def _create_app_settings_table_sql():
"""SQL to create app_settings with all MQTT columns for migration testing."""
return """
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0,
advert_interval INTEGER DEFAULT 0,
last_advert_time INTEGER DEFAULT 0,
bots TEXT DEFAULT '[]',
mqtt_broker_host TEXT DEFAULT '',
mqtt_broker_port INTEGER DEFAULT 1883,
mqtt_username TEXT DEFAULT '',
mqtt_password TEXT DEFAULT '',
mqtt_use_tls INTEGER DEFAULT 0,
mqtt_tls_insecure INTEGER DEFAULT 0,
mqtt_topic_prefix TEXT DEFAULT 'meshcore',
mqtt_publish_messages INTEGER DEFAULT 0,
mqtt_publish_raw_packets INTEGER DEFAULT 0,
community_mqtt_enabled INTEGER DEFAULT 0,
community_mqtt_iata TEXT DEFAULT '',
community_mqtt_broker_host TEXT DEFAULT 'mqtt-us-v1.letsmesh.net',
community_mqtt_broker_port INTEGER DEFAULT 443,
community_mqtt_email TEXT DEFAULT '',
flood_scope TEXT DEFAULT '',
blocked_keys TEXT DEFAULT '[]',
blocked_names TEXT DEFAULT '[]'
)
"""
class TestMigration036:
@pytest.mark.asyncio
async def test_fanout_configs_table_created(self):
"""Migration 36 should create the fanout_configs table."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
await db.conn.commit()
try:
await _migrate_036_create_fanout_configs(db.conn)
cursor = await db.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
)
row = await cursor.fetchone()
assert row is not None
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_creates_mqtt_private_from_settings(self):
"""Migration should create mqtt_private config from existing MQTT settings."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute(
"""INSERT OR REPLACE INTO app_settings (id, mqtt_broker_host, mqtt_broker_port,
mqtt_username, mqtt_password, mqtt_use_tls, mqtt_tls_insecure,
mqtt_topic_prefix, mqtt_publish_messages, mqtt_publish_raw_packets)
VALUES (1, 'broker.local', 1883, 'user', 'pass', 0, 0, 'mesh', 1, 0)"""
)
await db.conn.commit()
try:
await _migrate_036_create_fanout_configs(db.conn)
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs WHERE type = 'mqtt_private'"
)
row = await cursor.fetchone()
assert row is not None
config = json.loads(row["config"])
assert config["broker_host"] == "broker.local"
assert config["username"] == "user"
scope = json.loads(row["scope"])
assert scope["messages"] == "all"
assert scope["raw_packets"] == "none"
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_creates_community_from_settings(self):
"""Migration should create mqtt_community config when community was enabled."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute(
"""INSERT OR REPLACE INTO app_settings (id, community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port, community_mqtt_email)
VALUES (1, 1, 'DEN', 'mqtt-us-v1.letsmesh.net', 443, 'test@example.com')"""
)
await db.conn.commit()
try:
await _migrate_036_create_fanout_configs(db.conn)
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs WHERE type = 'mqtt_community'"
)
row = await cursor.fetchone()
assert row is not None
assert bool(row["enabled"])
config = json.loads(row["config"])
assert config["iata"] == "DEN"
assert config["email"] == "test@example.com"
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_skips_when_no_mqtt_configured(self):
"""Migration should not create rows when MQTT was not configured."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
await db.conn.commit()
try:
await _migrate_036_create_fanout_configs(db.conn)
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs")
row = await cursor.fetchone()
assert row[0] == 0
finally:
await db.disconnect()
async def _setup_db_with_fanout_table():
"""Create a DB with app_settings + fanout_configs tables for migration 37 tests."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
await db.conn.commit()
await _migrate_036_create_fanout_configs(db.conn)
return db
class TestMigration037:
@pytest.mark.asyncio
async def test_migration_creates_bot_from_settings(self):
"""Migration should create a fanout_configs row for each bot in app_settings."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
bots_json = json.dumps(
[
{
"id": "bot-1",
"name": "EchoBot",
"enabled": True,
"code": "def bot(**k): return 'echo'",
},
{
"id": "bot-2",
"name": "Quiet",
"enabled": False,
"code": "def bot(**k): pass",
},
]
)
await db.conn.execute("UPDATE app_settings SET bots = ? WHERE id = 1", (bots_json,))
await db.conn.commit()
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs WHERE type = 'bot' ORDER BY sort_order"
)
rows = await cursor.fetchall()
assert len(rows) == 2
# First bot
assert rows[0]["name"] == "EchoBot"
assert bool(rows[0]["enabled"])
config0 = json.loads(rows[0]["config"])
assert config0["code"] == "def bot(**k): return 'echo'"
scope0 = json.loads(rows[0]["scope"])
assert scope0["messages"] == "all"
assert scope0["raw_packets"] == "none"
assert rows[0]["sort_order"] == 200
# Second bot
assert rows[1]["name"] == "Quiet"
assert not bool(rows[1]["enabled"])
assert rows[1]["sort_order"] == 201
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_skips_when_no_bots(self):
"""Migration should not create rows when there are no bots."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
assert row[0] == 0
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_handles_empty_bots_array(self):
"""Migration handles bots=[] gracefully."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
await db.conn.execute("UPDATE app_settings SET bots = '[]' WHERE id = 1")
await db.conn.commit()
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
assert row[0] == 0
finally:
await db.disconnect()
# ---------------------------------------------------------------------------
# Webhook module unit tests
# ---------------------------------------------------------------------------

View File

@@ -174,7 +174,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -218,7 +218,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -264,7 +264,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -297,7 +297,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -345,7 +345,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -382,7 +382,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),

View File

@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 37 # All migrations run
assert await get_version(conn) == 37
assert applied == 38 # All migrations run
assert await get_version(conn) == 38
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -183,9 +183,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
assert applied1 == 37 # All migrations run
assert applied1 == 38 # All migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 37
assert await get_version(conn) == 38
finally:
await conn.close()
@@ -246,8 +246,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error
assert applied == 37
assert await get_version(conn) == 37
assert applied == 38
assert await get_version(conn) == 38
finally:
await conn.close()
@@ -374,28 +374,27 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14-37 which also run)
# Run migration 13 (plus 14-38 which also run)
applied = await run_migrations(conn)
assert applied == 25
assert await get_version(conn) == 37
assert applied == 26
assert await get_version(conn) == 38
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
# Bots were migrated from app_settings to fanout_configs (migration 37)
# and the bots column was dropped (migration 38)
cursor = await conn.execute("SELECT * FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
bots = json.loads(row["bots"])
assert row is not None
assert len(bots) == 1
assert bots[0]["name"] == "Bot 1"
assert bots[0]["enabled"] is True
assert bots[0]["code"] == 'def bot(): return "hello"'
assert "id" in bots[0] # Should have a UUID
config = json.loads(row["config"])
assert config["code"] == 'def bot(): return "hello"'
assert row["name"] == "Bot 1"
assert bool(row["enabled"])
finally:
await conn.close()
@pytest.mark.asyncio
async def test_migration_creates_empty_array_when_no_bot(self):
"""Migration creates empty bots array when no existing bot data."""
import json
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
@@ -424,11 +423,10 @@ class TestMigration013:
await run_migrations(conn)
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
# Bots column was dropped by migration 38; verify no bots in fanout_configs
cursor = await conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
bots = json.loads(row["bots"])
assert bots == []
assert row[0] == 0
finally:
await conn.close()
@@ -497,7 +495,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 37
assert await get_version(conn) == 38
# Verify autoindex is gone
cursor = await conn.execute(
@@ -575,8 +573,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 20 # Migrations 18-37 run (18+19 skip internally)
assert await get_version(conn) == 37
assert applied == 21 # Migrations 18-38 run (18+19 skip internally)
assert await get_version(conn) == 38
finally:
await conn.close()
@@ -648,7 +646,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 37
assert await get_version(conn) == 38
# Verify autoindex is gone
cursor = await conn.execute(
@@ -714,8 +712,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
assert applied == 18 # Migrations 20-37
assert await get_version(conn) == 37
assert applied == 19 # Migrations 20-38
assert await get_version(conn) == 38
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -745,7 +743,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
assert applied == 17 # Migrations 21-37 still run
assert applied == 18 # Migrations 21-38 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -803,8 +801,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 10
assert await get_version(conn) == 37
assert applied == 11
assert await get_version(conn) == 38
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -873,8 +871,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 10 # Version still bumped
assert await get_version(conn) == 37
assert applied == 11 # Version still bumped
assert await get_version(conn) == 38
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -923,22 +921,16 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 37
assert applied == 7
assert await get_version(conn) == 38
# Verify all columns exist with correct defaults
# Community MQTT columns were added by migration 32 and dropped by migration 38.
# Verify community settings were NOT migrated (no community config existed).
cursor = await conn.execute(
"""SELECT community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port,
community_mqtt_email
FROM app_settings WHERE id = 1"""
"SELECT COUNT(*) FROM fanout_configs WHERE type = 'mqtt_community'"
)
row = await cursor.fetchone()
assert row["community_mqtt_enabled"] == 0
assert row["community_mqtt_iata"] == ""
assert row["community_mqtt_broker_host"] == "mqtt-us-v1.letsmesh.net"
assert row["community_mqtt_broker_port"] == 443
assert row["community_mqtt_email"] == ""
assert row[0] == 0
finally:
await conn.close()
@@ -996,8 +988,8 @@ class TestMigration034:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 37
assert applied == 5
assert await get_version(conn) == 38
# Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1039,8 +1031,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 37
assert applied == 6
assert await get_version(conn) == 38
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",

View File

@@ -1,28 +1,29 @@
"""Tests for MQTT publisher module."""
import ssl
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.models import AppSettings
from app.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
from app.fanout.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
def _make_settings(**overrides) -> AppSettings:
"""Create an AppSettings with MQTT fields."""
def _make_settings(**overrides) -> SimpleNamespace:
"""Create a settings namespace with MQTT fields."""
defaults = {
"mqtt_broker_host": "broker.local",
"mqtt_broker_port": 1883,
"mqtt_username": "",
"mqtt_password": "",
"mqtt_use_tls": False,
"mqtt_tls_insecure": False,
"mqtt_topic_prefix": "meshcore",
"mqtt_publish_messages": True,
"mqtt_publish_raw_packets": True,
}
defaults.update(overrides)
return AppSettings(**defaults)
return SimpleNamespace(**defaults)
class TestTopicBuilders:
@@ -214,8 +215,8 @@ class TestConnectionLoop:
mock_client.__aenter__ = AsyncMock(side_effect=side_effect_aenter)
with (
patch("app.mqtt_base.aiomqtt.Client", return_value=mock_client),
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base.aiomqtt.Client", return_value=mock_client),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_health"),
):
@@ -235,7 +236,7 @@ class TestConnectionLoop:
"""Connection loop should retry after a connection error with backoff."""
import asyncio
from app.mqtt_base import _BACKOFF_MIN
from app.fanout.mqtt_base import _BACKOFF_MIN
pub = MqttPublisher()
settings = _make_settings()
@@ -268,12 +269,12 @@ class TestConnectionLoop:
return factory
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_client_factory()),
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=make_client_factory()),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
patch("app.mqtt_base.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.mqtt_base.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
):
await pub.start(settings)
@@ -292,7 +293,7 @@ class TestConnectionLoop:
"""Backoff should double after each failure, capped at _backoff_max."""
import asyncio
from app.mqtt_base import _BACKOFF_MIN
from app.fanout.mqtt_base import _BACKOFF_MIN
pub = MqttPublisher()
settings = _make_settings()
@@ -322,11 +323,11 @@ class TestConnectionLoop:
raise asyncio.CancelledError
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=factory),
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=factory),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
patch("app.mqtt_base.asyncio.sleep", side_effect=capture_sleep),
patch("app.fanout.mqtt_base.asyncio.sleep", side_effect=capture_sleep),
):
await pub.start(settings)
try:
@@ -363,8 +364,8 @@ class TestConnectionLoop:
return mock
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_success_client),
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=make_success_client),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_health"),
):
@@ -411,8 +412,8 @@ class TestConnectionLoop:
return mock
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_client),
patch("app.mqtt_base._broadcast_health", side_effect=track_health),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=make_client),
patch("app.fanout.mqtt_base._broadcast_health", side_effect=track_health),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_health"),
):
@@ -448,11 +449,11 @@ class TestConnectionLoop:
return mock
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_failing_client),
patch("app.mqtt_base._broadcast_health", side_effect=track_health),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=make_failing_client),
patch("app.fanout.mqtt_base._broadcast_health", side_effect=track_health),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
patch("app.mqtt_base.asyncio.sleep", side_effect=cancel_on_sleep),
patch("app.fanout.mqtt_base.asyncio.sleep", side_effect=cancel_on_sleep),
):
await pub.start(settings)
try:

View File

@@ -492,21 +492,6 @@ class TestAppSettingsRepository:
"preferences_migrated": 0,
"advert_interval": None,
"last_advert_time": None,
"bots": "{bad-bots-json",
"mqtt_broker_host": "",
"mqtt_broker_port": 1883,
"mqtt_username": "",
"mqtt_password": "",
"mqtt_use_tls": 0,
"mqtt_tls_insecure": 0,
"mqtt_topic_prefix": "meshcore",
"mqtt_publish_messages": 0,
"mqtt_publish_raw_packets": 0,
"community_mqtt_enabled": 0,
"community_mqtt_iata": "",
"community_mqtt_broker_host": "mqtt-us-v1.letsmesh.net",
"community_mqtt_broker_port": 443,
"community_mqtt_email": "",
"flood_scope": "",
"blocked_keys": "[]",
"blocked_names": "[]",
@@ -525,7 +510,6 @@ class TestAppSettingsRepository:
assert settings.favorites == []
assert settings.last_message_times == {}
assert settings.sidebar_sort_order == "recent"
assert settings.bots == []
assert settings.advert_interval == 0
assert settings.last_advert_time == 0