From 7cd54d14d85214b66f54fa411d3b4cf258aa6e1f Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 17:16:13 -0800
Subject: [PATCH 01/28] Move to modular fanout bus
---
app/community_mqtt.py | 47 --
app/fanout/AGENTS_fanout.md | 92 +++
app/fanout/__init__.py | 8 +
app/fanout/base.py | 34 ++
app/fanout/manager.py | 162 ++++++
app/fanout/mqtt_community.py | 89 +++
app/fanout/mqtt_private.py | 62 ++
app/main.py | 17 +-
app/migrations.py | 135 +++++
app/models.py | 13 +
app/mqtt.py | 33 --
app/packet_processor.py | 2 +
app/repository/__init__.py | 2 +
app/repository/fanout.py | 137 +++++
app/routers/fanout.py | 165 ++++++
app/routers/health.py | 30 +-
app/routers/settings.py | 133 -----
app/websocket.py | 21 +-
frontend/src/api.ts | 32 ++
frontend/src/components/SettingsModal.tsx | 17 +-
.../settings/SettingsFanoutSection.tsx | 505 +++++++++++++++++
.../settings/SettingsMqttSection.tsx | 451 ---------------
.../components/settings/settingsConstants.ts | 6 +-
frontend/src/test/settingsModal.test.tsx | 158 +-----
frontend/src/types.ts | 34 +-
tests/conftest.py | 2 +-
tests/test_community_mqtt.py | 34 --
tests/test_fanout.py | 528 ++++++++++++++++++
tests/test_fanout_integration.py | 403 +++++++++++++
tests/test_health_mqtt_status.py | 110 +---
tests/test_migrations.py | 52 +-
tests/test_mqtt.py | 114 +---
tests/test_settings_router.py | 124 ----
tests/test_websocket.py | 29 +-
34 files changed, 2489 insertions(+), 1292 deletions(-)
create mode 100644 app/fanout/AGENTS_fanout.md
create mode 100644 app/fanout/__init__.py
create mode 100644 app/fanout/base.py
create mode 100644 app/fanout/manager.py
create mode 100644 app/fanout/mqtt_community.py
create mode 100644 app/fanout/mqtt_private.py
create mode 100644 app/repository/fanout.py
create mode 100644 app/routers/fanout.py
create mode 100644 frontend/src/components/settings/SettingsFanoutSection.tsx
delete mode 100644 frontend/src/components/settings/SettingsMqttSection.tsx
create mode 100644 tests/test_fanout.py
create mode 100644 tests/test_fanout_integration.py
diff --git a/app/community_mqtt.py b/app/community_mqtt.py
index 72fe6a9b..ba9836f0 100644
--- a/app/community_mqtt.py
+++ b/app/community_mqtt.py
@@ -555,50 +555,3 @@ class CommunityMqttPublisher(BaseMqttPublisher):
pass
return False
return True
-
-
-# Module-level singleton
-community_publisher = CommunityMqttPublisher()
-
-
-def community_mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
- """Fire-and-forget community MQTT publish for raw packets only."""
- if event_type != "raw_packet":
- return
- if not community_publisher.connected or community_publisher._settings is None:
- return
- asyncio.create_task(_community_maybe_publish(data))
-
-
-async def _community_maybe_publish(data: dict[str, Any]) -> None:
- """Format and publish a raw packet to the community broker."""
- settings = community_publisher._settings
- if settings is None or not settings.community_mqtt_enabled:
- return
-
- try:
- from app.keystore import get_public_key
- from app.radio import radio_manager
-
- public_key = get_public_key()
- if public_key is None:
- return
-
- pubkey_hex = public_key.hex().upper()
-
- # Get device name from radio
- device_name = ""
- if radio_manager.meshcore and radio_manager.meshcore.self_info:
- device_name = radio_manager.meshcore.self_info.get("name", "")
-
- packet = _format_raw_packet(data, device_name, pubkey_hex)
- iata = settings.community_mqtt_iata.upper().strip()
- if not _IATA_RE.fullmatch(iata):
- logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
- return
- topic = f"meshcore/{iata}/{pubkey_hex}/packets"
-
- await community_publisher.publish(topic, packet)
-
- except Exception as e:
- logger.warning("Community MQTT broadcast error: %s", e)
diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md
new file mode 100644
index 00000000..9e66d56f
--- /dev/null
+++ b/app/fanout/AGENTS_fanout.md
@@ -0,0 +1,92 @@
+# Fanout Bus Architecture
+
+The fanout bus is a unified system for dispatching mesh radio events (decoded messages and raw packets) to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
+
+## Core Concepts
+
+### FanoutModule (base.py)
+Abstract base class that all integration modules implement:
+- `start()` / `stop()` — lifecycle management
+- `on_message(data)` — receive decoded messages
+- `on_raw(data)` — receive raw packets
+- `status` property — "connected" | "disconnected"
+
+### FanoutManager (manager.py)
+Singleton that owns all active modules and dispatches events:
+- `load_from_db()` — startup: load enabled configs, instantiate modules
+- `reload_config(id)` — CRUD: stop old, start new
+- `remove_config(id)` — delete: stop and remove
+- `broadcast_message(data)` — scope-check + dispatch `on_message`
+- `broadcast_raw(data)` — scope-check + dispatch `on_raw`
+- `stop_all()` — shutdown
+- `get_statuses()` — health endpoint data
+
+### Scope Matching
+Each config has a `scope` JSON blob controlling what events reach it:
+```json
+{"messages": "all", "raw_packets": "all"}
+{"messages": "none", "raw_packets": "all"}
+{"messages": {"channels": ["key1"], "contacts": "all"}, "raw_packets": "none"}
+```
+Community MQTT always enforces `{"messages": "none", "raw_packets": "all"}`.
+
+## Event Flow
+
+```
+Radio Event → packet_processor / event_handler
+ → broadcast_event("message"|"raw_packet", data, realtime=True)
+ → WebSocket broadcast (always)
+ → FanoutManager.broadcast_message/raw (only if realtime=True)
+ → scope check per module
+ → module.on_message / on_raw
+```
+
+Setting `realtime=False` (used during historical decryption) skips fanout dispatch entirely.
+
+## Current Module Types
+
+### mqtt_private (mqtt_private.py)
+Wraps `MqttPublisher` from `app/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:
+- `broker_host`, `broker_port`, `iata`, `email`
+- Only publishes raw packets (on_message is a no-op)
+
+## Adding a New Integration Type
+
+1. Create `app/fanout/my_type.py` with a class extending `FanoutModule`
+2. Register it in `manager.py` → `_register_module_types()`
+3. Add validation in `app/routers/fanout.py` → `_VALID_TYPES` and validator function
+4. Add frontend editor component in `SettingsFanoutSection.tsx`
+
+## REST API
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/fanout` | List all fanout configs |
+| POST | `/api/fanout` | Create new config |
+| PATCH | `/api/fanout/{id}` | Update config (triggers module reload) |
+| DELETE | `/api/fanout/{id}` | Delete config (stops module) |
+
+## Database
+
+`fanout_configs` table (created in migration 36):
+- `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.
+
+## 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/repository/fanout.py` — Database CRUD
+- `app/routers/fanout.py` — REST API
+- `app/websocket.py` — `broadcast_event()` dispatches to fanout
+- `frontend/src/components/settings/SettingsFanoutSection.tsx` — UI
diff --git a/app/fanout/__init__.py b/app/fanout/__init__.py
new file mode 100644
index 00000000..885a25bf
--- /dev/null
+++ b/app/fanout/__init__.py
@@ -0,0 +1,8 @@
+from app.fanout.base import FanoutModule
+from app.fanout.manager import FanoutManager, fanout_manager
+
+__all__ = [
+ "FanoutManager",
+ "FanoutModule",
+ "fanout_manager",
+]
diff --git a/app/fanout/base.py b/app/fanout/base.py
new file mode 100644
index 00000000..9aa4acbb
--- /dev/null
+++ b/app/fanout/base.py
@@ -0,0 +1,34 @@
+"""Base class for fanout integration modules."""
+
+from __future__ import annotations
+
+
+class FanoutModule:
+ """Base class for all fanout integrations.
+
+ Each module wraps a specific integration (MQTT, webhook, etc.) and
+ receives dispatched messages/packets from the FanoutManager.
+
+ Subclasses must override the ``status`` property.
+ """
+
+ def __init__(self, config_id: str, config: dict) -> None:
+ self.config_id = config_id
+ self.config = config
+
+ async def start(self) -> None:
+ """Start the module (e.g. connect to broker). Override for persistent connections."""
+
+ async def stop(self) -> None:
+ """Stop the module (e.g. disconnect from broker)."""
+
+ async def on_message(self, data: dict) -> None:
+ """Called for decoded messages (DM/channel). Override if needed."""
+
+ async def on_raw(self, data: dict) -> None:
+ """Called for raw RF packets. Override if needed."""
+
+ @property
+ def status(self) -> str:
+ """Return 'connected', 'disconnected', or 'error'."""
+ raise NotImplementedError
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
new file mode 100644
index 00000000..06a070f9
--- /dev/null
+++ b/app/fanout/manager.py
@@ -0,0 +1,162 @@
+"""FanoutManager: owns all active fanout modules and dispatches events."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from app.fanout.base import FanoutModule
+
+logger = logging.getLogger(__name__)
+
+# Type string -> module class mapping (extended in Phase 2/3)
+_MODULE_TYPES: dict[str, type] = {}
+
+
+def _register_module_types() -> None:
+ """Lazily populate the type registry to avoid circular imports."""
+ if _MODULE_TYPES:
+ return
+ from app.fanout.mqtt_community import MqttCommunityModule
+ from app.fanout.mqtt_private import MqttPrivateModule
+
+ _MODULE_TYPES["mqtt_private"] = MqttPrivateModule
+ _MODULE_TYPES["mqtt_community"] = MqttCommunityModule
+
+
+def _scope_matches_message(scope: dict, data: dict) -> bool:
+ """Check whether a message event matches the given scope."""
+ messages = scope.get("messages", "none")
+ if messages == "all":
+ return True
+ if messages == "none":
+ return False
+ if isinstance(messages, dict):
+ msg_type = data.get("type", "")
+ conversation_key = data.get("conversation_key", "")
+ if msg_type == "CHAN":
+ channels = messages.get("channels", "none")
+ if channels == "all":
+ return True
+ if channels == "none":
+ return False
+ if isinstance(channels, list):
+ return conversation_key in channels
+ elif msg_type == "PRIV":
+ contacts = messages.get("contacts", "none")
+ if contacts == "all":
+ return True
+ if contacts == "none":
+ return False
+ if isinstance(contacts, list):
+ return conversation_key in contacts
+ return False
+
+
+def _scope_matches_raw(scope: dict, _data: dict) -> bool:
+ """Check whether a raw packet event matches the given scope."""
+ return scope.get("raw_packets", "none") == "all"
+
+
+class FanoutManager:
+ """Owns all active fanout modules and dispatches events."""
+
+ def __init__(self) -> None:
+ self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
+
+ async def load_from_db(self) -> None:
+ """Read enabled fanout_configs and instantiate modules."""
+ _register_module_types()
+ from app.repository.fanout import FanoutConfigRepository
+
+ configs = await FanoutConfigRepository.get_enabled()
+ for cfg in configs:
+ await self._start_module(cfg)
+
+ async def _start_module(self, cfg: dict[str, Any]) -> None:
+ """Instantiate and start a single module from a config dict."""
+ config_id = cfg["id"]
+ config_type = cfg["type"]
+ config_blob = cfg["config"]
+ scope = cfg["scope"]
+
+ cls = _MODULE_TYPES.get(config_type)
+ if cls is None:
+ logger.warning("Unknown fanout type %r for config %s, skipping", config_type, config_id)
+ return
+
+ try:
+ module = cls(config_id, config_blob)
+ await module.start()
+ self._modules[config_id] = (module, scope)
+ logger.info(
+ "Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
+ )
+ except Exception:
+ logger.exception("Failed to start fanout module %s", config_id)
+
+ async def reload_config(self, config_id: str) -> None:
+ """Stop old module (if any) and start updated config."""
+ await self.remove_config(config_id)
+
+ from app.repository.fanout import FanoutConfigRepository
+
+ cfg = await FanoutConfigRepository.get(config_id)
+ if cfg is None or not cfg["enabled"]:
+ return
+ await self._start_module(cfg)
+
+ async def remove_config(self, config_id: str) -> None:
+ """Stop and remove a module."""
+ entry = self._modules.pop(config_id, None)
+ if entry is not None:
+ module, _ = entry
+ try:
+ await module.stop()
+ except Exception:
+ logger.exception("Error stopping fanout module %s", config_id)
+
+ async def broadcast_message(self, data: dict) -> None:
+ """Dispatch a decoded message to modules whose scope matches."""
+ for config_id, (module, scope) in list(self._modules.items()):
+ if _scope_matches_message(scope, data):
+ try:
+ await module.on_message(data)
+ except Exception:
+ logger.exception("Fanout %s on_message error", config_id)
+
+ async def broadcast_raw(self, data: dict) -> None:
+ """Dispatch a raw packet to modules whose scope matches."""
+ for config_id, (module, scope) in list(self._modules.items()):
+ if _scope_matches_raw(scope, data):
+ try:
+ await module.on_raw(data)
+ except Exception:
+ logger.exception("Fanout %s on_raw error", config_id)
+
+ async def stop_all(self) -> None:
+ """Shutdown all modules."""
+ for config_id, (module, _) in list(self._modules.items()):
+ try:
+ await module.stop()
+ except Exception:
+ logger.exception("Error stopping fanout module %s", config_id)
+ self._modules.clear()
+
+ def get_statuses(self) -> dict[str, dict[str, str]]:
+ """Return status info for each active module."""
+ from app.repository.fanout import _configs_cache
+
+ result: dict[str, dict[str, str]] = {}
+ for config_id, (module, _) in self._modules.items():
+ info = _configs_cache.get(config_id, {})
+ result[config_id] = {
+ "name": info.get("name", config_id),
+ "type": info.get("type", "unknown"),
+ "status": module.status,
+ }
+ return result
+
+
+# Module-level singleton
+fanout_manager = FanoutManager()
diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py
new file mode 100644
index 00000000..0cd5590e
--- /dev/null
+++ b/app/fanout/mqtt_community.py
@@ -0,0 +1,89 @@
+"""Fanout module wrapping the community MQTT publisher."""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any
+
+from app.community_mqtt import CommunityMqttPublisher, _format_raw_packet
+from app.fanout.base import FanoutModule
+from app.models import AppSettings
+
+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(
+ 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),
+ community_mqtt_iata=config.get("iata", ""),
+ community_mqtt_email=config.get("email", ""),
+ )
+
+
+class MqttCommunityModule(FanoutModule):
+ """Wraps a CommunityMqttPublisher for community packet sharing."""
+
+ def __init__(self, config_id: str, config: dict) -> None:
+ super().__init__(config_id, config)
+ self._publisher = CommunityMqttPublisher()
+
+ async def start(self) -> None:
+ settings = _config_to_settings(self.config)
+ await self._publisher.start(settings)
+
+ async def stop(self) -> None:
+ await self._publisher.stop()
+
+ async def on_message(self, data: dict) -> None:
+ # Community MQTT only publishes raw packets, not decoded messages.
+ pass
+
+ async def on_raw(self, data: dict) -> None:
+ if not self._publisher.connected or self._publisher._settings is None:
+ return
+ await _publish_community_packet(self._publisher, self.config, data)
+
+ @property
+ def status(self) -> str:
+ if self._publisher._is_configured():
+ return "connected" if self._publisher.connected else "disconnected"
+ return "disconnected"
+
+
+async def _publish_community_packet(
+ publisher: CommunityMqttPublisher,
+ config: dict,
+ data: dict[str, Any],
+) -> None:
+ """Format and publish a raw packet to the community broker."""
+ try:
+ from app.keystore import get_public_key
+ from app.radio import radio_manager
+
+ public_key = get_public_key()
+ if public_key is None:
+ return
+
+ pubkey_hex = public_key.hex().upper()
+
+ device_name = ""
+ if radio_manager.meshcore and radio_manager.meshcore.self_info:
+ device_name = radio_manager.meshcore.self_info.get("name", "")
+
+ packet = _format_raw_packet(data, device_name, pubkey_hex)
+ iata = config.get("iata", "").upper().strip()
+ if not _IATA_RE.fullmatch(iata):
+ logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
+ return
+ topic = f"meshcore/{iata}/{pubkey_hex}/packets"
+
+ await publisher.publish(topic, packet)
+
+ except Exception as e:
+ logger.warning("Community MQTT broadcast error: %s", e)
diff --git a/app/fanout/mqtt_private.py b/app/fanout/mqtt_private.py
new file mode 100644
index 00000000..b016282f
--- /dev/null
+++ b/app/fanout/mqtt_private.py
@@ -0,0 +1,62 @@
+"""Fanout module wrapping the private MQTT publisher."""
+
+from __future__ import annotations
+
+import logging
+
+from app.fanout.base import FanoutModule
+from app.models import AppSettings
+from app.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(
+ mqtt_broker_host=config.get("broker_host", ""),
+ mqtt_broker_port=config.get("broker_port", 1883),
+ mqtt_username=config.get("username", ""),
+ mqtt_password=config.get("password", ""),
+ 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,
+ )
+
+
+class MqttPrivateModule(FanoutModule):
+ """Wraps an MqttPublisher instance for private MQTT forwarding."""
+
+ def __init__(self, config_id: str, config: dict) -> None:
+ super().__init__(config_id, config)
+ self._publisher = MqttPublisher()
+
+ async def start(self) -> None:
+ settings = _config_to_settings(self.config)
+ await self._publisher.start(settings)
+
+ async def stop(self) -> None:
+ await self._publisher.stop()
+
+ async def on_message(self, data: dict) -> None:
+ if not self._publisher.connected or self._publisher._settings is None:
+ return
+ prefix = self.config.get("topic_prefix", "meshcore")
+ topic = _build_message_topic(prefix, data)
+ await self._publisher.publish(topic, data)
+
+ async def on_raw(self, data: dict) -> None:
+ if not self._publisher.connected or self._publisher._settings is None:
+ return
+ prefix = self.config.get("topic_prefix", "meshcore")
+ topic = _build_raw_packet_topic(prefix, data)
+ await self._publisher.publish(topic, data)
+
+ @property
+ def status(self) -> str:
+ if not self.config.get("broker_host"):
+ return "disconnected"
+ return "connected" if self._publisher.connected else "disconnected"
diff --git a/app/main.py b/app/main.py
index 4914e4a2..b1f1e6f6 100644
--- a/app/main.py
+++ b/app/main.py
@@ -18,6 +18,7 @@ from app.radio_sync import (
from app.routers import (
channels,
contacts,
+ fanout,
health,
messages,
packets,
@@ -56,23 +57,18 @@ async def lifespan(app: FastAPI):
# Always start connection monitor (even if initial connection failed)
await radio_manager.start_connection_monitor()
- # Start MQTT publishers if configured
- from app.community_mqtt import community_publisher
- from app.mqtt import mqtt_publisher
- from app.repository import AppSettingsRepository
+ # Start fanout modules (MQTT, etc.) from database configs
+ from app.fanout.manager import fanout_manager
try:
- mqtt_settings = await AppSettingsRepository.get()
- await mqtt_publisher.start(mqtt_settings)
- await community_publisher.start(mqtt_settings)
+ await fanout_manager.load_from_db()
except Exception as e:
- logger.warning("Failed to start MQTT publisher(s): %s", e)
+ logger.warning("Failed to start fanout modules: %s", e)
yield
logger.info("Shutting down")
- await community_publisher.stop()
- await mqtt_publisher.stop()
+ await fanout_manager.stop_all()
await radio_manager.stop_connection_monitor()
await stop_message_polling()
await stop_periodic_advert()
@@ -119,6 +115,7 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
+app.include_router(fanout.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
app.include_router(contacts.router, prefix="/api")
app.include_router(repeaters.router, prefix="/api")
diff --git a/app/migrations.py b/app/migrations.py
index 2546092d..65168db9 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -282,6 +282,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 35)
applied += 1
+ # Migration 36: Create fanout_configs table and migrate existing MQTT settings
+ if version < 36:
+ logger.info("Applying migration 36: create fanout_configs and migrate MQTT settings")
+ await _migrate_036_create_fanout_configs(conn)
+ await set_version(conn, 36)
+ applied += 1
+
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2014,3 +2021,131 @@ async def _migrate_035_add_block_lists(conn: aiosqlite.Connection) -> None:
raise
await conn.commit()
+
+
+async def _migrate_036_create_fanout_configs(conn: aiosqlite.Connection) -> None:
+ """Create fanout_configs table and migrate existing MQTT settings.
+
+ Reads existing MQTT settings from app_settings and creates corresponding
+ fanout_configs rows. Old columns are NOT dropped (rollback safety).
+ """
+ import json
+ import uuid
+
+ # 1. Create fanout_configs table
+ await conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS fanout_configs (
+ id TEXT PRIMARY KEY,
+ type TEXT NOT NULL,
+ name TEXT NOT NULL,
+ enabled INTEGER DEFAULT 0,
+ config TEXT NOT NULL DEFAULT '{}',
+ scope TEXT NOT NULL DEFAULT '{}',
+ sort_order INTEGER DEFAULT 0,
+ created_at INTEGER NOT NULL
+ )
+ """
+ )
+
+ # 2. Read existing MQTT settings
+ try:
+ cursor = await conn.execute(
+ """
+ SELECT 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
+ FROM app_settings WHERE id = 1
+ """
+ )
+ row = await cursor.fetchone()
+ except Exception:
+ row = None
+
+ if row is None:
+ await conn.commit()
+ return
+
+ import time
+
+ now = int(time.time())
+ sort_order = 0
+
+ # 3. Migrate private MQTT if configured
+ broker_host = row["mqtt_broker_host"] or ""
+ if broker_host:
+ publish_messages = bool(row["mqtt_publish_messages"])
+ publish_raw = bool(row["mqtt_publish_raw_packets"])
+ enabled = publish_messages or publish_raw
+
+ config = {
+ "broker_host": broker_host,
+ "broker_port": row["mqtt_broker_port"] or 1883,
+ "username": row["mqtt_username"] or "",
+ "password": row["mqtt_password"] or "",
+ "use_tls": bool(row["mqtt_use_tls"]),
+ "tls_insecure": bool(row["mqtt_tls_insecure"]),
+ "topic_prefix": row["mqtt_topic_prefix"] or "meshcore",
+ }
+
+ scope = {
+ "messages": "all" if publish_messages else "none",
+ "raw_packets": "all" if publish_raw else "none",
+ }
+
+ await conn.execute(
+ """
+ INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ str(uuid.uuid4()),
+ "mqtt_private",
+ "Private MQTT",
+ 1 if enabled else 0,
+ json.dumps(config),
+ json.dumps(scope),
+ sort_order,
+ now,
+ ),
+ )
+ sort_order += 1
+ logger.info("Migrated private MQTT settings to fanout_configs (enabled=%s)", enabled)
+
+ # 4. Migrate community MQTT if enabled
+ community_enabled = bool(row["community_mqtt_enabled"])
+ if community_enabled:
+ config = {
+ "broker_host": row["community_mqtt_broker_host"] or "mqtt-us-v1.letsmesh.net",
+ "broker_port": row["community_mqtt_broker_port"] or 443,
+ "iata": row["community_mqtt_iata"] or "",
+ "email": row["community_mqtt_email"] or "",
+ }
+
+ scope = {
+ "messages": "none",
+ "raw_packets": "all",
+ }
+
+ await conn.execute(
+ """
+ INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ str(uuid.uuid4()),
+ "mqtt_community",
+ "Community MQTT",
+ 1,
+ json.dumps(config),
+ json.dumps(scope),
+ sort_order,
+ now,
+ ),
+ )
+ logger.info("Migrated community MQTT settings to fanout_configs")
+
+ await conn.commit()
diff --git a/app/models.py b/app/models.py
index f2e1755f..43bfabb8 100644
--- a/app/models.py
+++ b/app/models.py
@@ -533,6 +533,19 @@ class AppSettings(BaseModel):
)
+class FanoutConfig(BaseModel):
+ """Configuration for a single fanout integration."""
+
+ id: str
+ type: str # 'mqtt_private' | 'mqtt_community'
+ name: str
+ enabled: bool
+ config: dict
+ scope: dict
+ sort_order: int = 0
+ created_at: int = 0
+
+
class BusyChannel(BaseModel):
channel_key: str
channel_name: str
diff --git a/app/mqtt.py b/app/mqtt.py
index 79a6e3f2..fb96989c 100644
--- a/app/mqtt.py
+++ b/app/mqtt.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import asyncio
import logging
import ssl
from typing import Any
@@ -54,38 +53,6 @@ class MqttPublisher(BaseMqttPublisher):
return ctx
-# Module-level singleton
-mqtt_publisher = MqttPublisher()
-
-
-def mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
- """Fire-and-forget MQTT publish, matching broadcast_event's pattern."""
- if event_type not in ("message", "raw_packet"):
- return
- if not mqtt_publisher.connected or mqtt_publisher._settings is None:
- return
- asyncio.create_task(_mqtt_maybe_publish(event_type, data))
-
-
-async def _mqtt_maybe_publish(event_type: str, data: dict[str, Any]) -> None:
- """Check settings and build topic, then publish."""
- settings = mqtt_publisher._settings
- if settings is None:
- return
-
- try:
- if event_type == "message" and settings.mqtt_publish_messages:
- topic = _build_message_topic(settings.mqtt_topic_prefix, data)
- await mqtt_publisher.publish(topic, data)
-
- elif event_type == "raw_packet" and settings.mqtt_publish_raw_packets:
- topic = _build_raw_packet_topic(settings.mqtt_topic_prefix, data)
- await mqtt_publisher.publish(topic, data)
-
- except Exception as e:
- logger.warning("MQTT broadcast error: %s", e)
-
-
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a decrypted message."""
msg_type = data.get("type", "")
diff --git a/app/packet_processor.py b/app/packet_processor.py
index 3e40a796..1264345c 100644
--- a/app/packet_processor.py
+++ b/app/packet_processor.py
@@ -209,6 +209,7 @@ async def create_message_from_decrypted(
sender_name=sender,
sender_key=resolved_sender_key,
).model_dump(),
+ realtime=trigger_bot,
)
# Run bot if enabled (for incoming channel messages, not historical decryption)
@@ -332,6 +333,7 @@ async def create_dm_message_from_decrypted(
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
).model_dump(),
+ realtime=trigger_bot,
)
# Update contact's last_contacted timestamp (for sorting)
diff --git a/app/repository/__init__.py b/app/repository/__init__.py
index 00589561..cb34f9c4 100644
--- a/app/repository/__init__.py
+++ b/app/repository/__init__.py
@@ -5,6 +5,7 @@ from app.repository.contacts import (
ContactNameHistoryRepository,
ContactRepository,
)
+from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository
@@ -16,6 +17,7 @@ __all__ = [
"ContactAdvertPathRepository",
"ContactNameHistoryRepository",
"ContactRepository",
+ "FanoutConfigRepository",
"MessageRepository",
"RawPacketRepository",
"StatisticsRepository",
diff --git a/app/repository/fanout.py b/app/repository/fanout.py
new file mode 100644
index 00000000..76fb31d7
--- /dev/null
+++ b/app/repository/fanout.py
@@ -0,0 +1,137 @@
+"""Repository for fanout_configs table."""
+
+import json
+import logging
+import time
+import uuid
+from typing import Any
+
+from app.database import db
+
+logger = logging.getLogger(__name__)
+
+# In-memory cache of config metadata (name, type) for status reporting.
+# Populated by get_all/get/create/update and read by FanoutManager.get_statuses().
+_configs_cache: dict[str, dict[str, Any]] = {}
+
+
+def _row_to_dict(row: Any) -> dict[str, Any]:
+ """Convert a database row to a config dict."""
+ result = {
+ "id": row["id"],
+ "type": row["type"],
+ "name": row["name"],
+ "enabled": bool(row["enabled"]),
+ "config": json.loads(row["config"]) if row["config"] else {},
+ "scope": json.loads(row["scope"]) if row["scope"] else {},
+ "sort_order": row["sort_order"] or 0,
+ "created_at": row["created_at"] or 0,
+ }
+ _configs_cache[result["id"]] = result
+ return result
+
+
+class FanoutConfigRepository:
+ """CRUD operations for fanout_configs table."""
+
+ @staticmethod
+ async def get_all() -> list[dict[str, Any]]:
+ """Get all fanout configs ordered by sort_order."""
+ cursor = await db.conn.execute(
+ "SELECT * FROM fanout_configs ORDER BY sort_order, created_at"
+ )
+ rows = await cursor.fetchall()
+ return [_row_to_dict(row) for row in rows]
+
+ @staticmethod
+ async def get(config_id: str) -> dict[str, Any] | None:
+ """Get a single fanout config by ID."""
+ cursor = await db.conn.execute("SELECT * FROM fanout_configs WHERE id = ?", (config_id,))
+ row = await cursor.fetchone()
+ if row is None:
+ return None
+ return _row_to_dict(row)
+
+ @staticmethod
+ async def create(
+ config_type: str,
+ name: str,
+ config: dict,
+ scope: dict,
+ enabled: bool = True,
+ config_id: str | None = None,
+ ) -> dict[str, Any]:
+ """Create a new fanout config."""
+ new_id = config_id or str(uuid.uuid4())
+ now = int(time.time())
+
+ # Get next sort_order
+ cursor = await db.conn.execute(
+ "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM fanout_configs"
+ )
+ row = await cursor.fetchone()
+ sort_order = row[0] if row else 0
+
+ await db.conn.execute(
+ """
+ INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ new_id,
+ config_type,
+ name,
+ 1 if enabled else 0,
+ json.dumps(config),
+ json.dumps(scope),
+ sort_order,
+ now,
+ ),
+ )
+ await db.conn.commit()
+
+ result = await FanoutConfigRepository.get(new_id)
+ assert result is not None
+ return result
+
+ @staticmethod
+ async def update(config_id: str, **fields: Any) -> dict[str, Any] | None:
+ """Update a fanout config. Only provided fields are updated."""
+ updates = []
+ params: list[Any] = []
+
+ for field in ("name", "enabled", "config", "scope", "sort_order"):
+ if field in fields:
+ value = fields[field]
+ if field == "enabled":
+ value = 1 if value else 0
+ elif field in ("config", "scope"):
+ value = json.dumps(value)
+ updates.append(f"{field} = ?")
+ params.append(value)
+
+ if not updates:
+ return await FanoutConfigRepository.get(config_id)
+
+ params.append(config_id)
+ query = f"UPDATE fanout_configs SET {', '.join(updates)} WHERE id = ?"
+ await db.conn.execute(query, params)
+ await db.conn.commit()
+
+ return await FanoutConfigRepository.get(config_id)
+
+ @staticmethod
+ async def delete(config_id: str) -> None:
+ """Delete a fanout config."""
+ await db.conn.execute("DELETE FROM fanout_configs WHERE id = ?", (config_id,))
+ await db.conn.commit()
+ _configs_cache.pop(config_id, None)
+
+ @staticmethod
+ async def get_enabled() -> list[dict[str, Any]]:
+ """Get all enabled fanout configs."""
+ cursor = await db.conn.execute(
+ "SELECT * FROM fanout_configs WHERE enabled = 1 ORDER BY sort_order, created_at"
+ )
+ rows = await cursor.fetchall()
+ return [_row_to_dict(row) for row in rows]
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
new file mode 100644
index 00000000..4aa5c6b0
--- /dev/null
+++ b/app/routers/fanout.py
@@ -0,0 +1,165 @@
+"""REST API for fanout config CRUD."""
+
+import logging
+import re
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+from app.repository.fanout import FanoutConfigRepository
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/fanout", tags=["fanout"])
+
+# Valid types in Phase 1
+_VALID_TYPES = {"mqtt_private", "mqtt_community"}
+
+_IATA_RE = re.compile(r"^[A-Z]{3}$")
+
+
+class FanoutConfigCreate(BaseModel):
+ type: str = Field(description="Integration type: 'mqtt_private' or 'mqtt_community'")
+ name: str = Field(min_length=1, description="User-assigned label")
+ config: dict = Field(default_factory=dict, description="Type-specific config blob")
+ scope: dict = Field(default_factory=dict, description="Scope controls")
+ enabled: bool = Field(default=True, description="Whether enabled on creation")
+
+
+class FanoutConfigUpdate(BaseModel):
+ name: str | None = Field(default=None, description="Updated label")
+ config: dict | None = Field(default=None, description="Updated config blob")
+ scope: dict | None = Field(default=None, description="Updated scope controls")
+ enabled: bool | None = Field(default=None, description="Enable/disable toggle")
+
+
+def _validate_mqtt_private_config(config: dict) -> None:
+ """Validate mqtt_private config blob."""
+ if not config.get("broker_host"):
+ raise HTTPException(status_code=400, detail="broker_host is required for mqtt_private")
+ port = config.get("broker_port", 1883)
+ if not isinstance(port, int) or port < 1 or port > 65535:
+ raise HTTPException(status_code=400, detail="broker_port must be between 1 and 65535")
+
+
+def _validate_mqtt_community_config(config: dict) -> None:
+ """Validate mqtt_community config blob."""
+ iata = config.get("iata", "")
+ if iata and not _IATA_RE.fullmatch(iata.upper().strip()):
+ raise HTTPException(
+ status_code=400,
+ detail="IATA code must be exactly 3 uppercase alphabetic characters",
+ )
+
+
+def _enforce_scope(config_type: str, scope: dict) -> dict:
+ """Enforce type-specific scope constraints. Returns normalized scope."""
+ if config_type == "mqtt_community":
+ # Community MQTT always: no messages, all raw packets
+ return {"messages": "none", "raw_packets": "all"}
+ # For mqtt_private, validate scope values
+ messages = scope.get("messages", "all")
+ if messages not in ("all", "none") and not isinstance(messages, dict):
+ messages = "all"
+ raw_packets = scope.get("raw_packets", "all")
+ if raw_packets not in ("all", "none"):
+ raw_packets = "all"
+ return {"messages": messages, "raw_packets": raw_packets}
+
+
+@router.get("")
+async def list_fanout_configs() -> list[dict]:
+ """List all fanout configs."""
+ return await FanoutConfigRepository.get_all()
+
+
+@router.post("")
+async def create_fanout_config(body: FanoutConfigCreate) -> dict:
+ """Create a new fanout config."""
+ if body.type not in _VALID_TYPES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
+ )
+
+ # Only validate config when creating as enabled — disabled configs
+ # are drafts the user hasn't finished configuring yet.
+ if body.enabled:
+ if body.type == "mqtt_private":
+ _validate_mqtt_private_config(body.config)
+ elif body.type == "mqtt_community":
+ _validate_mqtt_community_config(body.config)
+
+ scope = _enforce_scope(body.type, body.scope)
+
+ cfg = await FanoutConfigRepository.create(
+ config_type=body.type,
+ name=body.name,
+ config=body.config,
+ scope=scope,
+ enabled=body.enabled,
+ )
+
+ # Start the module if enabled
+ if cfg["enabled"]:
+ from app.fanout.manager import fanout_manager
+
+ await fanout_manager.reload_config(cfg["id"])
+
+ logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
+ return cfg
+
+
+@router.patch("/{config_id}")
+async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict:
+ """Update a fanout config. Triggers module reload."""
+ existing = await FanoutConfigRepository.get(config_id)
+ if existing is None:
+ raise HTTPException(status_code=404, detail="Fanout config not found")
+
+ kwargs = {}
+ if body.name is not None:
+ kwargs["name"] = body.name
+ if body.enabled is not None:
+ kwargs["enabled"] = body.enabled
+ if body.config is not None:
+ kwargs["config"] = body.config
+ if body.scope is not None:
+ kwargs["scope"] = _enforce_scope(existing["type"], body.scope)
+
+ # Validate config when the result will be enabled
+ will_be_enabled = body.enabled if body.enabled is not None else existing["enabled"]
+ if will_be_enabled:
+ config_to_validate = body.config if body.config is not None else existing["config"]
+ if existing["type"] == "mqtt_private":
+ _validate_mqtt_private_config(config_to_validate)
+ elif existing["type"] == "mqtt_community":
+ _validate_mqtt_community_config(config_to_validate)
+
+ updated = await FanoutConfigRepository.update(config_id, **kwargs)
+ if updated is None:
+ raise HTTPException(status_code=404, detail="Fanout config not found")
+
+ # Reload the module to pick up changes
+ from app.fanout.manager import fanout_manager
+
+ await fanout_manager.reload_config(config_id)
+
+ logger.info("Updated fanout config %s", config_id)
+ return updated
+
+
+@router.delete("/{config_id}")
+async def delete_fanout_config(config_id: str) -> dict:
+ """Delete a fanout config."""
+ existing = await FanoutConfigRepository.get(config_id)
+ if existing is None:
+ raise HTTPException(status_code=404, detail="Fanout config not found")
+
+ # Stop the module first
+ from app.fanout.manager import fanout_manager
+
+ await fanout_manager.remove_config(config_id)
+ await FanoutConfigRepository.delete(config_id)
+
+ logger.info("Deleted fanout config %s", config_id)
+ return {"deleted": True}
diff --git a/app/routers/health.py b/app/routers/health.py
index 5be6e6b8..39a53c94 100644
--- a/app/routers/health.py
+++ b/app/routers/health.py
@@ -1,4 +1,5 @@
import os
+from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
@@ -16,8 +17,7 @@ class HealthResponse(BaseModel):
connection_info: str | None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
- mqtt_status: str | None = None
- community_mqtt_status: str | None = None
+ fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False
@@ -36,27 +36,12 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
except RuntimeError:
pass # Database not connected
- # MQTT status
- mqtt_status: str | None = None
+ # Fanout module statuses
+ fanout_statuses: dict[str, Any] = {}
try:
- from app.mqtt import mqtt_publisher
+ from app.fanout.manager import fanout_manager
- if mqtt_publisher._is_configured():
- mqtt_status = "connected" if mqtt_publisher.connected else "disconnected"
- else:
- mqtt_status = "disabled"
- except Exception:
- pass
-
- # Community MQTT status
- community_mqtt_status: str | None = None
- try:
- from app.community_mqtt import community_publisher
-
- if community_publisher._is_configured():
- community_mqtt_status = "connected" if community_publisher.connected else "disconnected"
- else:
- community_mqtt_status = "disabled"
+ fanout_statuses = fanout_manager.get_statuses()
except Exception:
pass
@@ -66,8 +51,7 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"connection_info": connection_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
- "mqtt_status": mqtt_status,
- "community_mqtt_status": community_mqtt_status,
+ "fanout_statuses": fanout_statuses,
"bots_disabled": settings.disable_bots,
}
diff --git a/app/routers/settings.py b/app/routers/settings.py
index 55e20b3f..56cf6dee 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -1,6 +1,5 @@
import asyncio
import logging
-import re
from typing import Literal
from fastapi import APIRouter, HTTPException
@@ -61,66 +60,6 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="List of bot configurations",
)
- mqtt_broker_host: str | None = Field(
- default=None,
- description="MQTT broker hostname (empty = disabled)",
- )
- mqtt_broker_port: int | None = Field(
- default=None,
- ge=1,
- le=65535,
- description="MQTT broker port",
- )
- mqtt_username: str | None = Field(
- default=None,
- description="MQTT username (optional)",
- )
- mqtt_password: str | None = Field(
- default=None,
- description="MQTT password (optional)",
- )
- mqtt_use_tls: bool | None = Field(
- default=None,
- description="Whether to use TLS for MQTT connection",
- )
- mqtt_tls_insecure: bool | None = Field(
- default=None,
- description="Skip TLS certificate verification (for self-signed certs)",
- )
- mqtt_topic_prefix: str | None = Field(
- default=None,
- description="MQTT topic prefix",
- )
- mqtt_publish_messages: bool | None = Field(
- default=None,
- description="Whether to publish decrypted messages to MQTT",
- )
- mqtt_publish_raw_packets: bool | None = Field(
- default=None,
- description="Whether to publish raw packets to MQTT",
- )
- community_mqtt_enabled: bool | None = Field(
- default=None,
- description="Whether to publish raw packets to the community MQTT broker",
- )
- community_mqtt_iata: str | None = Field(
- default=None,
- description="IATA region code for community MQTT topic routing (3 alpha chars)",
- )
- community_mqtt_broker_host: str | None = Field(
- default=None,
- description="Community MQTT broker hostname",
- )
- community_mqtt_broker_port: int | None = Field(
- default=None,
- ge=1,
- le=65535,
- description="Community MQTT broker port",
- )
- community_mqtt_email: str | None = Field(
- default=None,
- description="Email address for node claiming on the community aggregator",
- )
flood_scope: str | None = Field(
default=None,
description="Outbound flood scope / region name (empty = disabled)",
@@ -210,53 +149,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating bots (count=%d)", len(update.bots))
kwargs["bots"] = update.bots
- # MQTT fields
- mqtt_fields = [
- "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",
- ]
- mqtt_changed = False
- for field in mqtt_fields:
- value = getattr(update, field)
- if value is not None:
- kwargs[field] = value
- mqtt_changed = True
-
- # Community MQTT fields
- community_mqtt_changed = False
- if update.community_mqtt_enabled is not None:
- kwargs["community_mqtt_enabled"] = update.community_mqtt_enabled
- community_mqtt_changed = True
-
- if update.community_mqtt_iata is not None:
- iata = update.community_mqtt_iata.upper().strip()
- if iata and not re.fullmatch(r"[A-Z]{3}", iata):
- raise HTTPException(
- status_code=400,
- detail="IATA code must be exactly 3 uppercase alphabetic characters",
- )
- kwargs["community_mqtt_iata"] = iata
- community_mqtt_changed = True
-
- if update.community_mqtt_broker_host is not None:
- kwargs["community_mqtt_broker_host"] = update.community_mqtt_broker_host
- community_mqtt_changed = True
-
- if update.community_mqtt_broker_port is not None:
- kwargs["community_mqtt_broker_port"] = update.community_mqtt_broker_port
- community_mqtt_changed = True
-
- if update.community_mqtt_email is not None:
- kwargs["community_mqtt_email"] = update.community_mqtt_email
- community_mqtt_changed = True
-
# Block lists
if update.blocked_keys is not None:
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
@@ -270,34 +162,9 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
kwargs["flood_scope"] = stripped
flood_scope_changed = True
- # Require IATA when enabling community MQTT
- if kwargs.get("community_mqtt_enabled", False):
- # Check the IATA value being set, or fall back to current settings
- iata_value = kwargs.get("community_mqtt_iata")
- if iata_value is None:
- current = await AppSettingsRepository.get()
- iata_value = current.community_mqtt_iata
- if not iata_value or not re.fullmatch(r"[A-Z]{3}", iata_value):
- raise HTTPException(
- status_code=400,
- detail="A valid IATA region code is required to enable community sharing",
- )
-
if kwargs:
result = await AppSettingsRepository.update(**kwargs)
- # Restart MQTT publisher if any MQTT settings changed
- if mqtt_changed:
- from app.mqtt import mqtt_publisher
-
- await mqtt_publisher.restart(result)
-
- # Restart community MQTT publisher if any community settings changed
- if community_mqtt_changed:
- from app.community_mqtt import community_publisher
-
- await community_publisher.restart(result)
-
# Apply flood scope to radio immediately if changed
if flood_scope_changed:
from app.radio import radio_manager
diff --git a/app/websocket.py b/app/websocket.py
index 16380357..3ceb705e 100644
--- a/app/websocket.py
+++ b/app/websocket.py
@@ -92,21 +92,26 @@ class WebSocketManager:
ws_manager = WebSocketManager()
-def broadcast_event(event_type: str, data: dict) -> None:
+def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> None:
"""Schedule a broadcast without blocking.
Convenience function that creates an asyncio task to broadcast
- an event to all connected WebSocket clients and forward to MQTT.
+ an event to all connected WebSocket clients and forward to fanout modules.
+
+ Args:
+ event_type: Event type string (e.g. "message", "raw_packet")
+ data: Event payload dict
+ realtime: If False, skip fanout dispatch (used for historical decryption)
"""
asyncio.create_task(ws_manager.broadcast(event_type, data))
- from app.mqtt import mqtt_broadcast
+ if realtime:
+ from app.fanout.manager import fanout_manager
- mqtt_broadcast(event_type, data)
-
- from app.community_mqtt import community_mqtt_broadcast
-
- community_mqtt_broadcast(event_type, data)
+ if event_type == "message":
+ asyncio.create_task(fanout_manager.broadcast_message(data))
+ elif event_type == "raw_packet":
+ asyncio.create_task(fanout_manager.broadcast_raw(data))
def broadcast_error(message: str, details: str | None = None) -> None:
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index d2ca63a0..b4b4d113 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -8,6 +8,7 @@ import type {
ContactAdvertPath,
ContactAdvertPathSummary,
ContactDetail,
+ FanoutConfig,
Favorite,
HealthStatus,
MaintenanceResult,
@@ -280,6 +281,37 @@ export const api = {
body: JSON.stringify(request),
}),
+ // Fanout
+ getFanoutConfigs: () => fetchJson('/fanout'),
+ createFanoutConfig: (config: {
+ type: string;
+ name: string;
+ config: Record;
+ scope: Record;
+ enabled?: boolean;
+ }) =>
+ fetchJson('/fanout', {
+ method: 'POST',
+ body: JSON.stringify(config),
+ }),
+ updateFanoutConfig: (
+ id: string,
+ update: {
+ name?: string;
+ config?: Record;
+ scope?: Record;
+ enabled?: boolean;
+ }
+ ) =>
+ fetchJson(`/fanout/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(update),
+ }),
+ deleteFanoutConfig: (id: string) =>
+ fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
+ method: 'DELETE',
+ }),
+
// Statistics
getStatistics: () => fetchJson('/statistics'),
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index dc948014..174b9071 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -11,7 +11,7 @@ import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settin
import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
-import { SettingsMqttSection } from './settings/SettingsMqttSection';
+import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
import { SettingsBotSection } from './settings/SettingsBotSection';
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
@@ -78,7 +78,7 @@ export function SettingsModal(props: SettingsModalProps) {
const [expandedSections, setExpandedSections] = useState>({
radio: false,
local: false,
- mqtt: false,
+ fanout: false,
database: false,
bot: false,
statistics: false,
@@ -232,16 +232,11 @@ export function SettingsModal(props: SettingsModalProps) {
)}
- {shouldRenderSection('mqtt') && (
+ {shouldRenderSection('fanout') && (
- {renderSectionHeader('mqtt')}
- {isSectionVisible('mqtt') && appSettings && (
-
+ {renderSectionHeader('fanout')}
+ {isSectionVisible('fanout') && (
+
)}
)}
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
new file mode 100644
index 00000000..e804ac60
--- /dev/null
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -0,0 +1,505 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Input } from '../ui/input';
+import { Label } from '../ui/label';
+import { Button } from '../ui/button';
+import { Separator } from '../ui/separator';
+import { toast } from '../ui/sonner';
+import { cn } from '@/lib/utils';
+import { api } from '../../api';
+import type { FanoutConfig, HealthStatus } from '../../types';
+
+const TYPE_LABELS: Record = {
+ mqtt_private: 'Private MQTT',
+ mqtt_community: 'Community MQTT',
+};
+
+const TYPE_OPTIONS = [
+ { value: 'mqtt_private', label: 'Private MQTT' },
+ { value: 'mqtt_community', label: 'Community MQTT' },
+];
+
+function getStatusColor(status: string | undefined) {
+ if (status === 'connected')
+ return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
+ return 'bg-muted-foreground';
+}
+
+function getStatusLabel(status: string | undefined) {
+ if (status === 'connected') return 'Connected';
+ if (status === 'disconnected') return 'Disconnected';
+ return 'Inactive';
+}
+
+function MqttPrivateConfigEditor({
+ config,
+ scope,
+ onChange,
+ onScopeChange,
+}: {
+ config: Record;
+ scope: Record;
+ onChange: (config: Record) => void;
+ onScopeChange: (scope: Record) => void;
+}) {
+ return (
+
+ );
+}
+
+function MqttCommunityConfigEditor({
+ config,
+ onChange,
+}: {
+ config: Record;
+ onChange: (config: Record) => void;
+}) {
+ return (
+
+
+ Share raw packet data with the MeshCore community for coverage mapping and network analysis.
+ Only raw RF packets are shared — never decrypted messages.
+
+
+
+
+
+
Region Code (IATA)
+
onChange({ ...config, iata: e.target.value.toUpperCase() })}
+ className="w-32"
+ />
+
+ Your nearest airport's IATA code (required)
+
+
+
+
+
Owner Email (optional)
+
onChange({ ...config, email: e.target.value })}
+ />
+
+ Used to claim your node on the community aggregator
+
+
+
+ );
+}
+
+export function SettingsFanoutSection({
+ health,
+ className,
+}: {
+ health: HealthStatus | null;
+ className?: string;
+}) {
+ const [configs, setConfigs] = useState([]);
+ const [editingId, setEditingId] = useState(null);
+ const [editConfig, setEditConfig] = useState>({});
+ const [editScope, setEditScope] = useState>({});
+ const [editName, setEditName] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [addingType, setAddingType] = useState(null);
+
+ const loadConfigs = useCallback(async () => {
+ try {
+ const data = await api.getFanoutConfigs();
+ setConfigs(data);
+ } catch (err) {
+ console.error('Failed to load fanout configs:', err);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadConfigs();
+ }, [loadConfigs]);
+
+ const handleToggleEnabled = async (cfg: FanoutConfig) => {
+ try {
+ await api.updateFanoutConfig(cfg.id, { enabled: !cfg.enabled });
+ await loadConfigs();
+ toast.success(cfg.enabled ? 'Integration disabled' : 'Integration enabled');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to update');
+ }
+ };
+
+ const handleEdit = (cfg: FanoutConfig) => {
+ setEditingId(cfg.id);
+ setEditConfig(cfg.config);
+ setEditScope(cfg.scope);
+ setEditName(cfg.name);
+ };
+
+ const handleSave = async () => {
+ if (!editingId) return;
+ setBusy(true);
+ try {
+ await api.updateFanoutConfig(editingId, {
+ name: editName,
+ config: editConfig,
+ scope: editScope,
+ });
+ await loadConfigs();
+ setEditingId(null);
+ toast.success('Integration saved');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ const cfg = configs.find((c) => c.id === id);
+ if (!confirm(`Delete "${cfg?.name}"? This cannot be undone.`)) return;
+ try {
+ await api.deleteFanoutConfig(id);
+ if (editingId === id) setEditingId(null);
+ await loadConfigs();
+ toast.success('Integration deleted');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to delete');
+ }
+ };
+
+ const handleAddStart = (type: string) => {
+ setAddingType(type);
+ };
+
+ const handleAddCreate = async (type: string) => {
+ const defaults: Record> = {
+ mqtt_private: {
+ broker_host: '',
+ broker_port: 1883,
+ username: '',
+ password: '',
+ use_tls: false,
+ tls_insecure: false,
+ topic_prefix: 'meshcore',
+ },
+ mqtt_community: {
+ broker_host: 'mqtt-us-v1.letsmesh.net',
+ broker_port: 443,
+ iata: '',
+ email: '',
+ },
+ };
+ const defaultScopes: Record> = {
+ mqtt_private: { messages: 'all', raw_packets: 'all' },
+ mqtt_community: { messages: 'none', raw_packets: 'all' },
+ };
+
+ try {
+ const created = await api.createFanoutConfig({
+ type,
+ name: TYPE_LABELS[type] || type,
+ config: defaults[type] || {},
+ scope: defaultScopes[type] || {},
+ enabled: false,
+ });
+ await loadConfigs();
+ setAddingType(null);
+ handleEdit(created);
+ toast.success('Integration created');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to create');
+ }
+ };
+
+ const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
+
+ // Detail view
+ if (editingConfig) {
+ return (
+
+
setEditingId(null)}
+ >
+ ← Back to list
+
+
+
+ Name
+ setEditName(e.target.value)}
+ />
+
+
+
+ Type: {TYPE_LABELS[editingConfig.type] || editingConfig.type}
+
+
+
+
+ {editingConfig.type === 'mqtt_private' && (
+
+ )}
+
+ {editingConfig.type === 'mqtt_community' && (
+
+ )}
+
+
+
+
+
+ {busy ? 'Saving...' : 'Save'}
+
+ handleDelete(editingConfig.id)}>
+ Delete
+
+
+
+ );
+ }
+
+ // List view
+ return (
+
+
+ MQTT support is an experimental feature in open beta. All publishing uses QoS 0
+ (at-most-once delivery).
+
+
+ {configs.length === 0 ? (
+
+
No integrations configured
+
+ ) : (
+
+ {configs.map((cfg) => {
+ const statusEntry = health?.fanout_statuses?.[cfg.id];
+ const status = cfg.enabled ? statusEntry?.status : undefined;
+ return (
+
+
+
e.stopPropagation()}
+ >
+ handleToggleEnabled(cfg)}
+ className="w-4 h-4 rounded border-input accent-primary"
+ aria-label={`Enable ${cfg.name}`}
+ />
+
+
+
{cfg.name}
+
+
+ {TYPE_LABELS[cfg.type] || cfg.type}
+
+
+
+
+ {cfg.enabled ? getStatusLabel(status) : 'Disabled'}
+
+
+
handleEdit(cfg)}
+ >
+ Edit
+
+
+
+ );
+ })}
+
+ )}
+
+ {addingType ? (
+
+
Select integration type:
+
+ {TYPE_OPTIONS.map((opt) => (
+ handleAddCreate(opt.value)}
+ >
+ {opt.label}
+
+ ))}
+
+
setAddingType(null)}>
+ Cancel
+
+
+ ) : (
+
handleAddStart('mqtt_private')} className="w-full">
+ + Add Integration
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsMqttSection.tsx b/frontend/src/components/settings/SettingsMqttSection.tsx
deleted file mode 100644
index ad42b6a1..00000000
--- a/frontend/src/components/settings/SettingsMqttSection.tsx
+++ /dev/null
@@ -1,451 +0,0 @@
-import { useState, useEffect } from 'react';
-import { Input } from '../ui/input';
-import { Label } from '../ui/label';
-import { Button } from '../ui/button';
-import { Separator } from '../ui/separator';
-import { toast } from '../ui/sonner';
-import { cn } from '@/lib/utils';
-import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
-
-export function SettingsMqttSection({
- appSettings,
- health,
- onSaveAppSettings,
- className,
-}: {
- appSettings: AppSettings;
- health: HealthStatus | null;
- onSaveAppSettings: (update: AppSettingsUpdate) => Promise;
- className?: string;
-}) {
- const [mqttBrokerHost, setMqttBrokerHost] = useState('');
- const [mqttBrokerPort, setMqttBrokerPort] = useState('1883');
- const [mqttUsername, setMqttUsername] = useState('');
- const [mqttPassword, setMqttPassword] = useState('');
- const [mqttUseTls, setMqttUseTls] = useState(false);
- const [mqttTlsInsecure, setMqttTlsInsecure] = useState(false);
- const [mqttTopicPrefix, setMqttTopicPrefix] = useState('meshcore');
- const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
- const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
-
- // Community MQTT state
- const [communityMqttEnabled, setCommunityMqttEnabled] = useState(false);
- const [communityMqttIata, setCommunityMqttIata] = useState('');
- const [communityMqttBrokerHost, setCommunityMqttBrokerHost] = useState('mqtt-us-v1.letsmesh.net');
- const [communityMqttBrokerPort, setCommunityMqttBrokerPort] = useState('443');
- const [communityMqttEmail, setCommunityMqttEmail] = useState('');
-
- const [privateExpanded, setPrivateExpanded] = useState(false);
- const [communityExpanded, setCommunityExpanded] = useState(false);
-
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- setMqttBrokerHost(appSettings.mqtt_broker_host ?? '');
- setMqttBrokerPort(String(appSettings.mqtt_broker_port ?? 1883));
- setMqttUsername(appSettings.mqtt_username ?? '');
- setMqttPassword(appSettings.mqtt_password ?? '');
- setMqttUseTls(appSettings.mqtt_use_tls ?? false);
- setMqttTlsInsecure(appSettings.mqtt_tls_insecure ?? false);
- setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
- setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
- setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
- setCommunityMqttEnabled(appSettings.community_mqtt_enabled ?? false);
- setCommunityMqttIata(appSettings.community_mqtt_iata ?? '');
- setCommunityMqttBrokerHost(appSettings.community_mqtt_broker_host ?? 'mqtt-us-v1.letsmesh.net');
- setCommunityMqttBrokerPort(String(appSettings.community_mqtt_broker_port ?? 443));
- setCommunityMqttEmail(appSettings.community_mqtt_email ?? '');
- }, [appSettings]);
-
- const handleSave = async () => {
- setError(null);
- setBusy(true);
-
- try {
- const update: AppSettingsUpdate = {
- mqtt_broker_host: mqttBrokerHost,
- mqtt_broker_port: parseInt(mqttBrokerPort, 10) || 1883,
- mqtt_username: mqttUsername,
- mqtt_password: mqttPassword,
- mqtt_use_tls: mqttUseTls,
- mqtt_tls_insecure: mqttTlsInsecure,
- mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
- mqtt_publish_messages: mqttPublishMessages,
- mqtt_publish_raw_packets: mqttPublishRawPackets,
- community_mqtt_enabled: communityMqttEnabled,
- community_mqtt_iata: communityMqttIata,
- community_mqtt_broker_host: communityMqttBrokerHost || 'mqtt-us-v1.letsmesh.net',
- community_mqtt_broker_port: parseInt(communityMqttBrokerPort, 10) || 443,
- community_mqtt_email: communityMqttEmail,
- };
- await onSaveAppSettings(update);
- toast.success('MQTT settings saved');
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to save');
- } finally {
- setBusy(false);
- }
- };
-
- return (
-
-
- MQTT support is an experimental feature in open beta. All publishing uses QoS 0
- (at-most-once delivery). Please report any bugs on the{' '}
-
- GitHub issues page
-
- .
-
-
-
- Outgoing messages (DMs and group messages) will be reported to private MQTT brokers in
- decrypted/plaintext form. The raw outgoing packets will NOT be reported to any MQTT broker,
- private or community. This means that{' '}
-
- your advertisements will not be reported to community analytics (LetsMesh/etc.) due to
- fundamental limitations of the radio
- {' '}
- — you don't hear your own advertisements unless they're echoed back to you.
- So, your own advert echoes may result in you being listed on LetsMesh/etc., but if
- you're alone in your mesh, your node will appear as an ingest source within LetsMesh,
- without GPS data/etc. derived from adverts -- we faithfully report only traffic heard on the
- radio (and don't reconstruct synthetic advertisement events to submit). Rely on the
- “My Nodes” or view heard packets to validate that your radio is submitting to
- community sources; if you're alone in your local mesh, the radio itself may not appear
- as a heard/mapped source.
-
-
- {/* Private MQTT Broker */}
-
-
setPrivateExpanded(!privateExpanded)}
- >
-
- {privateExpanded ? '▼' : '▶'}
-
- Private MQTT Broker
-
-
- {health?.mqtt_status === 'connected'
- ? 'Connected'
- : health?.mqtt_status === 'disconnected'
- ? 'Disconnected'
- : 'Disabled'}
-
-
-
- {privateExpanded && (
-
-
- Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
-
-
-
- setMqttPublishMessages(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Publish Messages
-
-
- Forward decrypted DM and channel messages
-
-
-
- setMqttPublishRawPackets(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Publish Raw Packets
-
-
Forward all RF packets
-
- {(mqttPublishMessages || mqttPublishRawPackets) && (
-
-
-
-
-
-
-
-
- setMqttUseTls(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Use TLS
-
-
- {mqttUseTls && (
- <>
-
- setMqttTlsInsecure(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Skip certificate verification
-
-
- Allow self-signed or untrusted broker certificates
-
- >
- )}
-
-
-
-
-
Topic Prefix
-
setMqttTopicPrefix(e.target.value)}
- />
-
-
-
- Decrypted messages{' '}
-
- {'{'}id, type, conversation_key, text, sender_timestamp, received_at,
- paths, outgoing, acked{'}'}
-
-
-
-
{mqttTopicPrefix || 'meshcore'}/dm:<contact_key>
-
{mqttTopicPrefix || 'meshcore'}/gm:<channel_key>
-
-
-
-
- Raw packets{' '}
-
- {'{'}id, observation_id, timestamp, data, payload_type, snr, rssi,
- decrypted, decrypted_info{'}'}
-
-
-
-
{mqttTopicPrefix || 'meshcore'}/raw/dm:<contact_key>
-
{mqttTopicPrefix || 'meshcore'}/raw/gm:<channel_key>
-
{mqttTopicPrefix || 'meshcore'}/raw/unrouted
-
-
-
-
-
- )}
-
- )}
-
-
- {/* Community Analytics */}
-
-
setCommunityExpanded(!communityExpanded)}
- >
-
- {communityExpanded ? '▼' : '▶'}
-
- Community Analytics
-
-
- {health?.community_mqtt_status === 'connected'
- ? 'Connected'
- : health?.community_mqtt_status === 'disconnected'
- ? 'Disconnected'
- : 'Disabled'}
-
-
-
- {communityExpanded && (
-
-
- Share raw packet data with the MeshCore community for coverage mapping and network
- analysis. Only raw RF packets are shared — never decrypted messages. General parity
- with{' '}
-
- meshcore-packet-capture
-
- .
-
-
- setCommunityMqttEnabled(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Enable Community Analytics
-
-
- {communityMqttEnabled && (
-
-
-
-
Broker Host
- setCommunityMqttBrokerHost(e.target.value)}
- />
-
- MQTT over TLS (WebSocket Secure) only
-
-
-
- Broker Port
- setCommunityMqttBrokerPort(e.target.value)}
- />
-
-
-
-
Region Code (IATA)
- setCommunityMqttIata(e.target.value.toUpperCase())}
- className="w-32"
- />
-
- Your nearest airport's{' '}
-
- IATA code
- {' '}
- (required)
-
- {communityMqttIata && (
-
- Topic: meshcore/{communityMqttIata}/<pubkey>/packets
-
- )}
-
-
-
Owner Email (optional)
- setCommunityMqttEmail(e.target.value)}
- />
-
- Used to claim your node on the community aggregator
-
-
-
- )}
-
- )}
-
-
-
- {busy ? 'Saving...' : 'Save MQTT Settings'}
-
-
- {error && (
-
- {error}
-
- )}
-
- );
-}
diff --git a/frontend/src/components/settings/settingsConstants.ts b/frontend/src/components/settings/settingsConstants.ts
index 136fbd08..9bb88686 100644
--- a/frontend/src/components/settings/settingsConstants.ts
+++ b/frontend/src/components/settings/settingsConstants.ts
@@ -3,7 +3,7 @@ export type SettingsSection =
| 'local'
| 'database'
| 'bot'
- | 'mqtt'
+ | 'fanout'
| 'statistics'
| 'about';
@@ -12,7 +12,7 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'local',
'database',
'bot',
- 'mqtt',
+ 'fanout',
'statistics',
'about',
];
@@ -22,7 +22,7 @@ export const SETTINGS_SECTION_LABELS: Record = {
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
bot: '🤖 Bots',
- mqtt: '📤 MQTT',
+ fanout: '📤 Fanout & Forwarding',
statistics: '📊 Statistics',
about: 'About',
};
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index 03eb8546..9ed3cfbc 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -38,8 +38,7 @@ const baseHealth: HealthStatus = {
connection_info: 'Serial: /dev/ttyUSB0',
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
- mqtt_status: null,
- community_mqtt_status: null,
+ fanout_statuses: {},
bots_disabled: false,
};
@@ -159,19 +158,6 @@ function openLocalSection() {
fireEvent.click(localToggle);
}
-function openMqttSection() {
- const mqttToggle = screen.getByRole('button', { name: /MQTT/i });
- fireEvent.click(mqttToggle);
-}
-
-function expandPrivateMqtt() {
- fireEvent.click(screen.getByText('Private MQTT Broker'));
-}
-
-function expandCommunityMqtt() {
- fireEvent.click(screen.getByText('Community Analytics'));
-}
-
function openDatabaseSection() {
const databaseToggle = screen.getByRole('button', { name: /Database/i });
fireEvent.click(databaseToggle);
@@ -430,148 +416,6 @@ describe('SettingsModal', () => {
expect(screen.getByText('42 msgs')).toBeInTheDocument();
});
- it('renders MQTT section with form inputs', () => {
- renderModal();
- openMqttSection();
- expandPrivateMqtt();
-
- // Publish checkboxes always visible
- expect(screen.getByText('Publish Messages')).toBeInTheDocument();
- expect(screen.getByText('Publish Raw Packets')).toBeInTheDocument();
-
- // Broker config hidden until a publish option is enabled
- expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
-
- // Enable one publish option to reveal broker config
- fireEvent.click(screen.getByText('Publish Messages'));
- expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
- expect(screen.getByLabelText('Broker Port')).toBeInTheDocument();
- expect(screen.getByLabelText('Username')).toBeInTheDocument();
- expect(screen.getByLabelText('Password')).toBeInTheDocument();
- expect(screen.getByLabelText('Topic Prefix')).toBeInTheDocument();
- });
-
- it('saves MQTT settings through onSaveAppSettings', async () => {
- const { onSaveAppSettings } = renderModal({
- appSettings: { ...baseSettings, mqtt_publish_messages: true },
- });
- openMqttSection();
- expandPrivateMqtt();
-
- const hostInput = screen.getByLabelText('Broker Host');
- fireEvent.change(hostInput, { target: { value: 'mqtt.example.com' } });
-
- fireEvent.click(screen.getByRole('button', { name: 'Save MQTT Settings' }));
-
- await waitFor(() => {
- expect(onSaveAppSettings).toHaveBeenCalledWith(
- expect.objectContaining({
- mqtt_broker_host: 'mqtt.example.com',
- mqtt_broker_port: 1883,
- })
- );
- });
- });
-
- it('shows MQTT disabled status when mqtt_status is null', () => {
- renderModal({
- appSettings: {
- ...baseSettings,
- mqtt_broker_host: 'broker.local',
- },
- });
- openMqttSection();
-
- // Both MQTT and community MQTT show "Disabled" when null status
- const disabledElements = screen.getAllByText('Disabled');
- expect(disabledElements.length).toBeGreaterThanOrEqual(1);
- });
-
- it('shows MQTT connected status badge', () => {
- renderModal({
- appSettings: {
- ...baseSettings,
- mqtt_broker_host: 'broker.local',
- },
- health: {
- ...baseHealth,
- mqtt_status: 'connected',
- },
- });
- openMqttSection();
-
- expect(screen.getByText('Connected')).toBeInTheDocument();
- });
-
- it('renders community sharing section in MQTT tab', () => {
- renderModal();
- openMqttSection();
- expandCommunityMqtt();
-
- expect(screen.getByText('Community Analytics')).toBeInTheDocument();
- expect(screen.getByText('Enable Community Analytics')).toBeInTheDocument();
- });
-
- it('shows IATA input only when community sharing is enabled', () => {
- renderModal({
- appSettings: {
- ...baseSettings,
- community_mqtt_enabled: false,
- },
- });
- openMqttSection();
- expandCommunityMqtt();
-
- expect(screen.queryByLabelText('Region Code (IATA)')).not.toBeInTheDocument();
-
- // Enable community sharing
- fireEvent.click(screen.getByText('Enable Community Analytics'));
- expect(screen.getByLabelText('Region Code (IATA)')).toBeInTheDocument();
- });
-
- it('includes community MQTT fields in save payload', async () => {
- const { onSaveAppSettings } = renderModal({
- appSettings: {
- ...baseSettings,
- community_mqtt_enabled: true,
- community_mqtt_iata: 'DEN',
- },
- });
- openMqttSection();
-
- fireEvent.click(screen.getByRole('button', { name: 'Save MQTT Settings' }));
-
- await waitFor(() => {
- expect(onSaveAppSettings).toHaveBeenCalledWith(
- expect.objectContaining({
- community_mqtt_enabled: true,
- community_mqtt_iata: 'DEN',
- })
- );
- });
- });
-
- it('shows community MQTT connected status badge', () => {
- renderModal({
- appSettings: {
- ...baseSettings,
- community_mqtt_enabled: true,
- },
- health: {
- ...baseHealth,
- community_mqtt_status: 'connected',
- },
- });
- openMqttSection();
-
- // Community Analytics sub-section should show Connected
- const communitySection = screen.getByText('Community Analytics').closest('div');
- expect(communitySection).not.toBeNull();
- // Both MQTT and community could show "Connected" — check count
- const connectedElements = screen.getAllByText('Connected');
- expect(connectedElements.length).toBeGreaterThanOrEqual(1);
- });
-
it('fetches statistics when expanded in mobile external-nav mode', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [],
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index d5fcea6f..ee98507b 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -23,17 +23,33 @@ export interface RadioConfigUpdate {
radio?: RadioSettings;
}
+export interface FanoutStatusEntry {
+ name: string;
+ type: string;
+ status: string;
+}
+
export interface HealthStatus {
status: string;
radio_connected: boolean;
connection_info: string | null;
database_size_mb: number;
oldest_undecrypted_timestamp: number | null;
- mqtt_status: string | null;
- community_mqtt_status: string | null;
+ fanout_statuses: Record;
bots_disabled: boolean;
}
+export interface FanoutConfig {
+ id: string;
+ type: string;
+ name: string;
+ enabled: boolean;
+ config: Record;
+ scope: Record;
+ sort_order: number;
+ created_at: number;
+}
+
export interface MaintenanceResult {
packets_deleted: number;
vacuumed: boolean;
@@ -240,20 +256,6 @@ export interface AppSettingsUpdate {
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: 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[];
diff --git a/tests/conftest.py b/tests/conftest.py
index 86f2c7ef..976b3023 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -68,7 +68,7 @@ def captured_broadcasts():
"""Capture WebSocket broadcasts for verification."""
broadcasts = []
- def mock_broadcast(event_type: str, data: dict):
+ def mock_broadcast(event_type: str, data: dict, **kwargs):
broadcasts.append({"type": event_type, "data": data})
return broadcasts, mock_broadcast
diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py
index 159a5123..27c1a08b 100644
--- a/tests/test_community_mqtt.py
+++ b/tests/test_community_mqtt.py
@@ -21,7 +21,6 @@ from app.community_mqtt import (
_format_raw_packet,
_generate_jwt_token,
_get_client_version,
- community_mqtt_broadcast,
)
from app.models import AppSettings
@@ -394,39 +393,6 @@ class TestCommunityMqttPublisher:
assert pub._is_configured() is True
-class TestCommunityMqttBroadcast:
- def test_filters_non_raw_packet(self):
- """Non-raw_packet events should be ignored."""
- with patch("app.community_mqtt.community_publisher") as mock_pub:
- mock_pub.connected = True
- mock_pub._settings = AppSettings(community_mqtt_enabled=True)
- community_mqtt_broadcast("message", {"text": "hello"})
- # No asyncio.create_task should be called for non-raw_packet events
- # Since we're filtering, we just verify no exception
-
- def test_skips_when_disconnected(self):
- """Should not publish when disconnected."""
- with (
- patch("app.community_mqtt.community_publisher") as mock_pub,
- patch("app.community_mqtt.asyncio.create_task") as mock_task,
- ):
- mock_pub.connected = False
- mock_pub._settings = AppSettings(community_mqtt_enabled=True)
- community_mqtt_broadcast("raw_packet", {"data": "00"})
- mock_task.assert_not_called()
-
- def test_skips_when_settings_none(self):
- """Should not publish when settings are None."""
- with (
- patch("app.community_mqtt.community_publisher") as mock_pub,
- patch("app.community_mqtt.asyncio.create_task") as mock_task,
- ):
- mock_pub.connected = True
- mock_pub._settings = None
- community_mqtt_broadcast("raw_packet", {"data": "00"})
- mock_task.assert_not_called()
-
-
class TestPublishFailureSetsDisconnected:
@pytest.mark.asyncio
async def test_publish_error_sets_connected_false(self):
diff --git a/tests/test_fanout.py b/tests/test_fanout.py
new file mode 100644
index 00000000..e9316e0c
--- /dev/null
+++ b/tests/test_fanout.py
@@ -0,0 +1,528 @@
+"""Tests for fanout bus: manager, scope matching, repository, and modules."""
+
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from app.database import Database
+from app.fanout.base import FanoutModule
+from app.fanout.manager import (
+ FanoutManager,
+ _scope_matches_message,
+ _scope_matches_raw,
+)
+
+# ---------------------------------------------------------------------------
+# Scope matching unit tests
+# ---------------------------------------------------------------------------
+
+
+class TestScopeMatchesMessage:
+ def test_all_matches_everything(self):
+ assert _scope_matches_message({"messages": "all"}, {"type": "PRIV"})
+
+ def test_none_matches_nothing(self):
+ assert not _scope_matches_message({"messages": "none"}, {"type": "PRIV"})
+
+ def test_missing_key_defaults_none(self):
+ assert not _scope_matches_message({}, {"type": "PRIV"})
+
+ def test_dict_channels_all(self):
+ scope = {"messages": {"channels": "all", "contacts": "none"}}
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+
+ def test_dict_channels_none(self):
+ scope = {"messages": {"channels": "none"}}
+ assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+
+ def test_dict_channels_list_match(self):
+ scope = {"messages": {"channels": ["ch1", "ch2"]}}
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+
+ def test_dict_channels_list_no_match(self):
+ scope = {"messages": {"channels": ["ch1", "ch2"]}}
+ assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch3"})
+
+ def test_dict_contacts_all(self):
+ scope = {"messages": {"contacts": "all"}}
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
+
+ def test_dict_contacts_list_match(self):
+ scope = {"messages": {"contacts": ["pk1"]}}
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
+
+ def test_dict_contacts_list_no_match(self):
+ scope = {"messages": {"contacts": ["pk1"]}}
+ assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk2"})
+
+
+class TestScopeMatchesRaw:
+ def test_all_matches(self):
+ assert _scope_matches_raw({"raw_packets": "all"}, {})
+
+ def test_none_does_not_match(self):
+ assert not _scope_matches_raw({"raw_packets": "none"}, {})
+
+ def test_missing_key_does_not_match(self):
+ assert not _scope_matches_raw({}, {})
+
+
+# ---------------------------------------------------------------------------
+# FanoutManager dispatch tests
+# ---------------------------------------------------------------------------
+
+
+class StubModule(FanoutModule):
+ """Minimal FanoutModule for testing dispatch."""
+
+ def __init__(self):
+ super().__init__("stub", {})
+ self.message_calls: list[dict] = []
+ self.raw_calls: list[dict] = []
+ self._status = "connected"
+
+ async def start(self) -> None:
+ pass
+
+ async def stop(self) -> None:
+ pass
+
+ async def on_message(self, data: dict) -> None:
+ self.message_calls.append(data)
+
+ async def on_raw(self, data: dict) -> None:
+ self.raw_calls.append(data)
+
+ @property
+ def status(self) -> str:
+ return self._status
+
+
+class TestFanoutManagerDispatch:
+ @pytest.mark.asyncio
+ async def test_broadcast_message_dispatches_to_matching_module(self):
+ manager = FanoutManager()
+ mod = StubModule()
+ scope = {"messages": "all", "raw_packets": "none"}
+ manager._modules["test-id"] = (mod, scope)
+
+ await manager.broadcast_message({"type": "PRIV", "conversation_key": "pk1"})
+
+ assert len(mod.message_calls) == 1
+ assert mod.message_calls[0]["conversation_key"] == "pk1"
+
+ @pytest.mark.asyncio
+ async def test_broadcast_message_skips_non_matching_module(self):
+ manager = FanoutManager()
+ mod = StubModule()
+ scope = {"messages": "none", "raw_packets": "all"}
+ manager._modules["test-id"] = (mod, scope)
+
+ await manager.broadcast_message({"type": "PRIV", "conversation_key": "pk1"})
+
+ assert len(mod.message_calls) == 0
+
+ @pytest.mark.asyncio
+ async def test_broadcast_raw_dispatches_to_matching_module(self):
+ manager = FanoutManager()
+ mod = StubModule()
+ scope = {"messages": "none", "raw_packets": "all"}
+ manager._modules["test-id"] = (mod, scope)
+
+ await manager.broadcast_raw({"data": "aabbccdd"})
+
+ assert len(mod.raw_calls) == 1
+
+ @pytest.mark.asyncio
+ async def test_broadcast_raw_skips_non_matching(self):
+ manager = FanoutManager()
+ mod = StubModule()
+ scope = {"messages": "all", "raw_packets": "none"}
+ manager._modules["test-id"] = (mod, scope)
+
+ await manager.broadcast_raw({"data": "aabbccdd"})
+
+ assert len(mod.raw_calls) == 0
+
+ @pytest.mark.asyncio
+ async def test_stop_all_stops_all_modules(self):
+ manager = FanoutManager()
+ mod1 = StubModule()
+ mod1.stop = AsyncMock()
+ mod2 = StubModule()
+ mod2.stop = AsyncMock()
+ manager._modules["id1"] = (mod1, {})
+ manager._modules["id2"] = (mod2, {})
+
+ await manager.stop_all()
+
+ mod1.stop.assert_called_once()
+ mod2.stop.assert_called_once()
+ assert len(manager._modules) == 0
+
+ @pytest.mark.asyncio
+ async def test_module_error_does_not_halt_broadcast(self):
+ manager = FanoutManager()
+ bad_mod = StubModule()
+
+ async def fail(data):
+ raise RuntimeError("boom")
+
+ bad_mod.on_message = fail
+ good_mod = StubModule()
+
+ manager._modules["bad"] = (bad_mod, {"messages": "all"})
+ manager._modules["good"] = (good_mod, {"messages": "all"})
+
+ await manager.broadcast_message({"type": "PRIV", "conversation_key": "pk1"})
+
+ # Good module should still receive the message despite the bad one failing
+ assert len(good_mod.message_calls) == 1
+
+ def test_get_statuses(self):
+ manager = FanoutManager()
+ mod = StubModule()
+ mod._status = "connected"
+ manager._modules["test-id"] = (mod, {})
+
+ with patch(
+ "app.repository.fanout._configs_cache",
+ {"test-id": {"name": "Test", "type": "mqtt_private"}},
+ ):
+ statuses = manager.get_statuses()
+
+ assert "test-id" in statuses
+ assert statuses["test-id"]["status"] == "connected"
+ assert statuses["test-id"]["name"] == "Test"
+ assert statuses["test-id"]["type"] == "mqtt_private"
+
+
+# ---------------------------------------------------------------------------
+# Repository tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+async def fanout_db():
+ """Create an in-memory database with fanout_configs table."""
+ import app.repository.fanout as fanout_mod
+
+ db = Database(":memory:")
+ await db.connect()
+
+ await db.conn.execute("""
+ CREATE TABLE IF NOT EXISTS fanout_configs (
+ id TEXT PRIMARY KEY,
+ type TEXT NOT NULL,
+ name TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ config TEXT NOT NULL DEFAULT '{}',
+ scope TEXT NOT NULL DEFAULT '{}',
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL DEFAULT 0
+ )
+ """)
+ await db.conn.commit()
+
+ original_db = fanout_mod.db
+ fanout_mod.db = db
+
+ try:
+ yield db
+ finally:
+ fanout_mod.db = original_db
+ await db.disconnect()
+
+
+class TestFanoutConfigRepository:
+ @pytest.mark.asyncio
+ async def test_create_and_get(self, fanout_db):
+ from app.repository.fanout import FanoutConfigRepository
+
+ cfg = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Test MQTT",
+ config={"broker_host": "localhost", "broker_port": 1883},
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=True,
+ )
+
+ assert cfg["type"] == "mqtt_private"
+ assert cfg["name"] == "Test MQTT"
+ assert cfg["enabled"] is True
+ assert cfg["config"]["broker_host"] == "localhost"
+
+ fetched = await FanoutConfigRepository.get(cfg["id"])
+ assert fetched is not None
+ assert fetched["id"] == cfg["id"]
+
+ @pytest.mark.asyncio
+ async def test_get_all(self, fanout_db):
+ from app.repository.fanout import FanoutConfigRepository
+
+ await FanoutConfigRepository.create(
+ config_type="mqtt_private", name="A", config={}, scope={}, enabled=True
+ )
+ await FanoutConfigRepository.create(
+ config_type="mqtt_community", name="B", config={}, scope={}, enabled=False
+ )
+
+ all_configs = await FanoutConfigRepository.get_all()
+ assert len(all_configs) == 2
+
+ @pytest.mark.asyncio
+ async def test_update(self, fanout_db):
+ from app.repository.fanout import FanoutConfigRepository
+
+ cfg = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Original",
+ config={"broker_host": "old"},
+ scope={},
+ enabled=True,
+ )
+
+ updated = await FanoutConfigRepository.update(
+ cfg["id"],
+ name="Renamed",
+ config={"broker_host": "new"},
+ enabled=False,
+ )
+
+ assert updated is not None
+ assert updated["name"] == "Renamed"
+ assert updated["config"]["broker_host"] == "new"
+ assert updated["enabled"] is False
+
+ @pytest.mark.asyncio
+ async def test_delete(self, fanout_db):
+ from app.repository.fanout import FanoutConfigRepository
+
+ cfg = await FanoutConfigRepository.create(
+ config_type="mqtt_private", name="Doomed", config={}, scope={}, enabled=True
+ )
+
+ await FanoutConfigRepository.delete(cfg["id"])
+
+ assert await FanoutConfigRepository.get(cfg["id"]) is None
+
+ @pytest.mark.asyncio
+ async def test_get_enabled(self, fanout_db):
+ from app.repository.fanout import FanoutConfigRepository
+
+ await FanoutConfigRepository.create(
+ config_type="mqtt_private", name="On", config={}, scope={}, enabled=True
+ )
+ await FanoutConfigRepository.create(
+ config_type="mqtt_community", name="Off", config={}, scope={}, enabled=False
+ )
+
+ enabled = await FanoutConfigRepository.get_enabled()
+ assert len(enabled) == 1
+ assert enabled[0]["name"] == "On"
+
+
+# ---------------------------------------------------------------------------
+# broadcast_event realtime=False test
+# ---------------------------------------------------------------------------
+
+
+class TestBroadcastEventRealtime:
+ @pytest.mark.asyncio
+ async def test_realtime_false_does_not_dispatch_fanout(self):
+ """broadcast_event with realtime=False should NOT trigger fanout dispatch."""
+ from app.websocket import broadcast_event
+
+ with (
+ patch("app.websocket.ws_manager") as mock_ws,
+ patch("app.fanout.manager.fanout_manager") as mock_fm,
+ ):
+ mock_ws.broadcast = AsyncMock()
+
+ broadcast_event("message", {"type": "PRIV"}, realtime=False)
+
+ # Allow tasks to run
+ import asyncio
+
+ await asyncio.sleep(0)
+
+ # WebSocket broadcast should still fire
+ mock_ws.broadcast.assert_called_once()
+ # But fanout should NOT be called
+ mock_fm.broadcast_message.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_realtime_true_dispatches_fanout(self):
+ """broadcast_event with realtime=True should trigger fanout dispatch."""
+ from app.websocket import broadcast_event
+
+ with (
+ patch("app.websocket.ws_manager") as mock_ws,
+ patch("app.fanout.manager.fanout_manager") as mock_fm,
+ ):
+ mock_ws.broadcast = AsyncMock()
+ mock_fm.broadcast_message = AsyncMock()
+
+ broadcast_event("message", {"type": "PRIV"}, realtime=True)
+
+ import asyncio
+
+ await asyncio.sleep(0)
+
+ mock_ws.broadcast.assert_called_once()
+ 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()
diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py
new file mode 100644
index 00000000..ab2a5f9f
--- /dev/null
+++ b/tests/test_fanout_integration.py
@@ -0,0 +1,403 @@
+"""Integration tests: real MQTT capture broker + real fanout modules.
+
+Spins up a minimal in-process MQTT 3.1.1 broker on a random port, creates
+fanout configs in an in-memory DB, starts real MqttPrivateModule instances
+via the FanoutManager, and verifies that PUBLISH packets arrive (or don't)
+based on enabled/disabled state and scope settings.
+"""
+
+import asyncio
+import json
+import struct
+
+import pytest
+
+import app.repository.fanout as fanout_mod
+from app.database import Database
+from app.fanout.manager import FanoutManager
+from app.repository.fanout import FanoutConfigRepository
+
+# ---------------------------------------------------------------------------
+# Minimal async MQTT 3.1.1 capture broker
+# ---------------------------------------------------------------------------
+
+
+class MqttCaptureBroker:
+ """Tiny TCP server that speaks just enough MQTT to capture PUBLISH packets."""
+
+ def __init__(self):
+ self.published: list[tuple[str, dict]] = []
+ self._server: asyncio.Server | None = None
+ self.port: int = 0
+
+ async def start(self) -> int:
+ self._server = await asyncio.start_server(self._handle_client, "127.0.0.1", 0)
+ self.port = self._server.sockets[0].getsockname()[1]
+ return self.port
+
+ async def stop(self):
+ if self._server:
+ self._server.close()
+ await self._server.wait_closed()
+
+ async def wait_for(self, count: int, timeout: float = 5.0) -> list[tuple[str, dict]]:
+ """Block until *count* messages captured, or timeout."""
+ deadline = asyncio.get_event_loop().time() + timeout
+ while len(self.published) < count:
+ if asyncio.get_event_loop().time() >= deadline:
+ break
+ await asyncio.sleep(0.02)
+ return list(self.published)
+
+ async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
+ try:
+ while True:
+ first = await reader.readexactly(1)
+ pkt_type = (first[0] & 0xF0) >> 4
+ rem_len = await self._read_varlen(reader)
+ payload = await reader.readexactly(rem_len) if rem_len else b""
+
+ if pkt_type == 1: # CONNECT -> CONNACK
+ writer.write(b"\x20\x02\x00\x00")
+ await writer.drain()
+ elif pkt_type == 3: # PUBLISH (QoS 0)
+ topic_len = struct.unpack("!H", payload[:2])[0]
+ topic = payload[2 : 2 + topic_len].decode()
+ body = payload[2 + topic_len :]
+ try:
+ data = json.loads(body)
+ except Exception:
+ data = {}
+ self.published.append((topic, data))
+ elif pkt_type == 12: # PINGREQ -> PINGRESP
+ writer.write(b"\xd0\x00")
+ await writer.drain()
+ elif pkt_type == 14: # DISCONNECT
+ break
+ except (asyncio.IncompleteReadError, ConnectionError, OSError):
+ pass
+ finally:
+ writer.close()
+
+ @staticmethod
+ async def _read_varlen(reader: asyncio.StreamReader) -> int:
+ value, shift = 0, 0
+ while True:
+ b = (await reader.readexactly(1))[0]
+ value |= (b & 0x7F) << shift
+ if not (b & 0x80):
+ return value
+ shift += 7
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+async def mqtt_broker():
+ broker = MqttCaptureBroker()
+ await broker.start()
+ yield broker
+ await broker.stop()
+
+
+@pytest.fixture
+async def integration_db():
+ """In-memory DB with fanout_configs, wired into the repository module.
+
+ Database.connect() runs all migrations which create the fanout_configs
+ table, so no manual DDL is needed here.
+ """
+ test_db = Database(":memory:")
+ await test_db.connect()
+
+ original_db = fanout_mod.db
+ fanout_mod.db = test_db
+ try:
+ yield test_db
+ finally:
+ fanout_mod.db = original_db
+ await test_db.disconnect()
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+async def _wait_connected(manager: FanoutManager, config_id: str, timeout: float = 5.0):
+ """Poll until the module reports 'connected'."""
+ deadline = asyncio.get_event_loop().time() + timeout
+ while asyncio.get_event_loop().time() < deadline:
+ entry = manager._modules.get(config_id)
+ if entry and entry[0].status == "connected":
+ return
+ await asyncio.sleep(0.05)
+ raise TimeoutError(f"Module {config_id} did not connect within {timeout}s")
+
+
+def _private_config(port: int, prefix: str) -> dict:
+ return {"broker_host": "127.0.0.1", "broker_port": port, "topic_prefix": prefix}
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestFanoutMqttIntegration:
+ """End-to-end: real capture broker <-> real fanout modules."""
+
+ @pytest.mark.asyncio
+ async def test_both_enabled_both_receive(self, mqtt_broker, integration_db):
+ """Two enabled integrations with different prefixes both receive messages."""
+ from unittest.mock import patch
+
+ cfg_a = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Alpha",
+ config=_private_config(mqtt_broker.port, "alpha"),
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=True,
+ )
+ cfg_b = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Beta",
+ config=_private_config(mqtt_broker.port, "beta"),
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ with (
+ patch("app.mqtt_base._broadcast_health"),
+ patch("app.websocket.broadcast_success"),
+ patch("app.websocket.broadcast_error"),
+ patch("app.websocket.broadcast_health"),
+ ):
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg_a["id"])
+ await _wait_connected(manager, cfg_b["id"])
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "hello"}
+ )
+
+ messages = await mqtt_broker.wait_for(2)
+ finally:
+ await manager.stop_all()
+
+ topics = {m[0] for m in messages}
+ assert "alpha/dm:pk1" in topics
+ assert "beta/dm:pk1" in topics
+
+ @pytest.mark.asyncio
+ async def test_one_disabled_only_enabled_receives(self, mqtt_broker, integration_db):
+ """Disabled integration must not publish any messages."""
+ from unittest.mock import patch
+
+ cfg_on = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Enabled",
+ config=_private_config(mqtt_broker.port, "on"),
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=True,
+ )
+ await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Disabled",
+ config=_private_config(mqtt_broker.port, "off"),
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=False,
+ )
+
+ manager = FanoutManager()
+ with (
+ patch("app.mqtt_base._broadcast_health"),
+ patch("app.websocket.broadcast_success"),
+ patch("app.websocket.broadcast_error"),
+ patch("app.websocket.broadcast_health"),
+ ):
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg_on["id"])
+
+ # Only 1 module should be loaded
+ assert len(manager._modules) == 1
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "hello"}
+ )
+
+ await mqtt_broker.wait_for(1)
+ await asyncio.sleep(0.2) # extra time to catch stray messages
+ finally:
+ await manager.stop_all()
+
+ assert len(mqtt_broker.published) == 1
+ assert mqtt_broker.published[0][0] == "on/dm:pk1"
+
+ @pytest.mark.asyncio
+ async def test_both_disabled_nothing_published(self, mqtt_broker, integration_db):
+ """Both disabled -> zero messages published."""
+ from unittest.mock import patch
+
+ await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="A",
+ config=_private_config(mqtt_broker.port, "a"),
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=False,
+ )
+ await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="B",
+ config=_private_config(mqtt_broker.port, "b"),
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=False,
+ )
+
+ manager = FanoutManager()
+ with (
+ patch("app.mqtt_base._broadcast_health"),
+ patch("app.websocket.broadcast_success"),
+ patch("app.websocket.broadcast_error"),
+ patch("app.websocket.broadcast_health"),
+ ):
+ try:
+ await manager.load_from_db()
+ assert len(manager._modules) == 0
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "hello"}
+ )
+ await asyncio.sleep(0.3)
+ finally:
+ await manager.stop_all()
+
+ assert len(mqtt_broker.published) == 0
+
+ @pytest.mark.asyncio
+ async def test_disable_after_enable_stops_publishing(self, mqtt_broker, integration_db):
+ """Disabling a live integration stops its publishing immediately."""
+ from unittest.mock import patch
+
+ cfg = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Toggle",
+ config=_private_config(mqtt_broker.port, "toggle"),
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ with (
+ patch("app.mqtt_base._broadcast_health"),
+ patch("app.websocket.broadcast_success"),
+ patch("app.websocket.broadcast_error"),
+ patch("app.websocket.broadcast_health"),
+ ):
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ # Publishes while enabled
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "msg1"}
+ )
+ await mqtt_broker.wait_for(1)
+ assert len(mqtt_broker.published) == 1
+
+ # Disable via DB + reload
+ await FanoutConfigRepository.update(cfg["id"], enabled=False)
+ await manager.reload_config(cfg["id"])
+ assert cfg["id"] not in manager._modules
+
+ # Should NOT publish after disable
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk2", "text": "msg2"}
+ )
+ await asyncio.sleep(0.3)
+ finally:
+ await manager.stop_all()
+
+ # Only the first message
+ assert len(mqtt_broker.published) == 1
+ assert mqtt_broker.published[0][0] == "toggle/dm:pk1"
+
+ @pytest.mark.asyncio
+ async def test_scope_messages_only_no_raw(self, mqtt_broker, integration_db):
+ """Module with raw_packets=none receives messages but not raw packets."""
+ from unittest.mock import patch
+
+ cfg = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Messages Only",
+ config=_private_config(mqtt_broker.port, "msgsonly"),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ with (
+ patch("app.mqtt_base._broadcast_health"),
+ patch("app.websocket.broadcast_success"),
+ patch("app.websocket.broadcast_error"),
+ patch("app.websocket.broadcast_health"),
+ ):
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "hi"}
+ )
+ await manager.broadcast_raw({"data": "aabbccdd"})
+
+ await mqtt_broker.wait_for(1)
+ await asyncio.sleep(0.2)
+ finally:
+ await manager.stop_all()
+
+ assert len(mqtt_broker.published) == 1
+ assert "dm:pk1" in mqtt_broker.published[0][0]
+
+ @pytest.mark.asyncio
+ async def test_scope_raw_only_no_messages(self, mqtt_broker, integration_db):
+ """Module with messages=none receives raw packets but not decoded messages."""
+ from unittest.mock import patch
+
+ cfg = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Raw Only",
+ config=_private_config(mqtt_broker.port, "rawonly"),
+ scope={"messages": "none", "raw_packets": "all"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ with (
+ patch("app.mqtt_base._broadcast_health"),
+ patch("app.websocket.broadcast_success"),
+ patch("app.websocket.broadcast_error"),
+ patch("app.websocket.broadcast_health"),
+ ):
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "hi"}
+ )
+ await manager.broadcast_raw({"data": "aabbccdd"})
+
+ await mqtt_broker.wait_for(1)
+ await asyncio.sleep(0.2)
+ finally:
+ await manager.stop_all()
+
+ assert len(mqtt_broker.published) == 1
+ assert "raw/" in mqtt_broker.published[0][0]
diff --git a/tests/test_health_mqtt_status.py b/tests/test_health_mqtt_status.py
index c504d181..6a772d7b 100644
--- a/tests/test_health_mqtt_status.py
+++ b/tests/test_health_mqtt_status.py
@@ -1,7 +1,7 @@
-"""Tests for health endpoint MQTT status field.
+"""Tests for health endpoint fanout status fields.
-Verifies that build_health_data correctly reports MQTT status as
-'connected', 'disconnected', or 'disabled' based on publisher state.
+Verifies that build_health_data correctly reports fanout module statuses
+via the fanout_manager.
"""
from unittest.mock import patch
@@ -11,96 +11,34 @@ import pytest
from app.routers.health import build_health_data
-class TestHealthMqttStatus:
- """Test MQTT status in build_health_data."""
+class TestHealthFanoutStatus:
+ """Test fanout_statuses in build_health_data."""
@pytest.mark.asyncio
- async def test_mqtt_disabled_when_not_configured(self, test_db):
- """MQTT status is 'disabled' when broker host is empty."""
- from app.mqtt import mqtt_publisher
-
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
- try:
- from app.models import AppSettings
-
- mqtt_publisher._settings = AppSettings(mqtt_broker_host="")
- mqtt_publisher.connected = False
-
+ async def test_no_fanout_modules_returns_empty(self, test_db):
+ """fanout_statuses should be empty dict when no modules are running."""
+ with patch("app.fanout.manager.fanout_manager") as mock_fm:
+ mock_fm.get_statuses.return_value = {}
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
- assert data["mqtt_status"] == "disabled"
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
+ assert data["fanout_statuses"] == {}
@pytest.mark.asyncio
- async def test_mqtt_disabled_when_nothing_to_publish(self, test_db):
- """MQTT status is 'disabled' when broker host is set but no publish options enabled."""
- from app.mqtt import mqtt_publisher
+ async def test_fanout_statuses_reflect_manager(self, test_db):
+ """fanout_statuses should return whatever the manager reports."""
+ mock_statuses = {
+ "uuid-1": {"name": "Private MQTT", "type": "mqtt_private", "status": "connected"},
+ "uuid-2": {
+ "name": "Community MQTT",
+ "type": "mqtt_community",
+ "status": "disconnected",
+ },
+ }
+ with patch("app.fanout.manager.fanout_manager") as mock_fm:
+ mock_fm.get_statuses.return_value = mock_statuses
+ data = await build_health_data(True, "Serial: /dev/ttyUSB0")
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
- try:
- from app.models import AppSettings
-
- mqtt_publisher._settings = AppSettings(
- mqtt_broker_host="broker.local",
- mqtt_publish_messages=False,
- mqtt_publish_raw_packets=False,
- )
- mqtt_publisher.connected = False
-
- data = await build_health_data(True, "TCP: 1.2.3.4:4000")
-
- assert data["mqtt_status"] == "disabled"
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
-
- @pytest.mark.asyncio
- async def test_mqtt_connected_when_publisher_connected(self, test_db):
- """MQTT status is 'connected' when publisher is connected."""
- from app.mqtt import mqtt_publisher
-
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
- try:
- from app.models import AppSettings
-
- mqtt_publisher._settings = AppSettings(
- mqtt_broker_host="broker.local", mqtt_publish_messages=True
- )
- mqtt_publisher.connected = True
-
- data = await build_health_data(True, "TCP: 1.2.3.4:4000")
-
- assert data["mqtt_status"] == "connected"
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
-
- @pytest.mark.asyncio
- async def test_mqtt_disconnected_when_configured_but_not_connected(self, test_db):
- """MQTT status is 'disconnected' when configured but not connected."""
- from app.mqtt import mqtt_publisher
-
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
- try:
- from app.models import AppSettings
-
- mqtt_publisher._settings = AppSettings(
- mqtt_broker_host="broker.local", mqtt_publish_raw_packets=True
- )
- mqtt_publisher.connected = False
-
- data = await build_health_data(False, None)
-
- assert data["mqtt_status"] == "disconnected"
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
+ assert data["fanout_statuses"] == mock_statuses
@pytest.mark.asyncio
async def test_health_status_ok_when_connected(self, test_db):
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 5c318986..112efd25 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
- assert applied == 35 # All migrations run
- assert await get_version(conn) == 35
+ assert applied == 36 # All migrations run
+ assert await get_version(conn) == 36
# 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 == 35 # All migrations run
+ assert applied1 == 36 # All migrations run
assert applied2 == 0 # No migrations on second run
- assert await get_version(conn) == 35
+ assert await get_version(conn) == 36
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 == 35
- assert await get_version(conn) == 35
+ assert applied == 36
+ assert await get_version(conn) == 36
finally:
await conn.close()
@@ -374,10 +374,10 @@ class TestMigration013:
)
await conn.commit()
- # Run migration 13 (plus 14-34 which also run)
+ # Run migration 13 (plus 14-36 which also run)
applied = await run_migrations(conn)
- assert applied == 23
- assert await get_version(conn) == 35
+ assert applied == 24
+ assert await get_version(conn) == 36
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
@@ -497,7 +497,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
- assert await get_version(conn) == 35
+ assert await get_version(conn) == 36
# Verify autoindex is gone
cursor = await conn.execute(
@@ -575,8 +575,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 18 # Migrations 18-35 run (18+19 skip internally)
- assert await get_version(conn) == 35
+ assert applied == 19 # Migrations 18-36 run (18+19 skip internally)
+ assert await get_version(conn) == 36
finally:
await conn.close()
@@ -648,7 +648,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
- assert await get_version(conn) == 35
+ assert await get_version(conn) == 36
# Verify autoindex is gone
cursor = await conn.execute(
@@ -714,8 +714,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
- assert applied == 16 # Migrations 20-35
- assert await get_version(conn) == 35
+ assert applied == 17 # Migrations 20-36
+ assert await get_version(conn) == 36
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -745,7 +745,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
- assert applied == 15 # Migrations 21-35 still run
+ assert applied == 16 # Migrations 21-36 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -803,8 +803,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 8
- assert await get_version(conn) == 35
+ assert applied == 9
+ assert await get_version(conn) == 36
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -873,8 +873,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 8 # Version still bumped
- assert await get_version(conn) == 35
+ assert applied == 9 # Version still bumped
+ assert await get_version(conn) == 36
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -923,8 +923,8 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 4
- assert await get_version(conn) == 35
+ assert applied == 5
+ assert await get_version(conn) == 36
# Verify all columns exist with correct defaults
cursor = await conn.execute(
@@ -996,8 +996,8 @@ class TestMigration034:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 2
- assert await get_version(conn) == 35
+ assert applied == 3
+ assert await get_version(conn) == 36
# Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1039,8 +1039,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 3
- assert await get_version(conn) == 35
+ assert applied == 4
+ assert await get_version(conn) == 36
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py
index 7be34734..d17ee499 100644
--- a/tests/test_mqtt.py
+++ b/tests/test_mqtt.py
@@ -6,11 +6,7 @@ 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.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
def _make_settings(**overrides) -> AppSettings:
@@ -162,114 +158,6 @@ class TestMqttPublisher:
assert pub._client is None
-class TestMqttBroadcast:
- @pytest.mark.asyncio
- async def test_mqtt_broadcast_skips_when_disconnected(self):
- """mqtt_broadcast should return immediately if publisher is disconnected."""
- from app.mqtt import mqtt_publisher
-
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
-
- try:
- mqtt_publisher.connected = False
- mqtt_publisher._settings = _make_settings()
-
- # This should not create any tasks or fail
- from app.mqtt import mqtt_broadcast
-
- mqtt_broadcast("message", {"type": "PRIV", "conversation_key": "abc"})
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
-
- @pytest.mark.asyncio
- async def test_mqtt_maybe_publish_message(self):
- """_mqtt_maybe_publish should call publish for message events."""
- from app.mqtt import _mqtt_maybe_publish, mqtt_publisher
-
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
-
- try:
- mqtt_publisher._settings = _make_settings(mqtt_publish_messages=True)
- mqtt_publisher.connected = True
-
- with patch.object(mqtt_publisher, "publish", new_callable=AsyncMock) as mock_pub:
- await _mqtt_maybe_publish("message", {"type": "PRIV", "conversation_key": "abc123"})
- mock_pub.assert_called_once()
- topic = mock_pub.call_args[0][0]
- assert topic == "meshcore/dm:abc123"
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
-
- @pytest.mark.asyncio
- async def test_mqtt_maybe_publish_raw_packet(self):
- """_mqtt_maybe_publish should call publish for raw_packet events."""
- from app.mqtt import _mqtt_maybe_publish, mqtt_publisher
-
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
-
- try:
- mqtt_publisher._settings = _make_settings(mqtt_publish_raw_packets=True)
- mqtt_publisher.connected = True
-
- with patch.object(mqtt_publisher, "publish", new_callable=AsyncMock) as mock_pub:
- await _mqtt_maybe_publish(
- "raw_packet",
- {"decrypted_info": {"channel_key": "ch1", "contact_key": None}},
- )
- mock_pub.assert_called_once()
- topic = mock_pub.call_args[0][0]
- assert topic == "meshcore/raw/gm:ch1"
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
-
- @pytest.mark.asyncio
- async def test_mqtt_maybe_publish_skips_disabled_messages(self):
- """_mqtt_maybe_publish should skip messages when publish_messages is False."""
- from app.mqtt import _mqtt_maybe_publish, mqtt_publisher
-
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
-
- try:
- mqtt_publisher._settings = _make_settings(mqtt_publish_messages=False)
- mqtt_publisher.connected = True
-
- with patch.object(mqtt_publisher, "publish", new_callable=AsyncMock) as mock_pub:
- await _mqtt_maybe_publish("message", {"type": "PRIV", "conversation_key": "abc"})
- mock_pub.assert_not_called()
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
-
- @pytest.mark.asyncio
- async def test_mqtt_maybe_publish_skips_disabled_raw_packets(self):
- """_mqtt_maybe_publish should skip raw_packets when publish_raw_packets is False."""
- from app.mqtt import _mqtt_maybe_publish, mqtt_publisher
-
- original_settings = mqtt_publisher._settings
- original_connected = mqtt_publisher.connected
-
- try:
- mqtt_publisher._settings = _make_settings(mqtt_publish_raw_packets=False)
- mqtt_publisher.connected = True
-
- with patch.object(mqtt_publisher, "publish", new_callable=AsyncMock) as mock_pub:
- await _mqtt_maybe_publish(
- "raw_packet",
- {"decrypted_info": None},
- )
- mock_pub.assert_not_called()
- finally:
- mqtt_publisher._settings = original_settings
- mqtt_publisher.connected = original_connected
-
-
class TestBuildTlsContext:
def test_returns_none_when_tls_disabled(self):
settings = _make_settings(mqtt_use_tls=False)
diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py
index 71207420..bb7d542d 100644
--- a/tests/test_settings_router.py
+++ b/tests/test_settings_router.py
@@ -68,130 +68,6 @@ class TestUpdateSettings:
assert exc.value.status_code == 400
assert "syntax error" in exc.value.detail.lower()
- @pytest.mark.asyncio
- async def test_mqtt_fields_round_trip(self, test_db):
- """MQTT settings should be saved and retrieved correctly."""
- mock_publisher = type("MockPublisher", (), {"restart": AsyncMock()})()
- with patch("app.mqtt.mqtt_publisher", mock_publisher):
- result = await update_settings(
- AppSettingsUpdate(
- mqtt_broker_host="broker.test",
- mqtt_broker_port=8883,
- mqtt_username="user",
- mqtt_password="pass",
- mqtt_use_tls=True,
- mqtt_tls_insecure=True,
- mqtt_topic_prefix="custom",
- mqtt_publish_messages=True,
- mqtt_publish_raw_packets=True,
- )
- )
-
- assert result.mqtt_broker_host == "broker.test"
- assert result.mqtt_broker_port == 8883
- assert result.mqtt_username == "user"
- assert result.mqtt_password == "pass"
- assert result.mqtt_use_tls is True
- assert result.mqtt_tls_insecure is True
- assert result.mqtt_topic_prefix == "custom"
- assert result.mqtt_publish_messages is True
- assert result.mqtt_publish_raw_packets is True
-
- # Verify persistence
- fresh = await AppSettingsRepository.get()
- assert fresh.mqtt_broker_host == "broker.test"
- assert fresh.mqtt_use_tls is True
-
- @pytest.mark.asyncio
- async def test_mqtt_defaults_on_fresh_db(self, test_db):
- """MQTT fields should have correct defaults on a fresh database."""
- settings = await AppSettingsRepository.get()
-
- assert settings.mqtt_broker_host == ""
- assert settings.mqtt_broker_port == 1883
- assert settings.mqtt_username == ""
- assert settings.mqtt_password == ""
- assert settings.mqtt_use_tls is False
- assert settings.mqtt_tls_insecure is False
- assert settings.mqtt_topic_prefix == "meshcore"
- assert settings.mqtt_publish_messages is False
- assert settings.mqtt_publish_raw_packets is False
-
- @pytest.mark.asyncio
- async def test_community_mqtt_fields_round_trip(self, test_db):
- """Community MQTT settings should be saved and retrieved correctly."""
- mock_community = type("MockCommunity", (), {"restart": AsyncMock()})()
- with patch("app.community_mqtt.community_publisher", mock_community):
- result = await update_settings(
- AppSettingsUpdate(
- community_mqtt_enabled=True,
- community_mqtt_iata="DEN",
- community_mqtt_broker_host="custom-broker.example.com",
- community_mqtt_broker_port=8883,
- community_mqtt_email="test@example.com",
- )
- )
-
- assert result.community_mqtt_enabled is True
- assert result.community_mqtt_iata == "DEN"
- assert result.community_mqtt_broker_host == "custom-broker.example.com"
- assert result.community_mqtt_broker_port == 8883
- assert result.community_mqtt_email == "test@example.com"
-
- # Verify persistence
- fresh = await AppSettingsRepository.get()
- assert fresh.community_mqtt_enabled is True
- assert fresh.community_mqtt_iata == "DEN"
- assert fresh.community_mqtt_broker_host == "custom-broker.example.com"
- assert fresh.community_mqtt_broker_port == 8883
- assert fresh.community_mqtt_email == "test@example.com"
-
- # Verify restart was called
- mock_community.restart.assert_called_once()
-
- @pytest.mark.asyncio
- async def test_community_mqtt_iata_validation_rejects_invalid(self, test_db):
- """Invalid IATA codes should be rejected."""
- with pytest.raises(HTTPException) as exc:
- await update_settings(AppSettingsUpdate(community_mqtt_iata="A"))
- assert exc.value.status_code == 400
-
- with pytest.raises(HTTPException) as exc:
- await update_settings(AppSettingsUpdate(community_mqtt_iata="ABCDE"))
- assert exc.value.status_code == 400
-
- with pytest.raises(HTTPException) as exc:
- await update_settings(AppSettingsUpdate(community_mqtt_iata="12"))
- assert exc.value.status_code == 400
-
- with pytest.raises(HTTPException) as exc:
- await update_settings(AppSettingsUpdate(community_mqtt_iata="ABCD"))
- assert exc.value.status_code == 400
-
- @pytest.mark.asyncio
- async def test_community_mqtt_enable_requires_iata(self, test_db):
- """Enabling community MQTT without a valid IATA code should be rejected."""
- with pytest.raises(HTTPException) as exc:
- await update_settings(AppSettingsUpdate(community_mqtt_enabled=True))
- assert exc.value.status_code == 400
- assert "IATA" in exc.value.detail
-
- @pytest.mark.asyncio
- async def test_community_mqtt_iata_uppercased(self, test_db):
- """IATA codes should be uppercased."""
- mock_community = type("MockCommunity", (), {"restart": AsyncMock()})()
- with patch("app.community_mqtt.community_publisher", mock_community):
- result = await update_settings(AppSettingsUpdate(community_mqtt_iata="den"))
- assert result.community_mqtt_iata == "DEN"
-
- @pytest.mark.asyncio
- async def test_community_mqtt_defaults_on_fresh_db(self, test_db):
- """Community MQTT fields should have correct defaults on a fresh database."""
- settings = await AppSettingsRepository.get()
- assert settings.community_mqtt_enabled is False
- assert settings.community_mqtt_iata == ""
- assert settings.community_mqtt_email == ""
-
@pytest.mark.asyncio
async def test_flood_scope_round_trip(self, test_db):
"""Flood scope should be saved and retrieved correctly."""
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index ab816c0a..7bd361f2 100644
--- a/tests/test_websocket.py
+++ b/tests/test_websocket.py
@@ -206,45 +206,42 @@ class TestWebSocketConnectionManagement:
class TestBroadcastEventFanout:
- """Test that broadcast_event dispatches to WS, private MQTT, and community MQTT."""
+ """Test that broadcast_event dispatches to WS and fanout manager."""
@pytest.mark.asyncio
- async def test_broadcast_event_dispatches_to_all_three_sinks(self):
- """broadcast_event creates a WS task, calls mqtt_broadcast, and
- calls community_mqtt_broadcast."""
+ async def test_broadcast_event_dispatches_to_ws_and_fanout(self):
+ """broadcast_event creates a WS task and dispatches to fanout manager."""
from app.websocket import broadcast_event
with (
patch("app.websocket.ws_manager") as mock_ws,
- patch("app.mqtt.mqtt_broadcast") as mock_mqtt,
- patch("app.community_mqtt.community_mqtt_broadcast") as mock_community,
+ patch("app.fanout.manager.fanout_manager") as mock_fm,
):
mock_ws.broadcast = AsyncMock()
+ mock_fm.broadcast_message = AsyncMock()
broadcast_event("message", {"id": 1, "text": "hello"})
- # Let the asyncio task (ws_manager.broadcast) run
+ # Let the asyncio tasks run
await asyncio.sleep(0)
mock_ws.broadcast.assert_called_once_with("message", {"id": 1, "text": "hello"})
- mock_mqtt.assert_called_once_with("message", {"id": 1, "text": "hello"})
- mock_community.assert_called_once_with("message", {"id": 1, "text": "hello"})
+ mock_fm.broadcast_message.assert_called_once_with({"id": 1, "text": "hello"})
@pytest.mark.asyncio
- async def test_broadcast_event_passes_event_type_to_mqtt_filters(self):
- """MQTT sinks receive the event_type so they can filter by message vs raw_packet."""
+ async def test_broadcast_event_raw_packet_dispatches_to_fanout(self):
+ """broadcast_event for raw_packet dispatches to fanout broadcast_raw."""
from app.websocket import broadcast_event
with (
patch("app.websocket.ws_manager") as mock_ws,
- patch("app.mqtt.mqtt_broadcast") as mock_mqtt,
- patch("app.community_mqtt.community_mqtt_broadcast") as mock_community,
+ patch("app.fanout.manager.fanout_manager") as mock_fm,
):
mock_ws.broadcast = AsyncMock()
+ mock_fm.broadcast_raw = AsyncMock()
broadcast_event("raw_packet", {"data": "ff00"})
await asyncio.sleep(0)
- # Both MQTT sinks receive the event type for filtering
- assert mock_mqtt.call_args.args[0] == "raw_packet"
- assert mock_community.call_args.args[0] == "raw_packet"
+ mock_ws.broadcast.assert_called_once()
+ mock_fm.broadcast_raw.assert_called_once_with({"data": "ff00"})
From 5ecb63fde95c4b43089e8b8c76880559560a437d Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 18:14:03 -0800
Subject: [PATCH 02/28] Move bots into Fanout & Forwarding
---
app/event_handlers.py | 18 -
app/fanout/bot.py | 120 +++++++
app/fanout/manager.py | 15 +-
app/migrations.py | 65 ++++
app/packet_processor.py | 40 +--
app/routers/fanout.py | 28 +-
app/routers/messages.py | 36 +-
app/routers/settings.py | 39 +-
frontend/AGENTS.md | 3 +-
frontend/src/components/SettingsModal.tsx | 23 +-
.../settings/SettingsBotSection.tsx | 335 ------------------
.../settings/SettingsFanoutSection.tsx | 170 ++++++++-
.../components/settings/settingsConstants.ts | 11 +-
frontend/src/test/settingsModal.test.tsx | 5 +-
frontend/src/types.ts | 1 -
tests/conftest.py | 3 +-
tests/e2e/helpers/api.ts | 52 ++-
tests/e2e/specs/bot.spec.ts | 44 +--
tests/test_api.py | 16 +-
tests/test_bot.py | 50 +--
tests/test_disable_bots.py | 50 ++-
tests/test_event_handlers.py | 55 +--
tests/test_fanout.py | 100 ++++++
tests/test_migrations.py | 52 +--
tests/test_packet_pipeline.py | 76 ----
tests/test_send_messages.py | 154 ++------
tests/test_settings_router.py | 18 +-
27 files changed, 671 insertions(+), 908 deletions(-)
create mode 100644 app/fanout/bot.py
delete mode 100644 frontend/src/components/settings/SettingsBotSection.tsx
diff --git a/app/event_handlers.py b/app/event_handlers.py
index 84dceb39..e0cd6e6d 100644
--- a/app/event_handlers.py
+++ b/app/event_handlers.py
@@ -1,4 +1,3 @@
-import asyncio
import logging
import time
from typing import TYPE_CHECKING
@@ -155,23 +154,6 @@ async def on_contact_message(event: "Event") -> None:
if contact:
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
- # Run bot if enabled
- from app.bot import run_bot_for_message
-
- asyncio.create_task(
- run_bot_for_message(
- sender_name=contact.name if contact else None,
- sender_key=sender_pubkey,
- message_text=payload.get("text", ""),
- is_dm=True,
- channel_key=None,
- channel_name=None,
- sender_timestamp=payload.get("sender_timestamp"),
- path=payload.get("path"),
- is_outgoing=False,
- )
- )
-
async def on_rx_log_data(event: "Event") -> None:
"""Store raw RF packet data and process via centralized packet processor.
diff --git a/app/fanout/bot.py b/app/fanout/bot.py
new file mode 100644
index 00000000..715b9adc
--- /dev/null
+++ b/app/fanout/bot.py
@@ -0,0 +1,120 @@
+"""Fanout module wrapping bot execution logic."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+from app.fanout.base import FanoutModule
+
+logger = logging.getLogger(__name__)
+
+
+class BotModule(FanoutModule):
+ """Wraps a single bot's code execution and response routing.
+
+ Each BotModule represents one bot configuration. It receives decoded
+ messages via ``on_message``, executes the bot's Python code in a
+ background task (after a 2-second settle delay), and sends any response
+ back through the radio.
+ """
+
+ def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
+ super().__init__(config_id, config)
+ self._name = name
+
+ async def on_message(self, data: dict) -> None:
+ """Kick off bot execution in a background task so we don't block dispatch."""
+ 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
+
+ code = self.config.get("code", "")
+ if not code or not code.strip():
+ return
+
+ msg_type = data.get("type", "")
+ is_dm = msg_type == "PRIV"
+
+ # Extract bot parameters from broadcast data
+ if is_dm:
+ conversation_key = data.get("conversation_key", "")
+ sender_key = conversation_key
+ is_outgoing = data.get("outgoing", False)
+ message_text = data.get("text", "")
+ channel_key = None
+ channel_name = None
+
+ # Look up sender name from contacts
+ from app.repository import ContactRepository
+
+ contact = await ContactRepository.get_by_key(conversation_key)
+ sender_name = contact.name if contact else None
+ else:
+ conversation_key = data.get("conversation_key", "")
+ sender_key = None
+ is_outgoing = False
+ sender_name = data.get("sender_name")
+ channel_key = conversation_key
+
+ # Look up channel name
+ from app.repository import ChannelRepository
+
+ channel = await ChannelRepository.get_by_key(conversation_key)
+ channel_name = channel.name if channel else None
+
+ # Strip "sender: " prefix from channel message text
+ text = data.get("text", "")
+ if sender_name and text.startswith(f"{sender_name}: "):
+ message_text = text[len(f"{sender_name}: ") :]
+ else:
+ message_text = text
+
+ sender_timestamp = data.get("sender_timestamp")
+ path_value = data.get("path")
+ # Message model serializes paths as list of dicts; extract first path string
+ if path_value is None:
+ paths = data.get("paths")
+ if paths and isinstance(paths, list) and len(paths) > 0:
+ path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
+
+ # Wait for message to settle (allows retransmissions to be deduped)
+ await asyncio.sleep(2)
+
+ # Execute bot code in thread pool with timeout
+ from app.bot import _bot_executor, _bot_semaphore
+
+ async with _bot_semaphore:
+ loop = asyncio.get_event_loop()
+ try:
+ response = await asyncio.wait_for(
+ loop.run_in_executor(
+ _bot_executor,
+ execute_bot_code,
+ code,
+ sender_name,
+ sender_key,
+ message_text,
+ is_dm,
+ channel_key,
+ channel_name,
+ sender_timestamp,
+ path_value,
+ is_outgoing,
+ ),
+ timeout=BOT_EXECUTION_TIMEOUT,
+ )
+ except asyncio.TimeoutError:
+ logger.warning("Bot '%s' execution timed out", self._name)
+ return
+ except Exception as e:
+ logger.warning("Bot '%s' execution error: %s", self._name, e)
+ return
+
+ if response:
+ await process_bot_response(response, is_dm, sender_key or "", channel_key)
+
+ @property
+ def status(self) -> str:
+ return "connected"
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
index 06a070f9..bb5c38f1 100644
--- a/app/fanout/manager.py
+++ b/app/fanout/manager.py
@@ -17,11 +17,13 @@ def _register_module_types() -> None:
"""Lazily populate the type registry to avoid circular imports."""
if _MODULE_TYPES:
return
+ from app.fanout.bot import BotModule
from app.fanout.mqtt_community import MqttCommunityModule
from app.fanout.mqtt_private import MqttPrivateModule
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
+ _MODULE_TYPES["bot"] = BotModule
def _scope_matches_message(scope: dict, data: dict) -> bool:
@@ -80,13 +82,24 @@ class FanoutManager:
config_blob = cfg["config"]
scope = cfg["scope"]
+ # Skip bot modules when bots are disabled server-wide
+ if config_type == "bot":
+ from app.config import settings as server_settings
+
+ if server_settings.disable_bots:
+ logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
+ return
+
cls = _MODULE_TYPES.get(config_type)
if cls is None:
logger.warning("Unknown fanout type %r for config %s, skipping", config_type, config_id)
return
try:
- module = cls(config_id, config_blob)
+ if config_type == "bot":
+ module = cls(config_id, config_blob, name=cfg.get("name", "Bot"))
+ else:
+ module = cls(config_id, config_blob)
await module.start()
self._modules[config_id] = (module, scope)
logger.info(
diff --git a/app/migrations.py b/app/migrations.py
index 65168db9..28805b12 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -289,6 +289,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 36)
applied += 1
+ # Migration 37: Migrate bots from app_settings to fanout_configs
+ if version < 37:
+ logger.info("Applying migration 37: migrate bots to fanout_configs")
+ await _migrate_037_bots_to_fanout(conn)
+ await set_version(conn, 37)
+ applied += 1
+
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2149,3 +2156,61 @@ async def _migrate_036_create_fanout_configs(conn: aiosqlite.Connection) -> None
logger.info("Migrated community MQTT settings to fanout_configs")
await conn.commit()
+
+
+async def _migrate_037_bots_to_fanout(conn: aiosqlite.Connection) -> None:
+ """Migrate bots from app_settings.bots JSON to fanout_configs rows."""
+ import json
+ import uuid
+
+ try:
+ cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
+ row = await cursor.fetchone()
+ except Exception:
+ row = None
+
+ if row is None:
+ await conn.commit()
+ return
+
+ bots_json = row["bots"] or "[]"
+ try:
+ bots = json.loads(bots_json)
+ except (json.JSONDecodeError, TypeError):
+ bots = []
+
+ if not bots:
+ await conn.commit()
+ return
+
+ import time
+
+ now = int(time.time())
+
+ # Use sort_order starting at 200 to place bots after MQTT configs (0-99)
+ for i, bot in enumerate(bots):
+ bot_name = bot.get("name") or f"Bot {i + 1}"
+ bot_enabled = bool(bot.get("enabled", False))
+ bot_code = bot.get("code", "")
+
+ config_blob = json.dumps({"code": bot_code})
+ scope = json.dumps({"messages": "all", "raw_packets": "none"})
+
+ await conn.execute(
+ """
+ INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
+ VALUES (?, 'bot', ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ str(uuid.uuid4()),
+ bot_name,
+ 1 if bot_enabled else 0,
+ config_blob,
+ scope,
+ 200 + i,
+ now,
+ ),
+ )
+ logger.info("Migrated bot '%s' to fanout_configs (enabled=%s)", bot_name, bot_enabled)
+
+ await conn.commit()
diff --git a/app/packet_processor.py b/app/packet_processor.py
index 1264345c..cbe49d18 100644
--- a/app/packet_processor.py
+++ b/app/packet_processor.py
@@ -195,7 +195,7 @@ async def create_message_from_decrypted(
# Use "is not None" to include empty string (direct/0-hop messages)
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
- # Broadcast new message to connected clients
+ # Broadcast new message to connected clients (and fanout modules when realtime)
broadcast_event(
"message",
Message(
@@ -212,24 +212,6 @@ async def create_message_from_decrypted(
realtime=trigger_bot,
)
- # Run bot if enabled (for incoming channel messages, not historical decryption)
- if trigger_bot:
- from app.bot import run_bot_for_message
-
- asyncio.create_task(
- run_bot_for_message(
- sender_name=sender,
- sender_key=None, # Channel messages don't have a sender public key
- message_text=message_text,
- is_dm=False,
- channel_key=channel_key_normalized,
- channel_name=channel_name,
- sender_timestamp=timestamp,
- path=path,
- is_outgoing=False,
- )
- )
-
return msg_id
@@ -318,7 +300,7 @@ async def create_dm_message_from_decrypted(
# Build paths array for broadcast
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
- # Broadcast new message to connected clients
+ # Broadcast new message to connected clients (and fanout modules when realtime)
broadcast_event(
"message",
Message(
@@ -339,24 +321,6 @@ async def create_dm_message_from_decrypted(
# Update contact's last_contacted timestamp (for sorting)
await ContactRepository.update_last_contacted(conversation_key, received)
- # Run bot if enabled (for all real-time DMs, including our own outgoing messages)
- if trigger_bot:
- from app.bot import run_bot_for_message
-
- asyncio.create_task(
- run_bot_for_message(
- sender_name=contact.name if contact else None,
- sender_key=their_public_key,
- message_text=decrypted.message,
- is_dm=True,
- channel_key=None,
- channel_name=None,
- sender_timestamp=decrypted.timestamp,
- path=path,
- is_outgoing=outgoing,
- )
- )
-
return msg_id
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
index 4aa5c6b0..3bf4bdcd 100644
--- a/app/routers/fanout.py
+++ b/app/routers/fanout.py
@@ -6,13 +6,13 @@ import re
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
+from app.config import settings as server_settings
from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fanout", tags=["fanout"])
-# Valid types in Phase 1
-_VALID_TYPES = {"mqtt_private", "mqtt_community"}
+_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot"}
_IATA_RE = re.compile(r"^[A-Z]{3}$")
@@ -51,11 +51,26 @@ def _validate_mqtt_community_config(config: dict) -> None:
)
+def _validate_bot_config(config: dict) -> None:
+ """Validate bot config blob (syntax-check the code)."""
+ code = config.get("code", "")
+ if not code or not code.strip():
+ raise HTTPException(status_code=400, detail="Bot code cannot be empty")
+ try:
+ compile(code, "", "exec")
+ except SyntaxError as e:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
+ ) from None
+
+
def _enforce_scope(config_type: str, scope: dict) -> dict:
"""Enforce type-specific scope constraints. Returns normalized scope."""
if config_type == "mqtt_community":
- # Community MQTT always: no messages, all raw packets
return {"messages": "none", "raw_packets": "all"}
+ if config_type == "bot":
+ return {"messages": "all", "raw_packets": "none"}
# For mqtt_private, validate scope values
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
@@ -81,6 +96,9 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
)
+ if body.type == "bot" and server_settings.disable_bots:
+ raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
+
# Only validate config when creating as enabled — disabled configs
# are drafts the user hasn't finished configuring yet.
if body.enabled:
@@ -88,6 +106,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
_validate_mqtt_private_config(body.config)
elif body.type == "mqtt_community":
_validate_mqtt_community_config(body.config)
+ elif body.type == "bot":
+ _validate_bot_config(body.config)
scope = _enforce_scope(body.type, body.scope)
@@ -134,6 +154,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
_validate_mqtt_private_config(config_to_validate)
elif existing["type"] == "mqtt_community":
_validate_mqtt_community_config(config_to_validate)
+ elif existing["type"] == "bot":
+ _validate_bot_config(config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:
diff --git a/app/routers/messages.py b/app/routers/messages.py
index 757b0ba6..e8e13548 100644
--- a/app/routers/messages.py
+++ b/app/routers/messages.py
@@ -1,4 +1,3 @@
-import asyncio
import logging
import time
@@ -176,25 +175,9 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
)
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
+ # Fanout modules (including bots) are triggered via broadcast_event's realtime dispatch.
broadcast_event("message", message.model_dump())
- # Trigger bots for outgoing DMs (runs in background, doesn't block response)
- from app.bot import run_bot_for_message
-
- asyncio.create_task(
- run_bot_for_message(
- sender_name=None,
- sender_key=db_contact.public_key.lower(),
- message_text=request.text,
- is_dm=True,
- channel_key=None,
- channel_name=None,
- sender_timestamp=now,
- path=None,
- is_outgoing=True,
- )
- )
-
return message
@@ -335,23 +318,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
sender_key=our_public_key,
)
- # Trigger bots for outgoing channel messages (runs in background, doesn't block response)
- from app.bot import run_bot_for_message
-
- asyncio.create_task(
- run_bot_for_message(
- sender_name=radio_name or None,
- sender_key=None,
- message_text=request.text,
- is_dm=False,
- channel_key=channel_key_upper,
- channel_name=db_channel.name,
- sender_timestamp=now,
- path=None,
- is_outgoing=True,
- )
- )
-
return message
diff --git a/app/routers/settings.py b/app/routers/settings.py
index 56cf6dee..7f220a35 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -2,38 +2,16 @@ import asyncio
import logging
from typing import Literal
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter
from pydantic import BaseModel, Field
-from app.config import settings as server_settings
-from app.models import AppSettings, BotConfig
+from app.models import AppSettings
from app.repository import AppSettingsRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
-def validate_bot_code(code: str, bot_name: str | None = None) -> None:
- """Validate bot code syntax. Raises HTTPException on error."""
- if not code or not code.strip():
- return # Empty code is valid (disables bot)
-
- try:
- compile(code, "", "exec")
- except SyntaxError as e:
- name_part = f"'{bot_name}' " if bot_name else ""
- raise HTTPException(
- status_code=400,
- detail=f"Bot {name_part}has syntax error at line {e.lineno}: {e.msg}",
- ) from None
-
-
-def validate_all_bots(bots: list[BotConfig]) -> None:
- """Validate all bots' code syntax. Raises HTTPException on first error."""
- for bot in bots:
- validate_bot_code(bot.code, bot.name)
-
-
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
default=None,
@@ -56,10 +34,6 @@ class AppSettingsUpdate(BaseModel):
ge=0,
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
)
- bots: list[BotConfig] | None = Field(
- default=None,
- description="List of bot configurations",
- )
flood_scope: str | None = Field(
default=None,
description="Outbound flood scope / region name (empty = disabled)",
@@ -140,15 +114,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating advert_interval to %d", interval)
kwargs["advert_interval"] = interval
- if update.bots is not None:
- if server_settings.disable_bots:
- raise HTTPException(
- status_code=403, detail="Bot system disabled by server configuration"
- )
- validate_all_bots(update.bots)
- logger.info("Updating bots (count=%d)", len(update.bots))
- kwargs["bots"] = update.bots
-
# Block lists
if update.blocked_keys is not None:
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 61bcc5e4..407e803e 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -84,9 +84,8 @@ frontend/src/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
-│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
+│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
-│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
│ │ └── ThemeSelector.tsx # Color theme picker
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 174b9071..3cbb8513 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -13,7 +13,6 @@ import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
-import { SettingsBotSection } from './settings/SettingsBotSection';
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
import { SettingsAboutSection } from './settings/SettingsAboutSection';
@@ -80,7 +79,6 @@ export function SettingsModal(props: SettingsModalProps) {
local: false,
fanout: false,
database: false,
- bot: false,
statistics: false,
about: false,
});
@@ -217,26 +215,15 @@ export function SettingsModal(props: SettingsModalProps) {
)}
- {shouldRenderSection('bot') && (
-
- {renderSectionHeader('bot')}
- {isSectionVisible('bot') && appSettings && (
-
- )}
-
- )}
-
{shouldRenderSection('fanout') && (
{renderSectionHeader('fanout')}
{isSectionVisible('fanout') && (
-
+
)}
)}
diff --git a/frontend/src/components/settings/SettingsBotSection.tsx b/frontend/src/components/settings/SettingsBotSection.tsx
deleted file mode 100644
index c69b2707..00000000
--- a/frontend/src/components/settings/SettingsBotSection.tsx
+++ /dev/null
@@ -1,335 +0,0 @@
-import { useState, useEffect, lazy, Suspense } from 'react';
-import { Label } from '../ui/label';
-import { Button } from '../ui/button';
-import { Separator } from '../ui/separator';
-import { toast } from '../ui/sonner';
-import type { AppSettings, AppSettingsUpdate, BotConfig, HealthStatus } from '../../types';
-
-const BotCodeEditor = lazy(() =>
- import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
-);
-
-const DEFAULT_BOT_CODE = `def bot(
- sender_name: str | None,
- sender_key: str | None,
- message_text: str,
- is_dm: bool,
- channel_key: str | None,
- channel_name: str | None,
- sender_timestamp: int | None,
- path: str | None,
- is_outgoing: bool = False,
-) -> str | list[str] | None:
- """
- Process messages and optionally return a reply.
-
- Args:
- sender_name: Display name of sender (may be None)
- sender_key: 64-char hex public key (None for channel msgs)
- message_text: The message content
- is_dm: True for direct messages, False for channel
- channel_key: 32-char hex key for channels, None for DMs
- channel_name: Channel name with hash (e.g. "#bot"), None for DMs
- sender_timestamp: Sender's timestamp (unix seconds, may be None)
- path: Hex-encoded routing path (may be None)
- is_outgoing: True if this is our own outgoing message
-
- Returns:
- None for no reply, a string for a single reply,
- or a list of strings to send multiple messages in order
- """
- # Don't reply to our own outgoing messages
- if is_outgoing:
- return None
-
- # Example: Only respond in #bot channel to "!pling" command
- if channel_name == "#bot" and "!pling" in message_text.lower():
- return "[BOT] Plong!"
- return None`;
-
-export function SettingsBotSection({
- appSettings,
- health,
- isMobileLayout,
- onSaveAppSettings,
- className,
-}: {
- appSettings: AppSettings;
- health: HealthStatus | null;
- isMobileLayout: boolean;
- onSaveAppSettings: (update: AppSettingsUpdate) => Promise;
- className?: string;
-}) {
- const [bots, setBots] = useState([]);
- const [expandedBotId, setExpandedBotId] = useState(null);
- const [editingNameId, setEditingNameId] = useState(null);
- const [editingNameValue, setEditingNameValue] = useState('');
-
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- setBots(appSettings.bots || []);
- }, [appSettings]);
-
- const handleSave = async () => {
- setBusy(true);
- setError(null);
-
- try {
- await onSaveAppSettings({ bots });
- toast.success('Bot settings saved');
- } catch (err) {
- console.error('Failed to save bot settings:', err);
- const errorMsg = err instanceof Error ? err.message : 'Failed to save';
- setError(errorMsg);
- toast.error(errorMsg);
- } finally {
- setBusy(false);
- }
- };
-
- const handleAddBot = () => {
- const newBot: BotConfig = {
- id: crypto.randomUUID(),
- name: `Bot ${bots.length + 1}`,
- enabled: false,
- code: DEFAULT_BOT_CODE,
- };
- setBots([...bots, newBot]);
- setExpandedBotId(newBot.id);
- };
-
- const handleDeleteBot = (botId: string) => {
- const bot = bots.find((b) => b.id === botId);
- if (bot && bot.code.trim() && bot.code !== DEFAULT_BOT_CODE) {
- if (!confirm(`Delete "${bot.name}"? This will remove all its code.`)) {
- return;
- }
- }
- setBots(bots.filter((b) => b.id !== botId));
- if (expandedBotId === botId) {
- setExpandedBotId(null);
- }
- };
-
- const handleToggleBotEnabled = (botId: string) => {
- setBots(bots.map((b) => (b.id === botId ? { ...b, enabled: !b.enabled } : b)));
- };
-
- const handleBotCodeChange = (botId: string, code: string) => {
- setBots(bots.map((b) => (b.id === botId ? { ...b, code } : b)));
- };
-
- const handleStartEditingName = (bot: BotConfig) => {
- setEditingNameId(bot.id);
- setEditingNameValue(bot.name);
- };
-
- const handleFinishEditingName = () => {
- if (editingNameId && editingNameValue.trim()) {
- setBots(
- bots.map((b) => (b.id === editingNameId ? { ...b, name: editingNameValue.trim() } : b))
- );
- }
- setEditingNameId(null);
- setEditingNameValue('');
- };
-
- const handleResetBotCode = (botId: string) => {
- setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b)));
- };
-
- if (health?.bots_disabled) {
- return (
-
-
Bot system disabled by server startup flag.
-
- );
- }
-
- return (
-
-
-
- Experimental: This is an alpha feature and introduces automated message
- sending to your radio; unexpected behavior may occur. Use with caution, and please report
- any bugs!
-
-
-
-
-
- Security Warning: This feature executes arbitrary Python code on the
- server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
-
-
-
-
-
- Don't wreck the mesh! Bots process ALL messages, including their
- own. Be careful of creating infinite loops!
-
-
-
-
- Bots
-
- + New Bot
-
-
-
- {bots.length === 0 ? (
-
-
No bots configured
-
- Create your first bot
-
-
- ) : (
-
- {bots.map((bot) => (
-
-
{
- if ((e.target as HTMLElement).closest('input, [data-bot-control]')) return;
- setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
- }}
- >
-
- {expandedBotId === bot.id ? '▼' : '▶'}
-
-
- {editingNameId === bot.id ? (
- setEditingNameValue(e.target.value)}
- onBlur={handleFinishEditingName}
- onKeyDown={(e) => {
- if (e.key === 'Enter') handleFinishEditingName();
- if (e.key === 'Escape') {
- setEditingNameId(null);
- setEditingNameValue('');
- }
- }}
- autoFocus
- aria-label="Bot name"
- className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]"
- onClick={(e) => e.stopPropagation()}
- />
- ) : (
- {
- e.stopPropagation();
- handleStartEditingName(bot);
- }}
- title="Click to rename"
- >
- {bot.name}
-
- )}
-
- e.stopPropagation()}
- >
- handleToggleBotEnabled(bot.id)}
- className="w-4 h-4 rounded border-input accent-primary"
- aria-label={`Enable ${bot.name}`}
- />
- Enabled
-
-
- {
- e.stopPropagation();
- handleDeleteBot(bot.id);
- }}
- title="Delete bot"
- aria-label={`Delete ${bot.name}`}
- >
- 🗑
-
-
-
- {expandedBotId === bot.id && (
-
-
-
- Define a bot() function that
- receives message data and optionally returns a reply.
-
-
handleResetBotCode(bot.id)}
- >
- Reset to Example
-
-
-
- Loading editor...
-
- }
- >
-
handleBotCodeChange(bot.id, code)}
- id={`bot-code-${bot.id}`}
- height={isMobileLayout ? '256px' : '384px'}
- />
-
-
- )}
-
- ))}
-
- )}
-
-
-
-
-
- Available: Standard Python libraries and any modules installed in the
- server environment.
-
-
- Limits: 10 second timeout per bot.
-
-
- Note: Bots respond to all messages, including your own. For channel
- messages, sender_key is None. Multiple enabled bots run
- serially, with a two-second delay between messages to prevent repeater collision.
-
-
-
- {error && (
-
- {error}
-
- )}
-
-
- {busy ? 'Saving...' : 'Save Bot Settings'}
-
-
- );
-}
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index e804ac60..7d350dd1 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -8,24 +8,68 @@ import { cn } from '@/lib/utils';
import { api } from '../../api';
import type { FanoutConfig, HealthStatus } from '../../types';
+const BotCodeEditor = lazy(() =>
+ import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
+);
+
const TYPE_LABELS: Record = {
mqtt_private: 'Private MQTT',
mqtt_community: 'Community MQTT',
+ bot: 'Bot',
};
const TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community', label: 'Community MQTT' },
+ { value: 'bot', label: 'Bot' },
];
+const DEFAULT_BOT_CODE = `def bot(
+ sender_name: str | None,
+ sender_key: str | None,
+ message_text: str,
+ is_dm: bool,
+ channel_key: str | None,
+ channel_name: str | None,
+ sender_timestamp: int | None,
+ path: str | None,
+ is_outgoing: bool = False,
+) -> str | list[str] | None:
+ """
+ Process messages and optionally return a reply.
+
+ Args:
+ sender_name: Display name of sender (may be None)
+ sender_key: 64-char hex public key (None for channel msgs)
+ message_text: The message content
+ is_dm: True for direct messages, False for channel
+ channel_key: 32-char hex key for channels, None for DMs
+ channel_name: Channel name with hash (e.g. "#bot"), None for DMs
+ sender_timestamp: Sender's timestamp (unix seconds, may be None)
+ path: Hex-encoded routing path (may be None)
+ is_outgoing: True if this is our own outgoing message
+
+ Returns:
+ None for no reply, a string for a single reply,
+ or a list of strings to send multiple messages in order
+ """
+ # Don't reply to our own outgoing messages
+ if is_outgoing:
+ return None
+
+ # Example: Only respond in #bot channel to "!pling" command
+ if channel_name == "#bot" and "!pling" in message_text.lower():
+ return "[BOT] Plong!"
+ return None`;
+
function getStatusColor(status: string | undefined) {
if (status === 'connected')
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
return 'bg-muted-foreground';
}
-function getStatusLabel(status: string | undefined) {
- if (status === 'connected') return 'Connected';
+function getStatusLabel(status: string | undefined, type?: string) {
+ if (status === 'connected') return type === 'bot' ? 'Active' : 'Connected';
if (status === 'disconnected') return 'Disconnected';
return 'Inactive';
}
@@ -47,6 +91,11 @@ function MqttPrivateConfigEditor({
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
+
+ Outgoing messages (DMs and group messages) will be reported to private MQTT brokers in
+ decrypted/plaintext form.
+
+
Broker Host
@@ -234,11 +283,88 @@ function MqttCommunityConfigEditor({
);
}
+function BotConfigEditor({
+ config,
+ onChange,
+}: {
+ config: Record
;
+ onChange: (config: Record) => void;
+}) {
+ const code = (config.code as string) || '';
+ return (
+
+
+
+ Experimental: This is an alpha feature and introduces automated message
+ sending to your radio; unexpected behavior may occur. Use with caution, and please report
+ any bugs!
+
+
+
+
+
+ Security Warning: This feature executes arbitrary Python code on the
+ server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
+
+
+
+
+
+ Don't wreck the mesh! Bots process ALL messages, including their
+ own. Be careful of creating infinite loops!
+
+
+
+
+
+ Define a bot() function that receives
+ message data and optionally returns a reply.
+
+
onChange({ ...config, code: DEFAULT_BOT_CODE })}
+ >
+ Reset to Example
+
+
+
+
+ Loading editor...
+
+ }
+ >
+ onChange({ ...config, code: c })} />
+
+
+
+
+ Available: Standard Python libraries and any modules installed in the
+ server environment.
+
+
+ Limits: 10 second timeout per bot.
+
+
+ Note: Bots respond to all messages, including your own. For channel
+ messages, sender_key is None. Multiple enabled bots run
+ serially, with a two-second delay between messages to prevent repeater collision.
+
+
+
+ );
+}
+
export function SettingsFanoutSection({
health,
+ onHealthRefresh,
className,
}: {
health: HealthStatus | null;
+ onHealthRefresh?: () => Promise
;
className?: string;
}) {
const [configs, setConfigs] = useState([]);
@@ -266,6 +392,7 @@ export function SettingsFanoutSection({
try {
await api.updateFanoutConfig(cfg.id, { enabled: !cfg.enabled });
await loadConfigs();
+ if (onHealthRefresh) await onHealthRefresh();
toast.success(cfg.enabled ? 'Integration disabled' : 'Integration enabled');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
@@ -332,10 +459,14 @@ export function SettingsFanoutSection({
iata: '',
email: '',
},
+ bot: {
+ code: DEFAULT_BOT_CODE,
+ },
};
const defaultScopes: Record> = {
mqtt_private: { messages: 'all', raw_packets: 'all' },
mqtt_community: { messages: 'none', raw_packets: 'all' },
+ bot: { messages: 'all', raw_packets: 'none' },
};
try {
@@ -398,6 +529,10 @@ export function SettingsFanoutSection({
)}
+ {editingConfig.type === 'bot' && (
+
+ )}
+
@@ -416,8 +551,7 @@ export function SettingsFanoutSection({
return (
- MQTT support is an experimental feature in open beta. All publishing uses QoS 0
- (at-most-once delivery).
+ Integrations are an experimental feature in open beta.
{configs.length === 0 ? (
@@ -453,11 +587,11 @@ export function SettingsFanoutSection({
- {cfg.enabled ? getStatusLabel(status) : 'Disabled'}
+ {cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
Select integration type:
- {TYPE_OPTIONS.map((opt) => (
- handleAddCreate(opt.value)}
- >
- {opt.label}
-
- ))}
+ {TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
+ (opt) => (
+ handleAddCreate(opt.value)}
+ >
+ {opt.label}
+
+ )
+ )}
setAddingType(null)}>
Cancel
diff --git a/frontend/src/components/settings/settingsConstants.ts b/frontend/src/components/settings/settingsConstants.ts
index 9bb88686..5a04f71c 100644
--- a/frontend/src/components/settings/settingsConstants.ts
+++ b/frontend/src/components/settings/settingsConstants.ts
@@ -1,17 +1,9 @@
-export type SettingsSection =
- | 'radio'
- | 'local'
- | 'database'
- | 'bot'
- | 'fanout'
- | 'statistics'
- | 'about';
+export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio',
'local',
'database',
- 'bot',
'fanout',
'statistics',
'about',
@@ -21,7 +13,6 @@ export const SETTINGS_SECTION_LABELS: Record = {
radio: '📻 Radio',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
- bot: '🤖 Bots',
fanout: '📤 Fanout & Forwarding',
statistics: '📊 Statistics',
about: 'About',
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index 9ed3cfbc..c6f679b4 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -236,10 +236,9 @@ describe('SettingsModal', () => {
it('renders selected section from external sidebar nav on desktop mode', async () => {
renderModal({
externalSidebarNav: true,
- desktopSection: 'bot',
+ desktopSection: 'fanout',
});
- expect(screen.getByText('No bots configured')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Local Configuration/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
});
@@ -278,7 +277,7 @@ describe('SettingsModal', () => {
{
export type Favorite = { type: string; id: string };
-export interface BotConfig {
- id: string;
- name: string;
- enabled: boolean;
- code: string;
-}
-
export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
@@ -197,7 +190,6 @@ export interface AppSettings {
sidebar_sort_order: string;
last_message_times: Record;
preferences_migrated: boolean;
- bots: BotConfig[];
advert_interval: number;
}
@@ -212,6 +204,50 @@ export function updateSettings(patch: Partial): Promise;
+ scope: Record;
+ sort_order: number;
+ created_at: number;
+}
+
+export function getFanoutConfigs(): Promise {
+ return fetchJson('/fanout');
+}
+
+export function createFanoutConfig(body: {
+ type: string;
+ name: string;
+ config: Record;
+ scope?: Record;
+ enabled?: boolean;
+}): Promise {
+ return fetchJson('/fanout', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+}
+
+export function updateFanoutConfig(
+ id: string,
+ patch: Partial<{ name: string; config: Record; scope: Record; enabled: boolean }>
+): Promise {
+ return fetchJson(`/fanout/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(patch),
+ });
+}
+
+export function deleteFanoutConfig(id: string): Promise<{ deleted: boolean }> {
+ return fetchJson(`/fanout/${id}`, { method: 'DELETE' });
+}
+
// --- Helpers ---
/**
diff --git a/tests/e2e/specs/bot.spec.ts b/tests/e2e/specs/bot.spec.ts
index 6d01158c..42b155c4 100644
--- a/tests/e2e/specs/bot.spec.ts
+++ b/tests/e2e/specs/bot.spec.ts
@@ -1,6 +1,12 @@
import { test, expect } from '@playwright/test';
-import { ensureFlightlessChannel, getSettings, updateSettings } from '../helpers/api';
-import type { BotConfig } from '../helpers/api';
+import {
+ ensureFlightlessChannel,
+ getFanoutConfigs,
+ createFanoutConfig,
+ deleteFanoutConfig,
+ updateFanoutConfig,
+} from '../helpers/api';
+import type { FanoutConfig } from '../helpers/api';
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
@@ -8,45 +14,43 @@ const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_
return None`;
test.describe('Bot functionality', () => {
- let originalBots: BotConfig[];
+ let createdBotId: string | null = null;
test.beforeAll(async () => {
await ensureFlightlessChannel();
- const settings = await getSettings();
- originalBots = settings.bots ?? [];
});
test.afterAll(async () => {
- // Restore original bot config
- try {
- await updateSettings({ bots: originalBots });
- } catch {
- console.warn('Failed to restore bot config');
+ // Clean up the bot we created
+ if (createdBotId) {
+ try {
+ await deleteFanoutConfig(createdBotId);
+ } catch {
+ console.warn('Failed to delete test bot');
+ }
}
});
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
page,
}) => {
- // --- Step 1: Create and enable bot via API ---
- // CodeMirror is difficult to drive via Playwright (contenteditable, lazy-loaded),
- // so we set the bot code via the REST API and verify it through the UI.
- const testBot: BotConfig = {
- id: crypto.randomUUID(),
+ // --- Step 1: Create and enable bot via fanout API ---
+ const bot = await createFanoutConfig({
+ type: 'bot',
name: 'E2E Test Bot',
+ config: { code: BOT_CODE },
enabled: true,
- code: BOT_CODE,
- };
- await updateSettings({ bots: [...originalBots, testBot] });
+ });
+ createdBotId = bot.id;
// --- Step 2: Verify bot appears in settings UI ---
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /🤖 Bots/ }).click();
+ await page.getByRole('button', { name: /Fanout/ }).click();
- // The bot name should be visible in the bot list
+ // The bot name should be visible in the integration list
await expect(page.getByText('E2E Test Bot')).toBeVisible();
// Exit settings page mode
diff --git a/tests/test_api.py b/tests/test_api.py
index 6f4a37e9..40ad309e 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -162,16 +162,10 @@ class TestMessagesEndpoint:
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
)
- def _capture_task(coro):
- coro.close()
- return MagicMock()
-
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_rm,
- patch("app.bot.run_bot_for_message", new=AsyncMock()),
- patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
- patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
+ patch("app.routers.messages.broadcast_event") as mock_broadcast,
):
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
@@ -206,17 +200,11 @@ class TestMessagesEndpoint:
mock_mc.commands.set_channel = AsyncMock(return_value=ok_result)
mock_mc.commands.send_chan_msg = AsyncMock(return_value=ok_result)
- def _capture_task(coro):
- coro.close()
- return MagicMock()
-
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_rm,
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
- patch("app.bot.run_bot_for_message", new=AsyncMock()),
- patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
- patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
+ patch("app.routers.messages.broadcast_event") as mock_broadcast,
):
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
diff --git a/tests/test_bot.py b/tests/test_bot.py
index d4d4328b..68aef4a4 100644
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -745,69 +745,49 @@ class TestMultipleBots:
class TestBotCodeValidation:
- """Test bot code syntax validation on save."""
+ """Test bot code syntax validation via fanout router."""
def test_valid_code_passes(self):
"""Valid Python code passes validation."""
- from app.routers.settings import validate_bot_code
+ from app.routers.fanout import _validate_bot_config
# Should not raise
- validate_bot_code("def bot(): return 'hello'")
+ _validate_bot_config({"code": "def bot(): return 'hello'"})
def test_syntax_error_raises(self):
"""Syntax error in code raises HTTPException."""
from fastapi import HTTPException
- from app.routers.settings import validate_bot_code
+ from app.routers.fanout import _validate_bot_config
with pytest.raises(HTTPException) as exc_info:
- validate_bot_code("def bot(:\n return 'broken'")
+ _validate_bot_config({"code": "def bot(:\n return 'broken'"})
assert exc_info.value.status_code == 400
assert "syntax error" in exc_info.value.detail.lower()
- def test_syntax_error_includes_bot_name(self):
- """Syntax error message includes bot name when provided."""
+ def test_empty_code_raises(self):
+ """Empty code raises HTTPException."""
from fastapi import HTTPException
- from app.routers.settings import validate_bot_code
+ from app.routers.fanout import _validate_bot_config
with pytest.raises(HTTPException) as exc_info:
- validate_bot_code("def bot(:\n return 'broken'", bot_name="My Test Bot")
+ _validate_bot_config({"code": ""})
assert exc_info.value.status_code == 400
- assert "My Test Bot" in exc_info.value.detail
+ assert "empty" in exc_info.value.detail.lower()
- def test_empty_code_passes(self):
- """Empty code passes validation (disables bot)."""
- from app.routers.settings import validate_bot_code
-
- # Should not raise
- validate_bot_code("")
- validate_bot_code(" ")
-
- def test_validate_all_bots(self):
- """validate_all_bots validates all bots' code."""
+ def test_missing_code_raises(self):
+ """Missing code key raises HTTPException."""
from fastapi import HTTPException
- from app.routers.settings import validate_all_bots
+ from app.routers.fanout import _validate_bot_config
- # Valid bots should pass
- valid_bots = [
- BotConfig(id="1", name="Bot 1", enabled=True, code="def bot(): return 'hi'"),
- BotConfig(id="2", name="Bot 2", enabled=False, code="def bot(): return 'hello'"),
- ]
- validate_all_bots(valid_bots) # Should not raise
-
- # Invalid code should raise with bot name
- invalid_bots = [
- BotConfig(id="1", name="Good Bot", enabled=True, code="def bot(): return 'hi'"),
- BotConfig(id="2", name="Bad Bot", enabled=True, code="def bot(:"),
- ]
with pytest.raises(HTTPException) as exc_info:
- validate_all_bots(invalid_bots)
+ _validate_bot_config({})
- assert "Bad Bot" in exc_info.value.detail
+ assert exc_info.value.status_code == 400
class TestBotMessageRateLimiting:
diff --git a/tests/test_disable_bots.py b/tests/test_disable_bots.py
index eb179340..8638eb30 100644
--- a/tests/test_disable_bots.py
+++ b/tests/test_disable_bots.py
@@ -2,7 +2,7 @@
Verifies that when disable_bots=True:
- run_bot_for_message() exits immediately without any work
-- PATCH /api/settings with bots returns 403
+- POST /api/fanout with type=bot returns 403
- Health endpoint includes bots_disabled=True
"""
@@ -14,8 +14,8 @@ 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
-from app.routers.settings import AppSettingsUpdate, update_settings
class TestDisableBotsConfig:
@@ -78,19 +78,20 @@ class TestDisableBotsBotExecution:
mock_exec.assert_called_once()
-class TestDisableBotsSettingsEndpoint:
- """Test that bot settings updates are rejected when bots are disabled."""
+class TestDisableBotsFanoutEndpoint:
+ """Test that bot creation via fanout router is rejected when bots are disabled."""
@pytest.mark.asyncio
- async def test_bot_update_returns_403_when_disabled(self, test_db):
- """PATCH /api/settings with bots field returns 403."""
- with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
+ async def test_bot_create_returns_403_when_disabled(self, test_db):
+ """POST /api/fanout with type=bot returns 403."""
+ with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
with pytest.raises(HTTPException) as exc_info:
- await update_settings(
- AppSettingsUpdate(
- bots=[
- BotConfig(id="1", name="Bot", enabled=True, code="def bot(**k): pass")
- ]
+ await create_fanout_config(
+ FanoutConfigCreate(
+ type="bot",
+ name="Test Bot",
+ config={"code": "def bot(**k): pass"},
+ enabled=False,
)
)
@@ -98,22 +99,19 @@ class TestDisableBotsSettingsEndpoint:
assert "disabled" in exc_info.value.detail.lower()
@pytest.mark.asyncio
- async def test_non_bot_update_allowed_when_disabled(self, test_db):
- """Other settings can still be updated when bots are disabled."""
- with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
- result = await update_settings(AppSettingsUpdate(max_radio_contacts=50))
- assert result.max_radio_contacts == 50
-
- @pytest.mark.asyncio
- async def test_bot_update_allowed_when_not_disabled(self, test_db):
- """Bot updates work normally when disable_bots is False."""
- with patch("app.routers.settings.server_settings", MagicMock(disable_bots=False)):
- result = await update_settings(
- AppSettingsUpdate(
- bots=[BotConfig(id="1", name="Bot", enabled=False, code="def bot(**k): pass")]
+ async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
+ """Non-bot fanout configs can still be created when bots are disabled."""
+ with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
+ # Create as disabled so fanout_manager.reload_config is not called
+ result = await create_fanout_config(
+ FanoutConfigCreate(
+ type="mqtt_private",
+ name="Test MQTT",
+ config={"broker_host": "localhost", "broker_port": 1883},
+ enabled=False,
)
)
- assert len(result.bots) == 1
+ assert result["type"] == "mqtt_private"
class TestDisableBotsHealthEndpoint:
diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py
index 891e20b9..3d5e88a5 100644
--- a/tests/test_event_handlers.py
+++ b/tests/test_event_handlers.py
@@ -5,7 +5,7 @@ delivery confirmation, contact message handling, and event registration.
"""
import time
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import MagicMock, patch
import pytest
@@ -217,43 +217,12 @@ class TestContactMessageCLIFiltering:
messages = await MessageRepository.get_all()
assert len(messages) == 0
- @pytest.mark.asyncio
- async def test_normal_message_schedules_bot_in_background(self, test_db):
- """Normal messages should schedule bot execution without blocking."""
- from app.event_handlers import on_contact_message
-
- def _capture_task(coro):
- coro.close()
- return MagicMock()
-
- with (
- patch("app.event_handlers.broadcast_event"),
- patch("app.event_handlers.asyncio.create_task", side_effect=_capture_task) as mock_task,
- patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
- ):
-
- class MockEvent:
- payload = {
- "pubkey_prefix": "abc123def456",
- "text": "Hello, bot",
- "txt_type": 0,
- "sender_timestamp": 1700000000,
- }
-
- await on_contact_message(MockEvent())
-
- mock_task.assert_called_once()
- mock_bot.assert_called_once()
-
@pytest.mark.asyncio
async def test_normal_message_still_processed(self, test_db):
"""Normal messages (txt_type=0) are still processed normally."""
from app.event_handlers import on_contact_message
- with (
- patch("app.event_handlers.broadcast_event") as mock_broadcast,
- patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
- ):
+ with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
@@ -278,10 +247,7 @@ class TestContactMessageCLIFiltering:
"""Broadcast payload should have acked as integer 0, not boolean False."""
from app.event_handlers import on_contact_message
- with (
- patch("app.event_handlers.broadcast_event") as mock_broadcast,
- patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
- ):
+ with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
@@ -326,10 +292,7 @@ class TestContactMessageCLIFiltering:
"sender_name",
}
- with (
- patch("app.event_handlers.broadcast_event") as mock_broadcast,
- patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
- ):
+ with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
@@ -380,10 +343,7 @@ class TestContactMessageCLIFiltering:
"""Messages without txt_type field are treated as normal (not filtered)."""
from app.event_handlers import on_contact_message
- with (
- patch("app.event_handlers.broadcast_event"),
- patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
- ):
+ with patch("app.event_handlers.broadcast_event"):
class MockEvent:
payload = {
@@ -422,10 +382,7 @@ class TestContactMessageCLIFiltering:
}
)
- with (
- patch("app.event_handlers.broadcast_event") as mock_broadcast,
- patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
- ):
+ with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
diff --git a/tests/test_fanout.py b/tests/test_fanout.py
index e9316e0c..215b70d8 100644
--- a/tests/test_fanout.py
+++ b/tests/test_fanout.py
@@ -526,3 +526,103 @@ class TestMigration036:
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()
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 112efd25..0097f0a0 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
- assert applied == 36 # All migrations run
- assert await get_version(conn) == 36
+ assert applied == 37 # All migrations run
+ assert await get_version(conn) == 37
# 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 == 36 # All migrations run
+ assert applied1 == 37 # All migrations run
assert applied2 == 0 # No migrations on second run
- assert await get_version(conn) == 36
+ assert await get_version(conn) == 37
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 == 36
- assert await get_version(conn) == 36
+ assert applied == 37
+ assert await get_version(conn) == 37
finally:
await conn.close()
@@ -374,10 +374,10 @@ class TestMigration013:
)
await conn.commit()
- # Run migration 13 (plus 14-36 which also run)
+ # Run migration 13 (plus 14-37 which also run)
applied = await run_migrations(conn)
- assert applied == 24
- assert await get_version(conn) == 36
+ assert applied == 25
+ assert await get_version(conn) == 37
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
@@ -497,7 +497,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
- assert await get_version(conn) == 36
+ assert await get_version(conn) == 37
# Verify autoindex is gone
cursor = await conn.execute(
@@ -575,8 +575,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 19 # Migrations 18-36 run (18+19 skip internally)
- assert await get_version(conn) == 36
+ assert applied == 20 # Migrations 18-37 run (18+19 skip internally)
+ assert await get_version(conn) == 37
finally:
await conn.close()
@@ -648,7 +648,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
- assert await get_version(conn) == 36
+ assert await get_version(conn) == 37
# Verify autoindex is gone
cursor = await conn.execute(
@@ -714,8 +714,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
- assert applied == 17 # Migrations 20-36
- assert await get_version(conn) == 36
+ assert applied == 18 # Migrations 20-37
+ assert await get_version(conn) == 37
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -745,7 +745,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
- assert applied == 16 # Migrations 21-36 still run
+ assert applied == 17 # Migrations 21-37 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -803,8 +803,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 9
- assert await get_version(conn) == 36
+ assert applied == 10
+ assert await get_version(conn) == 37
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -873,8 +873,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 9 # Version still bumped
- assert await get_version(conn) == 36
+ assert applied == 10 # Version still bumped
+ assert await get_version(conn) == 37
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -923,8 +923,8 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 5
- assert await get_version(conn) == 36
+ assert applied == 6
+ assert await get_version(conn) == 37
# Verify all columns exist with correct defaults
cursor = await conn.execute(
@@ -996,8 +996,8 @@ class TestMigration034:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 3
- assert await get_version(conn) == 36
+ assert applied == 4
+ assert await get_version(conn) == 37
# Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1039,8 +1039,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 4
- assert await get_version(conn) == 36
+ assert applied == 5
+ assert await get_version(conn) == 37
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
diff --git a/tests/test_packet_pipeline.py b/tests/test_packet_pipeline.py
index 75a6e774..af1a375d 100644
--- a/tests/test_packet_pipeline.py
+++ b/tests/test_packet_pipeline.py
@@ -509,40 +509,6 @@ class TestAckPipeline:
class TestCreateMessageFromDecrypted:
"""Test the shared message creation function used by both real-time and historical decryption."""
- @pytest.mark.asyncio
- async def test_schedules_bot_in_background(self, test_db, captured_broadcasts):
- """Bot execution is scheduled and does not block channel message persistence."""
- from app.packet_processor import create_message_from_decrypted
-
- packet_id, _ = await RawPacketRepository.create(b"test_packet_bot_channel", 1700000000)
- broadcasts, mock_broadcast = captured_broadcasts
-
- def _capture_task(coro):
- coro.close()
- return MagicMock()
-
- with (
- patch("app.packet_processor.broadcast_event", mock_broadcast),
- patch(
- "app.packet_processor.asyncio.create_task", side_effect=_capture_task
- ) as mock_task,
- patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
- ):
- msg_id = await create_message_from_decrypted(
- packet_id=packet_id,
- channel_key="ABC123DEF456",
- sender="BotTrigger",
- message_text="Hello from channel",
- timestamp=1700000000,
- received_at=1700000001,
- trigger_bot=True,
- )
-
- assert msg_id is not None
- mock_task.assert_called_once()
- mock_bot.assert_called_once()
- assert mock_bot.await_count == 0
-
@pytest.mark.asyncio
async def test_creates_message_and_broadcasts(self, test_db, captured_broadcasts):
"""create_message_from_decrypted creates message and broadcasts correctly."""
@@ -760,48 +726,6 @@ class TestCreateDMMessageFromDecrypted:
FACE12_PUB = "FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46"
A1B2C3_PUB = "a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
- @pytest.mark.asyncio
- async def test_schedules_bot_in_background(self, test_db, captured_broadcasts):
- """Bot execution is scheduled and does not block DM persistence."""
- from app.decoder import DecryptedDirectMessage
- from app.packet_processor import create_dm_message_from_decrypted
-
- packet_id, _ = await RawPacketRepository.create(b"test_packet_bot_dm", 1700000000)
- decrypted = DecryptedDirectMessage(
- timestamp=1700000000,
- flags=0,
- message="Hello from DM",
- dest_hash="fa",
- src_hash="a1",
- )
- broadcasts, mock_broadcast = captured_broadcasts
-
- def _capture_task(coro):
- coro.close()
- return MagicMock()
-
- with (
- patch("app.packet_processor.broadcast_event", mock_broadcast),
- patch(
- "app.packet_processor.asyncio.create_task", side_effect=_capture_task
- ) as mock_task,
- patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
- ):
- msg_id = await create_dm_message_from_decrypted(
- packet_id=packet_id,
- decrypted=decrypted,
- their_public_key=self.A1B2C3_PUB,
- our_public_key=self.FACE12_PUB,
- received_at=1700000001,
- outgoing=False,
- trigger_bot=True,
- )
-
- assert msg_id is not None
- mock_task.assert_called_once()
- mock_bot.assert_called_once()
- assert mock_bot.await_count == 0
-
@pytest.mark.asyncio
async def test_creates_dm_message_and_broadcasts(self, test_db, captured_broadcasts):
"""create_dm_message_from_decrypted creates message and broadcasts correctly."""
diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py
index dabcf7c2..d789a134 100644
--- a/tests/test_send_messages.py
+++ b/tests/test_send_messages.py
@@ -1,4 +1,4 @@
-"""Tests for bot triggering on outgoing messages sent via the messages router."""
+"""Tests for outgoing message sending via the messages router."""
import asyncio
import time
@@ -76,77 +76,36 @@ async def _insert_contact(public_key, name="Alice"):
)
-class TestOutgoingDMBotTrigger:
- """Test that sending a DM triggers bots with is_outgoing=True."""
+class TestOutgoingDMBroadcast:
+ """Test that outgoing DMs are broadcast via broadcast_event for fanout dispatch."""
@pytest.mark.asyncio
- async def test_send_dm_triggers_bot(self, test_db):
- """Sending a DM creates a background task to run bots."""
+ async def test_send_dm_broadcasts_outgoing(self, test_db):
+ """Sending a DM broadcasts the message with outgoing=True for fanout dispatch."""
mc = _make_mc()
pub_key = "ab" * 32
await _insert_contact(pub_key, "Alice")
+ broadcasts = []
+
+ def capture_broadcast(event_type, data):
+ broadcasts.append({"type": event_type, "data": data})
+
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
- patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
+ patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
):
request = SendDirectMessageRequest(destination=pub_key, text="!lasttime Alice")
await send_direct_message(request)
- # Let the background task run
- await asyncio.sleep(0)
-
- mock_bot.assert_called_once()
- call_kwargs = mock_bot.call_args[1]
- assert call_kwargs["message_text"] == "!lasttime Alice"
- assert call_kwargs["is_dm"] is True
- assert call_kwargs["is_outgoing"] is True
- assert call_kwargs["sender_key"] == pub_key
- assert call_kwargs["channel_key"] is None
-
- @pytest.mark.asyncio
- async def test_send_dm_bot_does_not_block_response(self, test_db):
- """Bot trigger runs in background and doesn't delay the message response."""
- mc = _make_mc()
- pub_key = "ab" * 32
- await _insert_contact(pub_key, "Alice")
-
- # Bot that would take a long time
- async def _slow(**kw):
- await asyncio.sleep(10)
-
- slow_bot = AsyncMock(side_effect=_slow)
-
- with (
- patch("app.routers.messages.require_connected", return_value=mc),
- patch.object(radio_manager, "_meshcore", mc),
- patch("app.bot.run_bot_for_message", new=slow_bot),
- ):
- request = SendDirectMessageRequest(destination=pub_key, text="Hello")
- # This should return immediately, not wait 10 seconds
- message = await send_direct_message(request)
- assert message.text == "Hello"
- assert message.outgoing is True
-
- @pytest.mark.asyncio
- async def test_send_dm_passes_no_sender_name(self, test_db):
- """Outgoing DMs pass sender_name=None (we are the sender)."""
- mc = _make_mc()
- pub_key = "cd" * 32
- await _insert_contact(pub_key, "Bob")
-
- with (
- patch("app.routers.messages.require_connected", return_value=mc),
- patch.object(radio_manager, "_meshcore", mc),
- patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
- ):
- request = SendDirectMessageRequest(destination=pub_key, text="test")
- await send_direct_message(request)
- await asyncio.sleep(0)
-
- call_kwargs = mock_bot.call_args[1]
- assert call_kwargs["sender_name"] is None
+ msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
+ assert len(msg_broadcasts) == 1
+ data = msg_broadcasts[0]["data"]
+ assert data["text"] == "!lasttime Alice"
+ assert data["outgoing"] is True
+ assert data["type"] == "PRIV"
+ assert data["conversation_key"] == pub_key
@pytest.mark.asyncio
async def test_send_dm_ambiguous_prefix_returns_409(self, test_db):
@@ -167,77 +126,37 @@ class TestOutgoingDMBotTrigger:
assert "ambiguous" in exc_info.value.detail.lower()
-class TestOutgoingChannelBotTrigger:
- """Test that sending a channel message triggers bots with is_outgoing=True."""
+class TestOutgoingChannelBroadcast:
+ """Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""
@pytest.mark.asyncio
- async def test_send_channel_msg_triggers_bot(self, test_db):
- """Sending a channel message creates a background task to run bots."""
+ async def test_send_channel_msg_broadcasts_outgoing(self, test_db):
+ """Sending a channel message broadcasts with outgoing=True for fanout dispatch."""
mc = _make_mc(name="MyNode")
chan_key = "aa" * 16
await ChannelRepository.upsert(key=chan_key, name="#general")
+ broadcasts = []
+
+ def capture_broadcast(event_type, data):
+ broadcasts.append({"type": event_type, "data": data})
+
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
- patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
+ patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="!lasttime5 someone")
await send_channel_message(request)
- await asyncio.sleep(0)
- mock_bot.assert_called_once()
- call_kwargs = mock_bot.call_args[1]
- assert call_kwargs["message_text"] == "!lasttime5 someone"
- assert call_kwargs["is_dm"] is False
- assert call_kwargs["is_outgoing"] is True
- assert call_kwargs["channel_key"] == chan_key.upper()
- assert call_kwargs["channel_name"] == "#general"
- assert call_kwargs["sender_name"] == "MyNode"
- assert call_kwargs["sender_key"] is None
-
- @pytest.mark.asyncio
- async def test_send_channel_msg_no_radio_name(self, test_db):
- """When radio has no name, sender_name is None."""
- mc = _make_mc(name="")
- chan_key = "bb" * 16
- await ChannelRepository.upsert(key=chan_key, name="#test")
-
- with (
- patch("app.routers.messages.require_connected", return_value=mc),
- patch.object(radio_manager, "_meshcore", mc),
- patch("app.decoder.calculate_channel_hash", return_value="abcd"),
- patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
- ):
- request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
- await send_channel_message(request)
- await asyncio.sleep(0)
-
- call_kwargs = mock_bot.call_args[1]
- assert call_kwargs["sender_name"] is None
-
- @pytest.mark.asyncio
- async def test_send_channel_msg_bot_does_not_block_response(self, test_db):
- """Bot trigger runs in background and doesn't delay the message response."""
- mc = _make_mc(name="MyNode")
- chan_key = "cc" * 16
- await ChannelRepository.upsert(key=chan_key, name="#slow")
-
- async def _slow(**kw):
- await asyncio.sleep(10)
-
- slow_bot = AsyncMock(side_effect=_slow)
-
- with (
- patch("app.routers.messages.require_connected", return_value=mc),
- patch.object(radio_manager, "_meshcore", mc),
- patch("app.decoder.calculate_channel_hash", return_value="abcd"),
- patch("app.bot.run_bot_for_message", new=slow_bot),
- ):
- request = SendChannelMessageRequest(channel_key=chan_key, text="test")
- message = await send_channel_message(request)
- assert message.outgoing is True
+ msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
+ assert len(msg_broadcasts) == 1
+ data = msg_broadcasts[0]["data"]
+ assert data["outgoing"] is True
+ assert data["type"] == "CHAN"
+ assert data["conversation_key"] == chan_key.upper()
+ assert data["sender_name"] == "MyNode"
@pytest.mark.asyncio
async def test_send_channel_msg_response_includes_current_ack_count(self, test_db):
@@ -250,7 +169,7 @@ class TestOutgoingChannelBotTrigger:
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
- patch("app.bot.run_bot_for_message", new=AsyncMock()),
+ patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="acked now")
message = await send_channel_message(request)
@@ -277,7 +196,6 @@ class TestOutgoingChannelBotTrigger:
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
- patch("app.bot.run_bot_for_message", new=AsyncMock()),
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py
index bb7d542d..e2b39cf0 100644
--- a/tests/test_settings_router.py
+++ b/tests/test_settings_router.py
@@ -3,9 +3,8 @@
from unittest.mock import AsyncMock, patch
import pytest
-from fastapi import HTTPException
-from app.models import AppSettings, BotConfig
+from app.models import AppSettings
from app.repository import AppSettingsRepository
from app.routers.settings import (
AppSettingsUpdate,
@@ -53,21 +52,6 @@ class TestUpdateSettings:
assert isinstance(result, AppSettings)
assert result.max_radio_contacts == 200 # default
- @pytest.mark.asyncio
- async def test_invalid_bot_syntax_returns_400(self):
- bad_bot = BotConfig(
- id="bot-1",
- name="BadBot",
- enabled=True,
- code="def bot(:\n return 'x'\n",
- )
-
- with pytest.raises(HTTPException) as exc:
- await update_settings(AppSettingsUpdate(bots=[bad_bot]))
-
- assert exc.value.status_code == 400
- assert "syntax error" in exc.value.detail.lower()
-
@pytest.mark.asyncio
async def test_flood_scope_round_trip(self, test_db):
"""Flood scope should be saved and retrieved correctly."""
From e3e4e0b8397e92e976c3cae31df49b707b77ef71 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 19:10:29 -0800
Subject: [PATCH 03/28] Add webhooks & reformat a bit
---
app/fanout/manager.py | 38 +-
app/fanout/webhook.py | 79 ++++
app/routers/fanout.py | 26 +-
.../settings/SettingsFanoutSection.tsx | 445 ++++++++++++++++--
.../components/settings/settingsConstants.ts | 2 +-
tests/test_fanout.py | 123 +++++
tests/test_fanout_integration.py | 434 +++++++++++++++++
7 files changed, 1086 insertions(+), 61 deletions(-)
create mode 100644 app/fanout/webhook.py
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
index bb5c38f1..4eb7d734 100644
--- a/app/fanout/manager.py
+++ b/app/fanout/manager.py
@@ -20,10 +20,32 @@ def _register_module_types() -> None:
from app.fanout.bot import BotModule
from app.fanout.mqtt_community import MqttCommunityModule
from app.fanout.mqtt_private import MqttPrivateModule
+ from app.fanout.webhook import WebhookModule
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
_MODULE_TYPES["bot"] = BotModule
+ _MODULE_TYPES["webhook"] = WebhookModule
+
+
+def _matches_filter(filter_value: Any, key: str) -> bool:
+ """Check a single filter value (channels or contacts) against a key.
+
+ Supported shapes:
+ "all" -> True
+ "none" -> False
+ ["key1", "key2"] -> key in list (only listed)
+ {"except": ["key1", "key2"]} -> key not in list (all except listed)
+ """
+ if filter_value == "all":
+ return True
+ if filter_value == "none":
+ return False
+ if isinstance(filter_value, list):
+ return key in filter_value
+ if isinstance(filter_value, dict) and "except" in filter_value:
+ return key not in filter_value["except"]
+ return False
def _scope_matches_message(scope: dict, data: dict) -> bool:
@@ -37,21 +59,9 @@ def _scope_matches_message(scope: dict, data: dict) -> bool:
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "")
if msg_type == "CHAN":
- channels = messages.get("channels", "none")
- if channels == "all":
- return True
- if channels == "none":
- return False
- if isinstance(channels, list):
- return conversation_key in channels
+ return _matches_filter(messages.get("channels", "none"), conversation_key)
elif msg_type == "PRIV":
- contacts = messages.get("contacts", "none")
- if contacts == "all":
- return True
- if contacts == "none":
- return False
- if isinstance(contacts, list):
- return conversation_key in contacts
+ return _matches_filter(messages.get("contacts", "none"), conversation_key)
return False
diff --git a/app/fanout/webhook.py b/app/fanout/webhook.py
new file mode 100644
index 00000000..4f9a798e
--- /dev/null
+++ b/app/fanout/webhook.py
@@ -0,0 +1,79 @@
+"""Fanout module for webhook (HTTP POST) delivery."""
+
+from __future__ import annotations
+
+import logging
+
+import httpx
+
+from app.fanout.base import FanoutModule
+
+logger = logging.getLogger(__name__)
+
+
+class WebhookModule(FanoutModule):
+ """Delivers message data to an HTTP endpoint via POST (or configurable method)."""
+
+ def __init__(self, config_id: str, config: dict) -> None:
+ super().__init__(config_id, config)
+ self._client: httpx.AsyncClient | None = None
+ self._last_error: str | None = None
+
+ async def start(self) -> None:
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
+ self._last_error = None
+
+ async def stop(self) -> None:
+ if self._client:
+ await self._client.aclose()
+ self._client = None
+
+ async def on_message(self, data: dict) -> None:
+ await self._send(data, event_type="message")
+
+ async def on_raw(self, data: dict) -> None:
+ await self._send(data, event_type="raw_packet")
+
+ async def _send(self, data: dict, *, event_type: str) -> None:
+ if not self._client:
+ return
+
+ url = self.config.get("url", "")
+ if not url:
+ return
+
+ method = self.config.get("method", "POST").upper()
+ extra_headers = self.config.get("headers", {})
+ secret = self.config.get("secret", "")
+
+ headers = {
+ "Content-Type": "application/json",
+ "X-Webhook-Event": event_type,
+ **extra_headers,
+ }
+ if secret:
+ headers["X-Webhook-Secret"] = secret
+
+ try:
+ resp = await self._client.request(method, url, json=data, headers=headers)
+ resp.raise_for_status()
+ self._last_error = None
+ except httpx.HTTPStatusError as exc:
+ self._last_error = f"HTTP {exc.response.status_code}"
+ logger.warning(
+ "Webhook %s returned %s for %s",
+ self.config_id,
+ exc.response.status_code,
+ url,
+ )
+ except httpx.RequestError as exc:
+ self._last_error = str(exc)
+ logger.warning("Webhook %s request error: %s", self.config_id, exc)
+
+ @property
+ def status(self) -> str:
+ if not self.config.get("url"):
+ return "disconnected"
+ if self._last_error:
+ return "error"
+ return "connected"
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
index 3bf4bdcd..f4bd01c6 100644
--- a/app/routers/fanout.py
+++ b/app/routers/fanout.py
@@ -12,7 +12,7 @@ from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fanout", tags=["fanout"])
-_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot"}
+_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook"}
_IATA_RE = re.compile(r"^[A-Z]{3}$")
@@ -65,12 +65,32 @@ def _validate_bot_config(config: dict) -> None:
) from None
+def _validate_webhook_config(config: dict) -> None:
+ """Validate webhook config blob."""
+ url = config.get("url", "")
+ if not url:
+ raise HTTPException(status_code=400, detail="url is required for webhook")
+ if not url.startswith(("http://", "https://")):
+ raise HTTPException(status_code=400, detail="url must start with http:// or https://")
+ method = config.get("method", "POST").upper()
+ if method not in ("POST", "PUT", "PATCH"):
+ raise HTTPException(status_code=400, detail="method must be POST, PUT, or PATCH")
+ headers = config.get("headers", {})
+ if not isinstance(headers, dict):
+ raise HTTPException(status_code=400, detail="headers must be a JSON object")
+
+
def _enforce_scope(config_type: str, scope: dict) -> dict:
"""Enforce type-specific scope constraints. Returns normalized scope."""
if config_type == "mqtt_community":
return {"messages": "none", "raw_packets": "all"}
if config_type == "bot":
return {"messages": "all", "raw_packets": "none"}
+ if config_type == "webhook":
+ messages = scope.get("messages", "all")
+ if messages not in ("all", "none") and not isinstance(messages, dict):
+ messages = "all"
+ return {"messages": messages, "raw_packets": "none"}
# For mqtt_private, validate scope values
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
@@ -108,6 +128,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
_validate_mqtt_community_config(body.config)
elif body.type == "bot":
_validate_bot_config(body.config)
+ elif body.type == "webhook":
+ _validate_webhook_config(body.config)
scope = _enforce_scope(body.type, body.scope)
@@ -156,6 +178,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
_validate_mqtt_community_config(config_to_validate)
elif existing["type"] == "bot":
_validate_bot_config(config_to_validate)
+ elif existing["type"] == "webhook":
+ _validate_webhook_config(config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 7d350dd1..67cbbc44 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -6,7 +6,7 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { cn } from '@/lib/utils';
import { api } from '../../api';
-import type { FanoutConfig, HealthStatus } from '../../types';
+import type { Channel, Contact, FanoutConfig, HealthStatus } from '../../types';
const BotCodeEditor = lazy(() =>
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
@@ -16,12 +16,14 @@ const TYPE_LABELS: Record = {
mqtt_private: 'Private MQTT',
mqtt_community: 'Community MQTT',
bot: 'Bot',
+ webhook: 'Webhook',
};
const TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community', label: 'Community MQTT' },
{ value: 'bot', label: 'Bot' },
+ { value: 'webhook', label: 'Webhook' },
];
const DEFAULT_BOT_CODE = `def bot(
@@ -62,18 +64,20 @@ const DEFAULT_BOT_CODE = `def bot(
return "[BOT] Plong!"
return None`;
+function getStatusLabel(status: string | undefined, type?: string) {
+ if (status === 'connected') return type === 'bot' || type === 'webhook' ? 'Active' : 'Connected';
+ if (status === 'error') return 'Error';
+ if (status === 'disconnected') return 'Disconnected';
+ return 'Inactive';
+}
+
function getStatusColor(status: string | undefined) {
if (status === 'connected')
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
+ if (status === 'error') return 'bg-destructive shadow-[0_0_6px_hsl(var(--destructive)/0.5)]';
return 'bg-muted-foreground';
}
-function getStatusLabel(status: string | undefined, type?: string) {
- if (status === 'connected') return type === 'bot' ? 'Active' : 'Connected';
- if (status === 'disconnected') return 'Disconnected';
- return 'Inactive';
-}
-
function MqttPrivateConfigEditor({
config,
scope,
@@ -358,6 +362,364 @@ function BotConfigEditor({
);
}
+type ScopeMode = 'all' | 'none' | 'only' | 'except';
+
+function getScopeMode(value: unknown): ScopeMode {
+ if (value === 'all') return 'all';
+ if (value === 'none') return 'none';
+ if (typeof value === 'object' && value !== null) {
+ // Check if either channels or contacts uses the {except: [...]} shape
+ const obj = value as Record;
+ const ch = obj.channels;
+ const co = obj.contacts;
+ if (
+ (typeof ch === 'object' && ch !== null && !Array.isArray(ch)) ||
+ (typeof co === 'object' && co !== null && !Array.isArray(co))
+ ) {
+ return 'except';
+ }
+ return 'only';
+ }
+ return 'all';
+}
+
+/** Extract the key list from a filter value, whether it's a plain list or {except: [...]} */
+function getFilterKeys(filter: unknown): string[] {
+ if (Array.isArray(filter)) return filter as string[];
+ if (typeof filter === 'object' && filter !== null && 'except' in filter)
+ return ((filter as Record).except as string[]) ?? [];
+ return [];
+}
+
+function ScopeSelector({
+ scope,
+ onChange,
+}: {
+ scope: Record;
+ onChange: (scope: Record) => void;
+}) {
+ const [channels, setChannels] = useState([]);
+ const [contacts, setContacts] = useState([]);
+
+ useEffect(() => {
+ api.getChannels().then(setChannels).catch(console.error);
+
+ // Paginate to fetch all contacts (API caps at 1000 per request)
+ (async () => {
+ const all: Contact[] = [];
+ const pageSize = 1000;
+ let offset = 0;
+
+ while (true) {
+ const page = await api.getContacts(pageSize, offset);
+ all.push(...page);
+ if (page.length < pageSize) break;
+ offset += pageSize;
+ }
+ setContacts(all);
+ })().catch(console.error);
+ }, []);
+
+ const messages = scope.messages ?? 'all';
+ const mode = getScopeMode(messages);
+ const isListMode = mode === 'only' || mode === 'except';
+
+ const selectedChannels: string[] =
+ isListMode && typeof messages === 'object' && messages !== null
+ ? getFilterKeys((messages as Record).channels)
+ : [];
+ const selectedContacts: string[] =
+ isListMode && typeof messages === 'object' && messages !== null
+ ? getFilterKeys((messages as Record).contacts)
+ : [];
+
+ /** Wrap channel/contact key lists in the right shape for the current mode */
+ const buildMessages = (chKeys: string[], coKeys: string[]) => {
+ if (mode === 'except') {
+ return {
+ channels: { except: chKeys },
+ contacts: { except: coKeys },
+ };
+ }
+ return { channels: chKeys, contacts: coKeys };
+ };
+
+ const handleModeChange = (newMode: ScopeMode) => {
+ if (newMode === 'all' || newMode === 'none') {
+ onChange({ ...scope, messages: newMode });
+ } else if (newMode === 'only') {
+ onChange({ ...scope, messages: { channels: [], contacts: [] } });
+ } else {
+ onChange({
+ ...scope,
+ messages: { channels: { except: [] }, contacts: { except: [] } },
+ });
+ }
+ };
+
+ const toggleChannel = (key: string) => {
+ const current = [...selectedChannels];
+ const idx = current.indexOf(key);
+ if (idx >= 0) current.splice(idx, 1);
+ else current.push(key);
+ onChange({ ...scope, messages: buildMessages(current, selectedContacts) });
+ };
+
+ const toggleContact = (key: string) => {
+ const current = [...selectedContacts];
+ const idx = current.indexOf(key);
+ if (idx >= 0) current.splice(idx, 1);
+ else current.push(key);
+ onChange({ ...scope, messages: buildMessages(selectedChannels, current) });
+ };
+
+ // Non-repeater contacts only (type 0)
+ const filteredContacts = contacts.filter((c) => c.type === 0);
+
+ const modeDescriptions: Record = {
+ all: 'All messages',
+ none: 'No messages',
+ only: 'Only listed channels/contacts',
+ except: 'All except listed channels/contacts',
+ };
+
+ // For "except" mode, checked means the item is in the exclusion list (will be excluded)
+ const isChannelChecked = (key: string) =>
+ mode === 'except' ? selectedChannels.includes(key) : selectedChannels.includes(key);
+ const isContactChecked = (key: string) =>
+ mode === 'except' ? selectedContacts.includes(key) : selectedContacts.includes(key);
+
+ const listHint =
+ mode === 'only'
+ ? 'Newly added channels or contacts will not be automatically included.'
+ : 'Newly added channels or contacts will be automatically included unless excluded here.';
+
+ const checkboxLabel = mode === 'except' ? 'exclude' : 'include';
+
+ return (
+
+
Message Scope
+
+ {(['all', 'none', 'only', 'except'] as const).map((m) => (
+
+ handleModeChange(m)}
+ className="h-4 w-4 accent-primary"
+ />
+ {modeDescriptions[m]}
+
+ ))}
+
+
+ {isListMode && (
+ <>
+
{listHint}
+
+ {channels.length > 0 && (
+
+
+
+ Channels{' '}
+ ({checkboxLabel})
+
+
+
+ onChange({
+ ...scope,
+ messages: buildMessages(
+ channels.map((ch) => ch.key),
+ selectedContacts
+ ),
+ })
+ }
+ >
+ All
+
+ /
+
+ onChange({ ...scope, messages: buildMessages([], selectedContacts) })
+ }
+ >
+ None
+
+
+
+
+ {channels.map((ch) => (
+
+ toggleChannel(ch.key)}
+ className="h-3.5 w-3.5 rounded border-input accent-primary"
+ />
+ {ch.name}
+
+ ))}
+
+
+ )}
+
+ {filteredContacts.length > 0 && (
+
+
+
+ Contacts{' '}
+ ({checkboxLabel})
+
+
+
+ onChange({
+ ...scope,
+ messages: buildMessages(
+ selectedChannels,
+ filteredContacts.map((c) => c.public_key)
+ ),
+ })
+ }
+ >
+ All
+
+ /
+
+ onChange({ ...scope, messages: buildMessages(selectedChannels, []) })
+ }
+ >
+ None
+
+
+
+
+ {filteredContacts.map((c) => (
+
+ toggleContact(c.public_key)}
+ className="h-3.5 w-3.5 rounded border-input accent-primary"
+ />
+
+ {c.name || c.public_key.substring(0, 12) + '...'}
+
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function WebhookConfigEditor({
+ config,
+ scope,
+ onChange,
+ onScopeChange,
+}: {
+ config: Record;
+ scope: Record;
+ onChange: (config: Record) => void;
+ onScopeChange: (scope: Record) => void;
+}) {
+ const headersStr = JSON.stringify(config.headers ?? {}, null, 2);
+ const [headersText, setHeadersText] = useState(headersStr);
+ const [headersError, setHeadersError] = useState(null);
+
+ const handleHeadersChange = (text: string) => {
+ setHeadersText(text);
+ try {
+ const parsed = JSON.parse(text);
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
+ setHeadersError('Must be a JSON object');
+ return;
+ }
+ setHeadersError(null);
+ onChange({ ...config, headers: parsed });
+ } catch {
+ setHeadersError('Invalid JSON');
+ }
+ };
+
+ return (
+
+
+ Send message data as JSON to an HTTP endpoint when messages are received.
+
+
+
+ URL
+ onChange({ ...config, url: e.target.value })}
+ />
+
+
+
+
+ HTTP Method
+ onChange({ ...config, method: e.target.value })}
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
+ >
+ POST
+ PUT
+ PATCH
+
+
+
+
+ Secret (optional)
+ onChange({ ...config, secret: e.target.value })}
+ />
+
+
+
+
+ Extra Headers (JSON)
+
+
+
+
+
+
+ );
+}
+
export function SettingsFanoutSection({
health,
onHealthRefresh,
@@ -373,7 +735,6 @@ export function SettingsFanoutSection({
const [editScope, setEditScope] = useState>({});
const [editName, setEditName] = useState('');
const [busy, setBusy] = useState(false);
- const [addingType, setAddingType] = useState(null);
const loadConfigs = useCallback(async () => {
try {
@@ -438,10 +799,6 @@ export function SettingsFanoutSection({
}
};
- const handleAddStart = (type: string) => {
- setAddingType(type);
- };
-
const handleAddCreate = async (type: string) => {
const defaults: Record> = {
mqtt_private: {
@@ -462,11 +819,18 @@ export function SettingsFanoutSection({
bot: {
code: DEFAULT_BOT_CODE,
},
+ webhook: {
+ url: '',
+ method: 'POST',
+ headers: {},
+ secret: '',
+ },
};
const defaultScopes: Record> = {
mqtt_private: { messages: 'all', raw_packets: 'all' },
mqtt_community: { messages: 'none', raw_packets: 'all' },
bot: { messages: 'all', raw_packets: 'none' },
+ webhook: { messages: 'all', raw_packets: 'none' },
};
try {
@@ -478,7 +842,6 @@ export function SettingsFanoutSection({
enabled: false,
});
await loadConfigs();
- setAddingType(null);
handleEdit(created);
toast.success('Integration created');
} catch (err) {
@@ -533,6 +896,15 @@ export function SettingsFanoutSection({
)}
+ {editingConfig.type === 'webhook' && (
+
+ )}
+
@@ -554,11 +926,21 @@ export function SettingsFanoutSection({
Integrations are an experimental feature in open beta.
- {configs.length === 0 ? (
-
-
No integrations configured
-
- ) : (
+
+ Add:
+ {TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map((opt) => (
+ handleAddCreate(opt.value)}
+ >
+ {opt.label}
+
+ ))}
+
+
+ {configs.length > 0 && (
{configs.map((cfg) => {
const statusEntry = health?.fanout_statuses?.[cfg.id];
@@ -609,33 +991,6 @@ export function SettingsFanoutSection({
})}
)}
-
- {addingType ? (
-
-
Select integration type:
-
- {TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
- (opt) => (
- handleAddCreate(opt.value)}
- >
- {opt.label}
-
- )
- )}
-
-
setAddingType(null)}>
- Cancel
-
-
- ) : (
- handleAddStart('mqtt_private')} className="w-full">
- + Add Integration
-
- )}
);
}
diff --git a/frontend/src/components/settings/settingsConstants.ts b/frontend/src/components/settings/settingsConstants.ts
index 5a04f71c..652bc44e 100644
--- a/frontend/src/components/settings/settingsConstants.ts
+++ b/frontend/src/components/settings/settingsConstants.ts
@@ -13,7 +13,7 @@ export const SETTINGS_SECTION_LABELS: Record
= {
radio: '📻 Radio',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
- fanout: '📤 Fanout & Forwarding',
+ fanout: '📤 MQTT & Forwarding',
statistics: '📊 Statistics',
about: 'About',
};
diff --git a/tests/test_fanout.py b/tests/test_fanout.py
index 215b70d8..19fddd7d 100644
--- a/tests/test_fanout.py
+++ b/tests/test_fanout.py
@@ -56,6 +56,26 @@ class TestScopeMatchesMessage:
scope = {"messages": {"contacts": ["pk1"]}}
assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk2"})
+ def test_dict_channels_except_excludes_listed(self):
+ scope = {"messages": {"channels": {"except": ["ch1"]}, "contacts": "all"}}
+ assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+
+ def test_dict_channels_except_includes_unlisted(self):
+ scope = {"messages": {"channels": {"except": ["ch1"]}, "contacts": "all"}}
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch2"})
+
+ def test_dict_contacts_except_excludes_listed(self):
+ scope = {"messages": {"channels": "all", "contacts": {"except": ["pk1"]}}}
+ assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
+
+ def test_dict_contacts_except_includes_unlisted(self):
+ scope = {"messages": {"channels": "all", "contacts": {"except": ["pk1"]}}}
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk2"})
+
+ def test_dict_channels_except_empty_matches_all(self):
+ scope = {"messages": {"channels": {"except": []}}}
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+
class TestScopeMatchesRaw:
def test_all_matches(self):
@@ -626,3 +646,106 @@ class TestMigration037:
assert row[0] == 0
finally:
await db.disconnect()
+
+
+# ---------------------------------------------------------------------------
+# Webhook module unit tests
+# ---------------------------------------------------------------------------
+
+
+class TestWebhookModule:
+ @pytest.mark.asyncio
+ async def test_status_disconnected_when_no_url(self):
+ from app.fanout.webhook import WebhookModule
+
+ mod = WebhookModule("test", {"url": ""})
+ await mod.start()
+ assert mod.status == "disconnected"
+ await mod.stop()
+
+ @pytest.mark.asyncio
+ async def test_status_connected_with_url(self):
+ from app.fanout.webhook import WebhookModule
+
+ mod = WebhookModule("test", {"url": "http://localhost:9999/hook"})
+ await mod.start()
+ assert mod.status == "connected"
+ await mod.stop()
+
+ @pytest.mark.asyncio
+ async def test_dispatch_with_matching_scope(self):
+ """WebhookModule dispatches through FanoutManager scope matching."""
+ manager = FanoutManager()
+ mod = StubModule()
+ scope = {"messages": {"channels": ["ch1"], "contacts": "none"}, "raw_packets": "none"}
+ manager._modules["test-webhook"] = (mod, scope)
+
+ await manager.broadcast_message({"type": "CHAN", "conversation_key": "ch1", "text": "yes"})
+ await manager.broadcast_message({"type": "CHAN", "conversation_key": "ch2", "text": "no"})
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "dm no"}
+ )
+
+ assert len(mod.message_calls) == 1
+ assert mod.message_calls[0]["text"] == "yes"
+
+
+# ---------------------------------------------------------------------------
+# Webhook router validation tests
+# ---------------------------------------------------------------------------
+
+
+class TestWebhookValidation:
+ def test_validate_webhook_config_requires_url(self):
+ from fastapi import HTTPException
+
+ from app.routers.fanout import _validate_webhook_config
+
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_webhook_config({"url": ""})
+ assert exc_info.value.status_code == 400
+ assert "url is required" in exc_info.value.detail
+
+ def test_validate_webhook_config_requires_http_scheme(self):
+ from fastapi import HTTPException
+
+ from app.routers.fanout import _validate_webhook_config
+
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_webhook_config({"url": "ftp://example.com"})
+ assert exc_info.value.status_code == 400
+
+ def test_validate_webhook_config_rejects_bad_method(self):
+ from fastapi import HTTPException
+
+ from app.routers.fanout import _validate_webhook_config
+
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_webhook_config({"url": "https://example.com/hook", "method": "DELETE"})
+ assert exc_info.value.status_code == 400
+ assert "method" in exc_info.value.detail
+
+ def test_validate_webhook_config_accepts_valid(self):
+ from app.routers.fanout import _validate_webhook_config
+
+ # Should not raise
+ _validate_webhook_config(
+ {"url": "https://example.com/hook", "method": "POST", "headers": {}}
+ )
+
+ def test_enforce_scope_webhook_strips_raw_packets(self):
+ from app.routers.fanout import _enforce_scope
+
+ scope = _enforce_scope("webhook", {"messages": "all", "raw_packets": "all"})
+ assert scope["raw_packets"] == "none"
+ assert scope["messages"] == "all"
+
+ def test_enforce_scope_webhook_preserves_selective(self):
+ from app.routers.fanout import _enforce_scope
+
+ scope = _enforce_scope(
+ "webhook",
+ {"messages": {"channels": ["ch1"], "contacts": "none"}, "raw_packets": "all"},
+ )
+ assert scope["raw_packets"] == "none"
+ assert scope["messages"] == {"channels": ["ch1"], "contacts": "none"}
diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py
index ab2a5f9f..fe2ad6c9 100644
--- a/tests/test_fanout_integration.py
+++ b/tests/test_fanout_integration.py
@@ -401,3 +401,437 @@ class TestFanoutMqttIntegration:
assert len(mqtt_broker.published) == 1
assert "raw/" in mqtt_broker.published[0][0]
+
+
+# ---------------------------------------------------------------------------
+# Webhook capture HTTP server
+# ---------------------------------------------------------------------------
+
+
+class WebhookCaptureServer:
+ """Tiny HTTP server that captures POST requests for webhook testing."""
+
+ def __init__(self):
+ self.received: list[dict] = []
+ self._server: asyncio.Server | None = None
+ self.port: int = 0
+
+ async def start(self) -> int:
+ self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
+ self.port = self._server.sockets[0].getsockname()[1]
+ return self.port
+
+ async def stop(self):
+ if self._server:
+ self._server.close()
+ await self._server.wait_closed()
+
+ async def wait_for(self, count: int, timeout: float = 5.0) -> list[dict]:
+ deadline = asyncio.get_event_loop().time() + timeout
+ while len(self.received) < count:
+ if asyncio.get_event_loop().time() >= deadline:
+ break
+ await asyncio.sleep(0.02)
+ return list(self.received)
+
+ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
+ try:
+ # Read HTTP request line
+ request_line = await reader.readline()
+ if not request_line:
+ return
+
+ # Read headers
+ headers: dict[str, str] = {}
+ while True:
+ line = await reader.readline()
+ if line in (b"\r\n", b"\n", b""):
+ break
+ decoded = line.decode("utf-8", errors="replace").strip()
+ if ":" in decoded:
+ key, val = decoded.split(":", 1)
+ headers[key.strip().lower()] = val.strip()
+
+ # Read body
+ content_length = int(headers.get("content-length", "0"))
+ body = b""
+ if content_length > 0:
+ body = await reader.readexactly(content_length)
+
+ payload: dict = {}
+ if body:
+ try:
+ payload = json.loads(body)
+ except Exception:
+ payload = {"_raw": body.decode("utf-8", errors="replace")}
+
+ self.received.append(
+ {
+ "method": request_line.decode().split()[0],
+ "headers": headers,
+ "body": payload,
+ }
+ )
+
+ # Send 200 OK
+ response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"
+ writer.write(response)
+ await writer.drain()
+ except (asyncio.IncompleteReadError, ConnectionError, OSError):
+ pass
+ finally:
+ writer.close()
+
+
+@pytest.fixture
+async def webhook_server():
+ server = WebhookCaptureServer()
+ await server.start()
+ yield server
+ await server.stop()
+
+
+def _webhook_config(port: int, secret: str = "") -> dict:
+ return {
+ "url": f"http://127.0.0.1:{port}/hook",
+ "method": "POST",
+ "headers": {},
+ "secret": secret,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Webhook integration tests
+# ---------------------------------------------------------------------------
+
+
+class TestFanoutWebhookIntegration:
+ """End-to-end: real HTTP capture server <-> real WebhookModule."""
+
+ @pytest.mark.asyncio
+ async def test_webhook_receives_message(self, webhook_server, integration_db):
+ """An enabled webhook receives message data via HTTP POST."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Test Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "hello webhook"}
+ )
+
+ results = await webhook_server.wait_for(1)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) == 1
+ assert results[0]["body"]["text"] == "hello webhook"
+ assert results[0]["body"]["conversation_key"] == "pk1"
+ assert results[0]["headers"].get("x-webhook-event") == "message"
+
+ @pytest.mark.asyncio
+ async def test_webhook_sends_secret_header(self, webhook_server, integration_db):
+ """Webhook sends X-Webhook-Secret when configured."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Secret Hook",
+ config=_webhook_config(webhook_server.port, secret="my-secret-123"),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch1", "text": "secret test"}
+ )
+
+ results = await webhook_server.wait_for(1)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) == 1
+ assert results[0]["headers"].get("x-webhook-secret") == "my-secret-123"
+
+ @pytest.mark.asyncio
+ async def test_webhook_disabled_no_delivery(self, webhook_server, integration_db):
+ """Disabled webhook should not deliver any messages."""
+ await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Disabled Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=False,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ assert len(manager._modules) == 0
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "nope"}
+ )
+ await asyncio.sleep(0.3)
+ finally:
+ await manager.stop_all()
+
+ assert len(webhook_server.received) == 0
+
+ @pytest.mark.asyncio
+ async def test_webhook_scope_selective_channels(self, webhook_server, integration_db):
+ """Webhook with selective scope only fires for matching channels."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Selective Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={"messages": {"channels": ["ch-yes"], "contacts": "none"}, "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ # Matching channel — should deliver
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch-yes", "text": "included"}
+ )
+ # Non-matching channel — should NOT deliver
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch-no", "text": "excluded"}
+ )
+ # DM — contacts is "none", should NOT deliver
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "dm excluded"}
+ )
+
+ await webhook_server.wait_for(1)
+ await asyncio.sleep(0.3) # wait for any stragglers
+ finally:
+ await manager.stop_all()
+
+ assert len(webhook_server.received) == 1
+ assert webhook_server.received[0]["body"]["text"] == "included"
+
+ @pytest.mark.asyncio
+ async def test_webhook_scope_selective_contacts(self, webhook_server, integration_db):
+ """Webhook with selective scope only fires for matching contacts."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Contact Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={
+ "messages": {"channels": "none", "contacts": ["pk-yes"]},
+ "raw_packets": "none",
+ },
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk-yes", "text": "dm included"}
+ )
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk-no", "text": "dm excluded"}
+ )
+
+ await webhook_server.wait_for(1)
+ await asyncio.sleep(0.3)
+ finally:
+ await manager.stop_all()
+
+ assert len(webhook_server.received) == 1
+ assert webhook_server.received[0]["body"]["text"] == "dm included"
+
+ @pytest.mark.asyncio
+ async def test_webhook_scope_all_receives_everything(self, webhook_server, integration_db):
+ """Webhook with scope messages='all' receives DMs and channel messages."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="All Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch1", "text": "channel msg"}
+ )
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "dm msg"}
+ )
+
+ results = await webhook_server.wait_for(2)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) == 2
+ texts = {r["body"]["text"] for r in results}
+ assert "channel msg" in texts
+ assert "dm msg" in texts
+
+ @pytest.mark.asyncio
+ async def test_webhook_scope_none_receives_nothing(self, webhook_server, integration_db):
+ """Webhook with scope messages='none' receives nothing."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="None Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={"messages": "none", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "should not arrive"}
+ )
+ await asyncio.sleep(0.3)
+ finally:
+ await manager.stop_all()
+
+ assert len(webhook_server.received) == 0
+
+ @pytest.mark.asyncio
+ async def test_two_webhooks_both_receive(self, webhook_server, integration_db):
+ """Two enabled webhooks both receive the same message."""
+ cfg_a = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Hook A",
+ config=_webhook_config(webhook_server.port, secret="a"),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+ cfg_b = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Hook B",
+ config=_webhook_config(webhook_server.port, secret="b"),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg_a["id"])
+ await _wait_connected(manager, cfg_b["id"])
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "multi"}
+ )
+
+ results = await webhook_server.wait_for(2)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) == 2
+ secrets = {r["headers"].get("x-webhook-secret") for r in results}
+ assert "a" in secrets
+ assert "b" in secrets
+
+ @pytest.mark.asyncio
+ async def test_webhook_disable_stops_delivery(self, webhook_server, integration_db):
+ """Disabling a webhook stops delivery immediately."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Toggle Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "before disable"}
+ )
+ await webhook_server.wait_for(1)
+ assert len(webhook_server.received) == 1
+
+ # Disable
+ await FanoutConfigRepository.update(cfg["id"], enabled=False)
+ await manager.reload_config(cfg["id"])
+ assert cfg["id"] not in manager._modules
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk2", "text": "after disable"}
+ )
+ await asyncio.sleep(0.3)
+ finally:
+ await manager.stop_all()
+
+ assert len(webhook_server.received) == 1
+
+ @pytest.mark.asyncio
+ async def test_webhook_scope_except_channels(self, webhook_server, integration_db):
+ """Webhook with except-mode excludes listed channels, includes others."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Except Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={
+ "messages": {
+ "channels": {"except": ["ch-excluded"]},
+ "contacts": {"except": []},
+ },
+ "raw_packets": "none",
+ },
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ # Excluded channel — should NOT deliver
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch-excluded", "text": "nope"}
+ )
+ # Non-excluded channel — should deliver
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch-other", "text": "yes"}
+ )
+ # DM with empty except list — should deliver
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "dm yes"}
+ )
+
+ await webhook_server.wait_for(2)
+ await asyncio.sleep(0.3)
+ finally:
+ await manager.stop_all()
+
+ assert len(webhook_server.received) == 2
+ texts = {r["body"]["text"] for r in webhook_server.received}
+ assert "yes" in texts
+ assert "dm yes" in texts
+ assert "nope" not in texts
From 418955198f6fcd088be867002157effd76728679 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 19:22:28 -0800
Subject: [PATCH 04/28] Add Apprise
---
app/fanout/apprise_mod.py | 125 +++++++++++++
app/fanout/manager.py | 2 +
app/routers/fanout.py | 15 +-
.../settings/SettingsFanoutSection.tsx | 105 ++++++++++-
pyproject.toml | 1 +
tests/test_fanout.py | 134 ++++++++++++++
uv.lock | 173 ++++++++++++++++++
7 files changed, 552 insertions(+), 3 deletions(-)
create mode 100644 app/fanout/apprise_mod.py
diff --git a/app/fanout/apprise_mod.py b/app/fanout/apprise_mod.py
new file mode 100644
index 00000000..a94821bc
--- /dev/null
+++ b/app/fanout/apprise_mod.py
@@ -0,0 +1,125 @@
+"""Fanout module for Apprise push notifications."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+
+from app.fanout.base import FanoutModule
+
+logger = logging.getLogger(__name__)
+
+
+def _parse_urls(raw: str) -> list[str]:
+ """Split multi-line URL string into individual URLs."""
+ return [line.strip() for line in raw.splitlines() if line.strip()]
+
+
+def _normalize_discord_url(url: str) -> str:
+ """Add avatar=no to Discord URLs to suppress identity override."""
+ parts = urlsplit(url)
+ scheme = parts.scheme.lower()
+ host = parts.netloc.lower()
+
+ is_discord = scheme in ("discord", "discords") or (
+ scheme in ("http", "https")
+ and host in ("discord.com", "discordapp.com")
+ and parts.path.lower().startswith("/api/webhooks/")
+ )
+ if not is_discord:
+ return url
+
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
+ query["avatar"] = "no"
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
+
+
+def _format_body(data: dict, *, include_path: bool) -> str:
+ """Build a human-readable notification body from message data."""
+ msg_type = data.get("type", "")
+ text = data.get("text", "")
+ sender_name = data.get("sender_name") or "Unknown"
+
+ via = ""
+ if include_path:
+ paths = data.get("paths")
+ if paths and isinstance(paths, list) and len(paths) > 0:
+ path_str = paths[0].get("path", "") if isinstance(paths[0], dict) else ""
+ else:
+ path_str = None
+
+ if msg_type == "PRIV" and path_str is None:
+ via = " **via:** [`direct`]"
+ elif path_str is not None:
+ path_str = path_str.strip().lower()
+ if path_str == "":
+ via = " **via:** [`direct`]"
+ else:
+ hops = [path_str[i : i + 2] for i in range(0, len(path_str), 2)]
+ if hops:
+ hop_list = ", ".join(f"`{h}`" for h in hops)
+ via = f" **via:** [{hop_list}]"
+
+ if msg_type == "PRIV":
+ return f"**DM:** {sender_name}: {text}{via}"
+
+ channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
+ return f"**{channel_name}:** {sender_name}: {text}{via}"
+
+
+def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
+ """Send notification synchronously via Apprise. Returns True on success."""
+ import apprise as apprise_lib
+
+ urls = _parse_urls(urls_raw)
+ if not urls:
+ return False
+
+ notifier = apprise_lib.Apprise()
+ for url in urls:
+ if preserve_identity:
+ url = _normalize_discord_url(url)
+ notifier.add(url)
+
+ return bool(notifier.notify(title="", body=body))
+
+
+class AppriseModule(FanoutModule):
+ """Sends push notifications via Apprise for incoming messages."""
+
+ def __init__(self, config_id: str, config: dict) -> None:
+ super().__init__(config_id, config)
+ self._last_error: str | None = None
+
+ async def on_message(self, data: dict) -> None:
+ # Skip outgoing messages — only notify on incoming
+ if data.get("outgoing"):
+ return
+
+ urls = self.config.get("urls", "")
+ if not urls or not urls.strip():
+ return
+
+ preserve_identity = self.config.get("preserve_identity", True)
+ include_path = self.config.get("include_path", True)
+ body = _format_body(data, include_path=include_path)
+
+ try:
+ success = await asyncio.to_thread(
+ _send_sync, urls, body, preserve_identity=preserve_identity
+ )
+ self._last_error = None if success else "Apprise notify returned failure"
+ if not success:
+ logger.warning("Apprise notification failed for module %s", self.config_id)
+ except Exception as exc:
+ self._last_error = str(exc)
+ logger.exception("Apprise send error for module %s", self.config_id)
+
+ @property
+ def status(self) -> str:
+ if not self.config.get("urls", "").strip():
+ return "disconnected"
+ if self._last_error:
+ return "error"
+ return "connected"
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
index 4eb7d734..23cda0e2 100644
--- a/app/fanout/manager.py
+++ b/app/fanout/manager.py
@@ -17,6 +17,7 @@ def _register_module_types() -> None:
"""Lazily populate the type registry to avoid circular imports."""
if _MODULE_TYPES:
return
+ from app.fanout.apprise_mod import AppriseModule
from app.fanout.bot import BotModule
from app.fanout.mqtt_community import MqttCommunityModule
from app.fanout.mqtt_private import MqttPrivateModule
@@ -26,6 +27,7 @@ def _register_module_types() -> None:
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
_MODULE_TYPES["bot"] = BotModule
_MODULE_TYPES["webhook"] = WebhookModule
+ _MODULE_TYPES["apprise"] = AppriseModule
def _matches_filter(filter_value: Any, key: str) -> bool:
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
index f4bd01c6..3a0928a1 100644
--- a/app/routers/fanout.py
+++ b/app/routers/fanout.py
@@ -12,7 +12,7 @@ from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fanout", tags=["fanout"])
-_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook"}
+_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise"}
_IATA_RE = re.compile(r"^[A-Z]{3}$")
@@ -65,6 +65,13 @@ def _validate_bot_config(config: dict) -> None:
) from None
+def _validate_apprise_config(config: dict) -> None:
+ """Validate apprise config blob."""
+ urls = config.get("urls", "")
+ if not urls or not urls.strip():
+ raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
+
+
def _validate_webhook_config(config: dict) -> None:
"""Validate webhook config blob."""
url = config.get("url", "")
@@ -86,7 +93,7 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
return {"messages": "none", "raw_packets": "all"}
if config_type == "bot":
return {"messages": "all", "raw_packets": "none"}
- if config_type == "webhook":
+ if config_type in ("webhook", "apprise"):
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
messages = "all"
@@ -130,6 +137,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
_validate_bot_config(body.config)
elif body.type == "webhook":
_validate_webhook_config(body.config)
+ elif body.type == "apprise":
+ _validate_apprise_config(body.config)
scope = _enforce_scope(body.type, body.scope)
@@ -180,6 +189,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
_validate_bot_config(config_to_validate)
elif existing["type"] == "webhook":
_validate_webhook_config(config_to_validate)
+ elif existing["type"] == "apprise":
+ _validate_apprise_config(config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 67cbbc44..7b9415f7 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -17,6 +17,7 @@ const TYPE_LABELS: Record = {
mqtt_community: 'Community MQTT',
bot: 'Bot',
webhook: 'Webhook',
+ apprise: 'Apprise',
};
const TYPE_OPTIONS = [
@@ -24,6 +25,7 @@ const TYPE_OPTIONS = [
{ value: 'mqtt_community', label: 'Community MQTT' },
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
+ { value: 'apprise', label: 'Apprise' },
];
const DEFAULT_BOT_CODE = `def bot(
@@ -65,7 +67,8 @@ const DEFAULT_BOT_CODE = `def bot(
return None`;
function getStatusLabel(status: string | undefined, type?: string) {
- if (status === 'connected') return type === 'bot' || type === 'webhook' ? 'Active' : 'Connected';
+ if (status === 'connected')
+ return type === 'bot' || type === 'webhook' || type === 'apprise' ? 'Active' : 'Connected';
if (status === 'error') return 'Error';
if (status === 'disconnected') return 'Disconnected';
return 'Inactive';
@@ -627,6 +630,91 @@ function ScopeSelector({
);
}
+function AppriseConfigEditor({
+ config,
+ scope,
+ onChange,
+ onScopeChange,
+}: {
+ config: Record;
+ scope: Record;
+ onChange: (config: Record) => void;
+ onScopeChange: (scope: Record) => void;
+}) {
+ return (
+
+
+ Send push notifications via{' '}
+
+ Apprise
+ {' '}
+ when messages are received. Supports Discord, Slack, Telegram, email, and{' '}
+
+ 100+ other services
+
+ .
+
+
+
+
+
+ onChange({ ...config, preserve_identity: e.target.checked })}
+ className="h-4 w-4 rounded border-border"
+ />
+
+
Preserve identity on Discord
+
+ When enabled, Discord webhooks will use their configured name/avatar instead of
+ overriding with MeshCore sender info.
+
+
+
+
+
+ onChange({ ...config, include_path: e.target.checked })}
+ className="h-4 w-4 rounded border-border"
+ />
+ Include routing path in notifications
+
+
+
+
+
+
+ );
+}
+
function WebhookConfigEditor({
config,
scope,
@@ -825,12 +913,18 @@ export function SettingsFanoutSection({
headers: {},
secret: '',
},
+ apprise: {
+ urls: '',
+ preserve_identity: true,
+ include_path: true,
+ },
};
const defaultScopes: Record> = {
mqtt_private: { messages: 'all', raw_packets: 'all' },
mqtt_community: { messages: 'none', raw_packets: 'all' },
bot: { messages: 'all', raw_packets: 'none' },
webhook: { messages: 'all', raw_packets: 'none' },
+ apprise: { messages: 'all', raw_packets: 'none' },
};
try {
@@ -896,6 +990,15 @@ export function SettingsFanoutSection({
)}
+ {editingConfig.type === 'apprise' && (
+
+ )}
+
{editingConfig.type === 'webhook' && (
=1.5.0",
"meshcore",
"aiomqtt>=2.0",
+ "apprise>=1.9.7",
]
[project.optional-dependencies]
diff --git a/tests/test_fanout.py b/tests/test_fanout.py
index 19fddd7d..1407141c 100644
--- a/tests/test_fanout.py
+++ b/tests/test_fanout.py
@@ -749,3 +749,137 @@ class TestWebhookValidation:
)
assert scope["raw_packets"] == "none"
assert scope["messages"] == {"channels": ["ch1"], "contacts": "none"}
+
+
+# ---------------------------------------------------------------------------
+# Apprise module unit tests
+# ---------------------------------------------------------------------------
+
+
+class TestAppriseModule:
+ @pytest.mark.asyncio
+ async def test_status_disconnected_when_no_urls(self):
+ from app.fanout.apprise_mod import AppriseModule
+
+ mod = AppriseModule("test", {"urls": ""})
+ assert mod.status == "disconnected"
+
+ @pytest.mark.asyncio
+ async def test_status_connected_with_urls(self):
+ from app.fanout.apprise_mod import AppriseModule
+
+ mod = AppriseModule("test", {"urls": "json://localhost"})
+ assert mod.status == "connected"
+
+ @pytest.mark.asyncio
+ async def test_skips_outgoing_messages(self):
+ from unittest.mock import patch as _patch
+
+ from app.fanout.apprise_mod import AppriseModule
+
+ mod = AppriseModule("test", {"urls": "json://localhost"})
+ with _patch("app.fanout.apprise_mod._send_sync") as mock_send:
+ await mod.on_message({"type": "PRIV", "text": "hi", "outgoing": True})
+ mock_send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_sends_for_incoming_messages(self):
+ from unittest.mock import patch as _patch
+
+ from app.fanout.apprise_mod import AppriseModule
+
+ mod = AppriseModule("test", {"urls": "json://localhost"})
+ with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
+ await mod.on_message(
+ {"type": "PRIV", "text": "hello", "outgoing": False, "sender_name": "Alice"}
+ )
+ mock_send.assert_called_once()
+ body = mock_send.call_args[0][1]
+ assert "Alice" in body
+ assert "hello" in body
+
+
+class TestAppriseFormatBody:
+ def test_dm_format(self):
+ from app.fanout.apprise_mod import _format_body
+
+ body = _format_body(
+ {"type": "PRIV", "text": "hi", "sender_name": "Alice"}, include_path=False
+ )
+ assert body == "**DM:** Alice: hi"
+
+ def test_channel_format(self):
+ from app.fanout.apprise_mod import _format_body
+
+ body = _format_body(
+ {"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
+ include_path=False,
+ )
+ assert body == "**#general:** Bob: hi"
+
+ def test_dm_with_path(self):
+ from app.fanout.apprise_mod import _format_body
+
+ body = _format_body(
+ {
+ "type": "PRIV",
+ "text": "hi",
+ "sender_name": "Alice",
+ "paths": [{"path": "2027"}],
+ },
+ include_path=True,
+ )
+ assert "**via:**" in body
+ assert "`20`" in body
+ assert "`27`" in body
+
+ def test_dm_no_path_shows_direct(self):
+ from app.fanout.apprise_mod import _format_body
+
+ body = _format_body(
+ {"type": "PRIV", "text": "hi", "sender_name": "Alice"},
+ include_path=True,
+ )
+ assert "`direct`" in body
+
+
+class TestAppriseNormalizeDiscordUrl:
+ def test_discord_scheme(self):
+ from app.fanout.apprise_mod import _normalize_discord_url
+
+ assert _normalize_discord_url("discord://123/abc") == "discord://123/abc?avatar=no"
+
+ def test_discord_https(self):
+ from app.fanout.apprise_mod import _normalize_discord_url
+
+ result = _normalize_discord_url("https://discord.com/api/webhooks/123/abc")
+ assert "avatar=no" in result
+
+ def test_non_discord_unchanged(self):
+ from app.fanout.apprise_mod import _normalize_discord_url
+
+ url = "slack://token_a/token_b/token_c"
+ assert _normalize_discord_url(url) == url
+
+
+class TestAppriseValidation:
+ def test_validate_apprise_config_requires_urls(self):
+ from fastapi import HTTPException
+
+ from app.routers.fanout import _validate_apprise_config
+
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_apprise_config({"urls": ""})
+ assert exc_info.value.status_code == 400
+
+ def test_validate_apprise_config_accepts_valid(self):
+ from app.routers.fanout import _validate_apprise_config
+
+ _validate_apprise_config({"urls": "discord://123/abc"})
+
+ def test_enforce_scope_apprise_strips_raw_packets(self):
+ from app.routers.fanout import _enforce_scope
+
+ scope = _enforce_scope("apprise", {"messages": "all", "raw_packets": "all"})
+ assert scope["raw_packets"] == "none"
+ assert scope["messages"] == "all"
diff --git a/uv.lock b/uv.lock
index 69ce8c60..d7b6ef2a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -56,6 +56,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362 },
]
+[[package]]
+name = "apprise"
+version = "1.9.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "click" },
+ { name = "markdown" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "requests-oauthlib" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/f5/97dc06b3401bb67abcef6e8bef7155f192b75795c2a2aa4d59eb5aa7fa66/apprise-1.9.7.tar.gz", hash = "sha256:2f73cc1e0264fb119fdb9b7cde82e8fde40a0f531ac885d8c6f0cf0f6e13aec2", size = 1937173 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/6b/cfa80a13437896eb8f4504ddac6dfa4ef7f1d2b2261057aa4a30003b8de6/apprise-1.9.7-py3-none-any.whl", hash = "sha256:c7640a81a1097685de66e0508e3da89f49235d566cb44bbead1dd98419bf5ee3", size = 1459879 },
+]
+
[[package]]
name = "async-timeout"
version = "5.0.1"
@@ -191,6 +209,95 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 },
]
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709 },
+ { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814 },
+ { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467 },
+ { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280 },
+ { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454 },
+ { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609 },
+ { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849 },
+ { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586 },
+ { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290 },
+ { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663 },
+ { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964 },
+ { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064 },
+ { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015 },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792 },
+ { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198 },
+ { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262 },
+ { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 },
+ { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 },
+ { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 },
+ { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 },
+ { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 },
+ { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 },
+ { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 },
+ { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 },
+ { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 },
+ { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 },
+ { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 },
+ { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 },
+ { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 },
+ { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 },
+ { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 },
+ { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 },
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 },
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 },
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 },
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 },
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 },
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 },
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 },
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 },
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 },
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 },
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 },
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 },
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 },
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 },
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 },
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 },
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 },
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 },
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 },
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 },
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 },
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 },
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 },
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 },
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 },
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 },
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 },
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 },
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 },
+]
+
[[package]]
name = "click"
version = "8.3.1"
@@ -379,6 +486,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
+[[package]]
+name = "markdown"
+version = "3.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180 },
+]
+
[[package]]
name = "meshcore"
version = "2.2.5"
@@ -402,6 +518,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 },
]
+[[package]]
+name = "oauthlib"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 },
+]
+
[[package]]
name = "packaging"
version = "25.0"
@@ -928,6 +1053,7 @@ source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },
{ name = "aiosqlite" },
+ { name = "apprise" },
{ name = "fastapi" },
{ name = "meshcore" },
{ name = "pycryptodome" },
@@ -959,6 +1085,7 @@ dev = [
requires-dist = [
{ name = "aiomqtt", specifier = ">=2.0" },
{ name = "aiosqlite", specifier = ">=0.19.0" },
+ { name = "apprise", specifier = ">=1.9.7" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
{ name = "meshcore" },
@@ -983,6 +1110,34 @@ dev = [
{ name = "ruff", specifier = ">=0.8.0" },
]
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
+]
+
+[[package]]
+name = "requests-oauthlib"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "oauthlib" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
+]
+
[[package]]
name = "ruff"
version = "0.14.11"
@@ -1092,6 +1247,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
]
+[[package]]
+name = "tzdata"
+version = "2025.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 },
+]
+
[[package]]
name = "uvicorn"
version = "0.40.0"
From 13fa94acaa6554bb99256615767c92f5dbff6f27 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 19:28:09 -0800
Subject: [PATCH 05/28] Richer saving options + more popping color on disabled
integration
---
.../settings/SettingsFanoutSection.tsx | 35 ++++++++++++++-----
1 file changed, 26 insertions(+), 9 deletions(-)
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 7b9415f7..1a30ebcc 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -74,7 +74,9 @@ function getStatusLabel(status: string | undefined, type?: string) {
return 'Inactive';
}
-function getStatusColor(status: string | undefined) {
+function getStatusColor(status: string | undefined, enabled?: boolean) {
+ if (enabled === false)
+ return 'bg-warning shadow-[0_0_6px_hsl(var(--warning)/0.5)]';
if (status === 'connected')
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
if (status === 'error') return 'bg-destructive shadow-[0_0_6px_hsl(var(--destructive)/0.5)]';
@@ -855,18 +857,21 @@ export function SettingsFanoutSection({
setEditName(cfg.name);
};
- const handleSave = async () => {
+ const handleSave = async (enabled?: boolean) => {
if (!editingId) return;
setBusy(true);
try {
- await api.updateFanoutConfig(editingId, {
+ const update: Record = {
name: editName,
config: editConfig,
scope: editScope,
- });
+ };
+ if (enabled !== undefined) update.enabled = enabled;
+ await api.updateFanoutConfig(editingId, update);
await loadConfigs();
+ if (onHealthRefresh) await onHealthRefresh();
setEditingId(null);
- toast.success('Integration saved');
+ toast.success(enabled ? 'Integration saved and enabled' : 'Integration saved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save');
} finally {
@@ -1011,8 +1016,20 @@ export function SettingsFanoutSection({
-
- {busy ? 'Saving...' : 'Save'}
+ handleSave(true)}
+ disabled={busy}
+ className="flex-1 bg-status-connected hover:bg-status-connected/90 text-primary-foreground"
+ >
+ {busy ? 'Saving...' : 'Save as Enabled'}
+
+ handleSave(false)}
+ disabled={busy}
+ className="flex-1"
+ >
+ {busy ? 'Saving...' : 'Save as Disabled'}
handleDelete(editingConfig.id)}>
Delete
@@ -1071,8 +1088,8 @@ export function SettingsFanoutSection({
From e99fed2e7613ac1ec42cf26ebf67b4f4cd77d2a8 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 19:44:06 -0800
Subject: [PATCH 06/28] Add some test coverage
---
.../settings/SettingsFanoutSection.tsx | 8 +-
tests/e2e/specs/apprise.spec.ts | 205 ++++++++++
tests/e2e/specs/bot.spec.ts | 5 +-
tests/e2e/specs/webhook.spec.ts | 172 +++++++++
...ings.spec.ts => zz-radio-settings.spec.ts} | 0
tests/test_fanout.py | 158 ++++++++
tests/test_fanout_integration.py | 360 ++++++++++++++++++
7 files changed, 901 insertions(+), 7 deletions(-)
create mode 100644 tests/e2e/specs/apprise.spec.ts
create mode 100644 tests/e2e/specs/webhook.spec.ts
rename tests/e2e/specs/{radio-settings.spec.ts => zz-radio-settings.spec.ts} (100%)
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 1a30ebcc..5d2d57aa 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -75,8 +75,7 @@ function getStatusLabel(status: string | undefined, type?: string) {
}
function getStatusColor(status: string | undefined, enabled?: boolean) {
- if (enabled === false)
- return 'bg-warning shadow-[0_0_6px_hsl(var(--warning)/0.5)]';
+ if (enabled === false) return 'bg-warning shadow-[0_0_6px_hsl(var(--warning)/0.5)]';
if (status === 'connected')
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
if (status === 'error') return 'bg-destructive shadow-[0_0_6px_hsl(var(--destructive)/0.5)]';
@@ -1088,7 +1087,10 @@ export function SettingsFanoutSection({
diff --git a/tests/e2e/specs/apprise.spec.ts b/tests/e2e/specs/apprise.spec.ts
new file mode 100644
index 00000000..c9dc4e7d
--- /dev/null
+++ b/tests/e2e/specs/apprise.spec.ts
@@ -0,0 +1,205 @@
+import { test, expect } from '@playwright/test';
+import {
+ createFanoutConfig,
+ deleteFanoutConfig,
+ getFanoutConfigs,
+} from '../helpers/api';
+
+test.describe('Apprise integration settings', () => {
+ let createdAppriseId: string | null = null;
+
+ test.afterEach(async () => {
+ if (createdAppriseId) {
+ try {
+ await deleteFanoutConfig(createdAppriseId);
+ } catch {
+ console.warn('Failed to delete test apprise config');
+ }
+ createdAppriseId = null;
+ }
+ });
+
+ test('create apprise via UI, configure URLs, save as enabled', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ // Open settings and navigate to MQTT & Forwarding
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ // Click the Apprise add button
+ await page.getByRole('button', { name: 'Apprise' }).click();
+
+ // Should navigate to the detail/edit view with default name
+ await expect(page.getByDisplayValue('Apprise')).toBeVisible();
+
+ // Fill in notification URL
+ const urlsTextarea = page.locator('#fanout-apprise-urls');
+ await urlsTextarea.fill('json://localhost:9999');
+
+ // Verify preserve identity checkbox is checked by default
+ const preserveIdentity = page.getByText('Preserve identity on Discord');
+ await expect(preserveIdentity).toBeVisible();
+
+ // Verify include routing path checkbox is checked by default
+ const includePath = page.getByText('Include routing path in notifications');
+ await expect(includePath).toBeVisible();
+
+ // Rename it
+ const nameInput = page.locator('#fanout-edit-name');
+ await nameInput.clear();
+ await nameInput.fill('E2E Apprise');
+
+ // Save as enabled
+ await page.getByRole('button', { name: /Save as Enabled/i }).click();
+ await expect(page.getByText('Integration saved and enabled')).toBeVisible();
+
+ // Should be back on list view with our apprise config visible
+ await expect(page.getByText('E2E Apprise')).toBeVisible();
+
+ // Clean up via API
+ const configs = await getFanoutConfigs();
+ const apprise = configs.find((c) => c.name === 'E2E Apprise');
+ if (apprise) {
+ createdAppriseId = apprise.id;
+ }
+ });
+
+ test('create apprise via API, verify options persist after edit', async ({ page }) => {
+ const apprise = await createFanoutConfig({
+ type: 'apprise',
+ name: 'API Apprise',
+ config: {
+ urls: 'json://localhost:9999\nslack://token_a/token_b/token_c',
+ preserve_identity: false,
+ include_path: false,
+ },
+ enabled: true,
+ });
+ createdAppriseId = apprise.id;
+
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ // Click Edit on our apprise config
+ const row = page.getByText('API Apprise').locator('..');
+ await row.getByRole('button', { name: 'Edit' }).click();
+
+ // Verify the URLs textarea has our content
+ const urlsTextarea = page.locator('#fanout-apprise-urls');
+ await expect(urlsTextarea).toHaveValue(/json:\/\/localhost:9999/);
+ await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
+
+ // Verify checkboxes reflect our config (both unchecked)
+ const preserveCheckbox = page
+ .getByText('Preserve identity on Discord')
+ .locator('xpath=ancestor::label[1]')
+ .locator('input[type="checkbox"]');
+ await expect(preserveCheckbox).not.toBeChecked();
+
+ const pathCheckbox = page
+ .getByText('Include routing path in notifications')
+ .locator('xpath=ancestor::label[1]')
+ .locator('input[type="checkbox"]');
+ await expect(pathCheckbox).not.toBeChecked();
+
+ // Go back
+ await page.getByText('← Back to list').click();
+ });
+
+ test('apprise shows scope selector', async ({ page }) => {
+ const apprise = await createFanoutConfig({
+ type: 'apprise',
+ name: 'Scope Apprise',
+ config: { urls: 'json://localhost:9999' },
+ });
+ createdAppriseId = apprise.id;
+
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ const row = page.getByText('Scope Apprise').locator('..');
+ await row.getByRole('button', { name: 'Edit' }).click();
+
+ // Verify scope selector is present
+ await expect(page.getByText('Message Scope')).toBeVisible();
+ await expect(page.getByText('All messages')).toBeVisible();
+
+ // Select "All except listed" mode
+ await page.getByText('All except listed channels/contacts').click();
+
+ // Should show channel and contact lists with exclude label
+ await expect(page.getByText('(exclude)')).toBeVisible();
+
+ // Go back
+ await page.getByText('← Back to list').click();
+ });
+
+ test('apprise disabled config shows amber dot and can be enabled via save button', async ({
+ page,
+ }) => {
+ const apprise = await createFanoutConfig({
+ type: 'apprise',
+ name: 'Disabled Apprise',
+ config: { urls: 'json://localhost:9999' },
+ enabled: false,
+ });
+ createdAppriseId = apprise.id;
+
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ // Should show "Disabled" text
+ const row = page.getByText('Disabled Apprise').locator('..');
+ await expect(row.getByText('Disabled')).toBeVisible();
+
+ // Edit it
+ await row.getByRole('button', { name: 'Edit' }).click();
+
+ // Save as enabled
+ await page.getByRole('button', { name: /Save as Enabled/i }).click();
+ await expect(page.getByText('Integration saved and enabled')).toBeVisible();
+
+ // Verify it's now enabled via API
+ const configs = await getFanoutConfigs();
+ const updated = configs.find((c) => c.id === apprise.id);
+ expect(updated?.enabled).toBe(true);
+ });
+
+ test('delete apprise via UI', async ({ page }) => {
+ const apprise = await createFanoutConfig({
+ type: 'apprise',
+ name: 'Delete Me Apprise',
+ config: { urls: 'json://localhost:9999' },
+ });
+ createdAppriseId = apprise.id;
+
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ const row = page.getByText('Delete Me Apprise').locator('..');
+ await row.getByRole('button', { name: 'Edit' }).click();
+
+ // Accept the confirmation dialog
+ page.on('dialog', (dialog) => dialog.accept());
+
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await expect(page.getByText('Integration deleted')).toBeVisible();
+
+ // Should be back on list, apprise gone
+ await expect(page.getByText('Delete Me Apprise')).not.toBeVisible();
+ createdAppriseId = null;
+ });
+});
diff --git a/tests/e2e/specs/bot.spec.ts b/tests/e2e/specs/bot.spec.ts
index 42b155c4..ebaa6b31 100644
--- a/tests/e2e/specs/bot.spec.ts
+++ b/tests/e2e/specs/bot.spec.ts
@@ -1,12 +1,9 @@
import { test, expect } from '@playwright/test';
import {
ensureFlightlessChannel,
- getFanoutConfigs,
createFanoutConfig,
deleteFanoutConfig,
- updateFanoutConfig,
} from '../helpers/api';
-import type { FanoutConfig } from '../helpers/api';
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
@@ -48,7 +45,7 @@ test.describe('Bot functionality', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /Fanout/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
// The bot name should be visible in the integration list
await expect(page.getByText('E2E Test Bot')).toBeVisible();
diff --git a/tests/e2e/specs/webhook.spec.ts b/tests/e2e/specs/webhook.spec.ts
new file mode 100644
index 00000000..1f9f3c98
--- /dev/null
+++ b/tests/e2e/specs/webhook.spec.ts
@@ -0,0 +1,172 @@
+import { test, expect } from '@playwright/test';
+import {
+ createFanoutConfig,
+ deleteFanoutConfig,
+ getFanoutConfigs,
+} from '../helpers/api';
+
+test.describe('Webhook integration settings', () => {
+ let createdWebhookId: string | null = null;
+
+ test.afterEach(async () => {
+ if (createdWebhookId) {
+ try {
+ await deleteFanoutConfig(createdWebhookId);
+ } catch {
+ console.warn('Failed to delete test webhook');
+ }
+ createdWebhookId = null;
+ }
+ });
+
+ test('create webhook via UI, configure, save as enabled, verify in list', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ // Open settings and navigate to MQTT & Forwarding
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ // Click the Webhook add button
+ await page.getByRole('button', { name: 'Webhook' }).click();
+
+ // Should navigate to the detail/edit view with default name
+ await expect(page.getByDisplayValue('Webhook')).toBeVisible();
+
+ // Fill in webhook URL
+ const urlInput = page.locator('#fanout-webhook-url');
+ await urlInput.fill('https://example.com/e2e-test-hook');
+
+ // Verify method defaults to POST
+ await expect(page.locator('#fanout-webhook-method')).toHaveValue('POST');
+
+ // Fill in a secret
+ const secretInput = page.locator('#fanout-webhook-secret');
+ await secretInput.fill('e2e-secret');
+
+ // Rename it
+ const nameInput = page.locator('#fanout-edit-name');
+ await nameInput.clear();
+ await nameInput.fill('E2E Webhook');
+
+ // Save as enabled
+ await page.getByRole('button', { name: /Save as Enabled/i }).click();
+ await expect(page.getByText('Integration saved and enabled')).toBeVisible();
+
+ // Should be back on list view with our webhook visible
+ await expect(page.getByText('E2E Webhook')).toBeVisible();
+
+ // Clean up via API
+ const configs = await getFanoutConfigs();
+ const webhook = configs.find((c) => c.name === 'E2E Webhook');
+ if (webhook) {
+ createdWebhookId = webhook.id;
+ }
+ });
+
+ test('create webhook via API, edit in UI, save as disabled', async ({ page }) => {
+ // Create via API
+ const webhook = await createFanoutConfig({
+ type: 'webhook',
+ name: 'API Webhook',
+ config: { url: 'https://example.com/hook', method: 'POST', headers: {}, secret: '' },
+ enabled: true,
+ });
+ createdWebhookId = webhook.id;
+
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ // Click Edit on our webhook
+ const row = page.getByText('API Webhook').locator('..');
+ await row.getByRole('button', { name: 'Edit' }).click();
+
+ // Should be in edit view
+ await expect(page.getByDisplayValue('API Webhook')).toBeVisible();
+
+ // Change method to PUT
+ await page.locator('#fanout-webhook-method').selectOption('PUT');
+
+ // Save as disabled
+ await page.getByRole('button', { name: /Save as Disabled/i }).click();
+ await expect(page.getByText('Integration saved')).toBeVisible();
+
+ // Verify it's now disabled in the list
+ const configs = await getFanoutConfigs();
+ const updated = configs.find((c) => c.id === webhook.id);
+ expect(updated?.enabled).toBe(false);
+ expect(updated?.config.method).toBe('PUT');
+ });
+
+ test('webhook shows scope selector with channel/contact options', async ({ page }) => {
+ const webhook = await createFanoutConfig({
+ type: 'webhook',
+ name: 'Scope Webhook',
+ config: { url: 'https://example.com/hook', method: 'POST', headers: {}, secret: '' },
+ });
+ createdWebhookId = webhook.id;
+
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ // Click Edit
+ const row = page.getByText('Scope Webhook').locator('..');
+ await row.getByRole('button', { name: 'Edit' }).click();
+
+ // Verify scope selector is visible with all four modes
+ await expect(page.getByText('Message Scope')).toBeVisible();
+ await expect(page.getByText('All messages')).toBeVisible();
+ await expect(page.getByText('No messages')).toBeVisible();
+ await expect(page.getByText('Only listed channels/contacts')).toBeVisible();
+ await expect(page.getByText('All except listed channels/contacts')).toBeVisible();
+
+ // 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();
+
+ // Go back without saving
+ await page.getByText('← Back to list').click();
+ });
+
+ test('delete webhook via UI', async ({ page }) => {
+ const webhook = await createFanoutConfig({
+ type: 'webhook',
+ name: 'Delete Me Webhook',
+ config: { url: 'https://example.com/hook', method: 'POST', headers: {}, secret: '' },
+ });
+ createdWebhookId = webhook.id;
+
+ await page.goto('/');
+ await expect(page.getByText('Connected')).toBeVisible();
+
+ await page.getByText('Settings').click();
+ await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+
+ // Click Edit
+ const row = page.getByText('Delete Me Webhook').locator('..');
+ await row.getByRole('button', { name: 'Edit' }).click();
+
+ // Accept the confirmation dialog
+ page.on('dialog', (dialog) => dialog.accept());
+
+ // Click Delete
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ await expect(page.getByText('Integration deleted')).toBeVisible();
+
+ // Should be back on list, webhook gone
+ await expect(page.getByText('Delete Me Webhook')).not.toBeVisible();
+
+ // Already deleted, clear the cleanup reference
+ createdWebhookId = null;
+ });
+});
diff --git a/tests/e2e/specs/radio-settings.spec.ts b/tests/e2e/specs/zz-radio-settings.spec.ts
similarity index 100%
rename from tests/e2e/specs/radio-settings.spec.ts
rename to tests/e2e/specs/zz-radio-settings.spec.ts
diff --git a/tests/test_fanout.py b/tests/test_fanout.py
index 1407141c..f69f9ce6 100644
--- a/tests/test_fanout.py
+++ b/tests/test_fanout.py
@@ -672,6 +672,25 @@ class TestWebhookModule:
assert mod.status == "connected"
await mod.stop()
+ @pytest.mark.asyncio
+ async def test_does_not_skip_outgoing_messages(self):
+ """Webhook should forward outgoing messages (unlike Apprise)."""
+ from app.fanout.webhook import WebhookModule
+
+ mod = WebhookModule("test", {"url": "http://localhost:9999/hook"})
+ await mod.start()
+ # Mock the client to capture the request
+ sent_data: list[dict] = []
+
+ async def capture_send(data: dict, *, event_type: str) -> None:
+ sent_data.append(data)
+
+ mod._send = capture_send
+ await mod.on_message({"type": "PRIV", "text": "outgoing", "outgoing": True})
+ assert len(sent_data) == 1
+ assert sent_data[0]["outgoing"] is True
+ await mod.stop()
+
@pytest.mark.asyncio
async def test_dispatch_with_matching_scope(self):
"""WebhookModule dispatches through FanoutManager scope matching."""
@@ -883,3 +902,142 @@ class TestAppriseValidation:
scope = _enforce_scope("apprise", {"messages": "all", "raw_packets": "all"})
assert scope["raw_packets"] == "none"
assert scope["messages"] == "all"
+
+
+# ---------------------------------------------------------------------------
+# Comprehensive scope/filter selection logic tests
+# ---------------------------------------------------------------------------
+
+
+class TestMatchesFilter:
+ """Test _matches_filter directly for all filter shapes."""
+
+ def test_all_matches_any_key(self):
+ from app.fanout.manager import _matches_filter
+
+ assert _matches_filter("all", "anything")
+ assert _matches_filter("all", "")
+ assert _matches_filter("all", "special-chars-!@#")
+
+ def test_none_matches_nothing(self):
+ from app.fanout.manager import _matches_filter
+
+ assert not _matches_filter("none", "anything")
+ assert not _matches_filter("none", "")
+
+ def test_list_matches_present_key(self):
+ from app.fanout.manager import _matches_filter
+
+ assert _matches_filter(["a", "b", "c"], "b")
+
+ def test_list_no_match_absent_key(self):
+ from app.fanout.manager import _matches_filter
+
+ assert not _matches_filter(["a", "b"], "c")
+
+ def test_list_empty_matches_nothing(self):
+ from app.fanout.manager import _matches_filter
+
+ assert not _matches_filter([], "anything")
+
+ def test_except_excludes_listed(self):
+ from app.fanout.manager import _matches_filter
+
+ assert not _matches_filter({"except": ["blocked"]}, "blocked")
+
+ def test_except_includes_unlisted(self):
+ from app.fanout.manager import _matches_filter
+
+ assert _matches_filter({"except": ["blocked"]}, "allowed")
+
+ def test_except_empty_matches_everything(self):
+ from app.fanout.manager import _matches_filter
+
+ assert _matches_filter({"except": []}, "anything")
+ assert _matches_filter({"except": []}, "")
+
+ def test_except_multiple_excludes(self):
+ from app.fanout.manager import _matches_filter
+
+ filt = {"except": ["x", "y", "z"]}
+ assert not _matches_filter(filt, "x")
+ assert not _matches_filter(filt, "y")
+ assert not _matches_filter(filt, "z")
+ assert _matches_filter(filt, "a")
+
+ def test_unrecognized_shape_returns_false(self):
+ from app.fanout.manager import _matches_filter
+
+ assert not _matches_filter(42, "key")
+ assert not _matches_filter({"other": "thing"}, "key")
+ assert not _matches_filter(True, "key")
+
+
+class TestScopeMatchesMessageCombinations:
+ """Test _scope_matches_message with complex combinations."""
+
+ def test_channel_with_only_channels_listed(self):
+ scope = {"messages": {"channels": ["ch1", "ch2"], "contacts": "all"}}
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch2"})
+ assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch3"})
+
+ def test_contact_with_only_contacts_listed(self):
+ scope = {"messages": {"channels": "all", "contacts": ["pk1"]}}
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
+ assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk2"})
+
+ def test_mixed_channels_all_contacts_except(self):
+ scope = {"messages": {"channels": "all", "contacts": {"except": ["pk-blocked"]}}}
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk-ok"})
+ assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk-blocked"})
+
+ def test_channels_except_contacts_only(self):
+ scope = {
+ "messages": {
+ "channels": {"except": ["ch-muted"]},
+ "contacts": ["pk-friend"],
+ }
+ }
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch-ok"})
+ assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch-muted"})
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk-friend"})
+ assert not _scope_matches_message(
+ scope, {"type": "PRIV", "conversation_key": "pk-stranger"}
+ )
+
+ def test_both_channels_and_contacts_none(self):
+ scope = {"messages": {"channels": "none", "contacts": "none"}}
+ assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+ assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
+
+ def test_both_channels_and_contacts_all(self):
+ scope = {"messages": {"channels": "all", "contacts": "all"}}
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
+
+ def test_missing_contacts_key_defaults_false(self):
+ scope = {"messages": {"channels": "all"}}
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+ # Missing contacts -> defaults to "none" -> no match for DMs
+ assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
+
+ def test_missing_channels_key_defaults_false(self):
+ scope = {"messages": {"contacts": "all"}}
+ assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
+
+ def test_unknown_message_type_no_match(self):
+ scope = {"messages": {"channels": "all", "contacts": "all"}}
+ assert not _scope_matches_message(scope, {"type": "UNKNOWN", "conversation_key": "x"})
+
+ def test_both_except_empty_matches_everything(self):
+ scope = {
+ "messages": {
+ "channels": {"except": []},
+ "contacts": {"except": []},
+ }
+ }
+ assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
+ assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py
index fe2ad6c9..ea591b42 100644
--- a/tests/test_fanout_integration.py
+++ b/tests/test_fanout_integration.py
@@ -4,6 +4,8 @@ Spins up a minimal in-process MQTT 3.1.1 broker on a random port, creates
fanout configs in an in-memory DB, starts real MqttPrivateModule instances
via the FanoutManager, and verifies that PUBLISH packets arrive (or don't)
based on enabled/disabled state and scope settings.
+
+Also covers webhook and Apprise modules with real HTTP capture servers.
"""
import asyncio
@@ -835,3 +837,361 @@ class TestFanoutWebhookIntegration:
assert "yes" in texts
assert "dm yes" in texts
assert "nope" not in texts
+
+ @pytest.mark.asyncio
+ async def test_webhook_delivers_outgoing_messages(self, webhook_server, integration_db):
+ """Webhooks should deliver outgoing messages (unlike Apprise which skips them)."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Outgoing Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(
+ {
+ "type": "PRIV",
+ "conversation_key": "pk1",
+ "text": "outgoing msg",
+ "outgoing": True,
+ }
+ )
+
+ results = await webhook_server.wait_for(1)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) == 1
+ assert results[0]["body"]["text"] == "outgoing msg"
+ assert results[0]["body"]["outgoing"] is True
+
+
+# ---------------------------------------------------------------------------
+# Apprise integration tests (real HTTP capture server + real AppriseModule)
+# ---------------------------------------------------------------------------
+
+
+class AppriseJsonCaptureServer:
+ """Minimal HTTP server that captures JSON POSTs from Apprise's json:// plugin.
+
+ Apprise json:// sends POST with JSON body containing title, body, type fields.
+ """
+
+ def __init__(self):
+ self.received: list[dict] = []
+ self._server: asyncio.Server | None = None
+ self.port: int = 0
+
+ async def start(self) -> int:
+ self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
+ self.port = self._server.sockets[0].getsockname()[1]
+ return self.port
+
+ async def stop(self):
+ if self._server:
+ self._server.close()
+ await self._server.wait_closed()
+
+ async def wait_for(self, count: int, timeout: float = 10.0) -> list[dict]:
+ deadline = asyncio.get_event_loop().time() + timeout
+ while len(self.received) < count:
+ if asyncio.get_event_loop().time() >= deadline:
+ break
+ await asyncio.sleep(0.05)
+ return list(self.received)
+
+ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
+ try:
+ request_line = await reader.readline()
+ if not request_line:
+ return
+
+ headers: dict[str, str] = {}
+ while True:
+ line = await reader.readline()
+ if line in (b"\r\n", b"\n", b""):
+ break
+ decoded = line.decode("utf-8", errors="replace").strip()
+ if ":" in decoded:
+ key, val = decoded.split(":", 1)
+ headers[key.strip().lower()] = val.strip()
+
+ content_length = int(headers.get("content-length", "0"))
+ body = b""
+ if content_length > 0:
+ body = await reader.readexactly(content_length)
+
+ if body:
+ try:
+ payload = json.loads(body)
+ except Exception:
+ payload = {"_raw": body.decode("utf-8", errors="replace")}
+ self.received.append(payload)
+
+ response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"
+ writer.write(response)
+ await writer.drain()
+ except (asyncio.IncompleteReadError, ConnectionError, OSError):
+ pass
+ finally:
+ writer.close()
+
+
+@pytest.fixture
+async def apprise_capture_server():
+ server = AppriseJsonCaptureServer()
+ await server.start()
+ yield server
+ await server.stop()
+
+
+class TestFanoutAppriseIntegration:
+ """End-to-end: real HTTP capture server <-> real AppriseModule via json:// URL."""
+
+ @pytest.mark.asyncio
+ async def test_apprise_delivers_incoming_dm(self, apprise_capture_server, integration_db):
+ """Apprise module delivers incoming DMs via json:// to a real HTTP server."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="Test Apprise",
+ config={
+ "urls": f"json://127.0.0.1:{apprise_capture_server.port}",
+ "preserve_identity": True,
+ "include_path": False,
+ },
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ assert cfg["id"] in manager._modules
+
+ await manager.broadcast_message(
+ {
+ "type": "PRIV",
+ "conversation_key": "pk1",
+ "text": "hello from mesh",
+ "sender_name": "Alice",
+ "outgoing": False,
+ }
+ )
+
+ results = await apprise_capture_server.wait_for(1)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) >= 1
+ # Apprise json:// sends body field with the formatted message
+ body_text = str(results[0])
+ assert "Alice" in body_text
+ assert "hello from mesh" in body_text
+
+ @pytest.mark.asyncio
+ async def test_apprise_delivers_incoming_channel_msg(
+ self, apprise_capture_server, integration_db
+ ):
+ """Apprise module delivers incoming channel messages."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="Channel Apprise",
+ config={
+ "urls": f"json://127.0.0.1:{apprise_capture_server.port}",
+ "include_path": False,
+ },
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ assert cfg["id"] in manager._modules
+
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch1",
+ "channel_name": "#general",
+ "text": "channel hello",
+ "sender_name": "Bob",
+ "outgoing": False,
+ }
+ )
+
+ results = await apprise_capture_server.wait_for(1)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) >= 1
+ body_text = str(results[0])
+ assert "Bob" in body_text
+ assert "channel hello" in body_text
+ assert "#general" in body_text
+
+ @pytest.mark.asyncio
+ async def test_apprise_skips_outgoing(self, apprise_capture_server, integration_db):
+ """Apprise should NOT deliver outgoing messages."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="No Outgoing",
+ config={
+ "urls": f"json://127.0.0.1:{apprise_capture_server.port}",
+ },
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ assert cfg["id"] in manager._modules
+
+ await manager.broadcast_message(
+ {
+ "type": "PRIV",
+ "conversation_key": "pk1",
+ "text": "my outgoing",
+ "sender_name": "Me",
+ "outgoing": True,
+ }
+ )
+
+ await asyncio.sleep(1.0)
+ finally:
+ await manager.stop_all()
+
+ assert len(apprise_capture_server.received) == 0
+
+ @pytest.mark.asyncio
+ async def test_apprise_disabled_no_delivery(self, apprise_capture_server, integration_db):
+ """Disabled Apprise module should not deliver anything."""
+ await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="Disabled Apprise",
+ config={
+ "urls": f"json://127.0.0.1:{apprise_capture_server.port}",
+ },
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=False,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ assert len(manager._modules) == 0
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "nope"}
+ )
+ await asyncio.sleep(0.5)
+ finally:
+ await manager.stop_all()
+
+ assert len(apprise_capture_server.received) == 0
+
+ @pytest.mark.asyncio
+ async def test_apprise_scope_selective_channels(self, apprise_capture_server, integration_db):
+ """Apprise with selective channel scope only delivers matching channels."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="Selective Apprise",
+ config={
+ "urls": f"json://127.0.0.1:{apprise_capture_server.port}",
+ "include_path": False,
+ },
+ scope={
+ "messages": {"channels": ["ch-yes"], "contacts": "none"},
+ "raw_packets": "none",
+ },
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ assert cfg["id"] in manager._modules
+
+ # Matching channel
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch-yes",
+ "channel_name": "#yes",
+ "text": "included",
+ "sender_name": "A",
+ }
+ )
+ # Non-matching channel
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch-no",
+ "channel_name": "#no",
+ "text": "excluded",
+ "sender_name": "B",
+ }
+ )
+ # DM — contacts is "none"
+ await manager.broadcast_message(
+ {
+ "type": "PRIV",
+ "conversation_key": "pk1",
+ "text": "dm excluded",
+ "sender_name": "C",
+ }
+ )
+
+ await apprise_capture_server.wait_for(1)
+ await asyncio.sleep(1.0)
+ finally:
+ await manager.stop_all()
+
+ assert len(apprise_capture_server.received) == 1
+ body_text = str(apprise_capture_server.received[0])
+ assert "included" in body_text
+
+ @pytest.mark.asyncio
+ async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
+ """Apprise with include_path=True shows routing hops in the body."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="Path Apprise",
+ config={
+ "urls": f"json://127.0.0.1:{apprise_capture_server.port}",
+ "include_path": True,
+ },
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ assert cfg["id"] in manager._modules
+
+ await manager.broadcast_message(
+ {
+ "type": "PRIV",
+ "conversation_key": "pk1",
+ "text": "routed msg",
+ "sender_name": "Eve",
+ "paths": [{"path": "2a3b"}],
+ }
+ )
+
+ results = await apprise_capture_server.wait_for(1)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) >= 1
+ body_text = str(results[0])
+ assert "Eve" in body_text
+ assert "routed msg" in body_text
From adfb4addb7a8f67276749fc2deefa5698509015f Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 21:21:08 -0800
Subject: [PATCH 07/28] Add MQTT removal migration and fix tests + docs
---
AGENTS.md | 41 +--
app/AGENTS.md | 46 +--
app/AGENTS_MQTT.md | 377 ----------------------
app/fanout/AGENTS_fanout.md | 38 ++-
app/fanout/bot.py | 8 +-
app/{bot.py => fanout/bot_exec.py} | 96 ------
app/{ => fanout}/community_mqtt.py | 55 ++--
app/{ => fanout}/mqtt.py | 43 ++-
app/{ => fanout}/mqtt_base.py | 21 +-
app/fanout/mqtt_community.py | 10 +-
app/fanout/mqtt_private.py | 11 +-
app/migrations.py | 56 ++++
app/models.py | 71 +---
app/repository/settings.py | 116 +------
frontend/src/test/appFavorites.test.tsx | 1 -
frontend/src/test/appSearchJump.test.tsx | 1 -
frontend/src/test/appStartupHash.test.tsx | 1 -
frontend/src/test/settingsModal.test.tsx | 15 -
frontend/src/types.ts | 22 --
tests/e2e/specs/apprise.spec.ts | 8 +-
tests/e2e/specs/webhook.spec.ts | 9 +-
tests/test_ack_tracking_wiring.py | 4 -
tests/test_bot.py | 375 ++-------------------
tests/test_community_mqtt.py | 86 ++---
tests/test_disable_bots.py | 53 +--
tests/test_fanout.py | 255 ---------------
tests/test_fanout_integration.py | 12 +-
tests/test_migrations.py | 90 +++---
tests/test_mqtt.py | 45 +--
tests/test_repository.py | 16 -
30 files changed, 352 insertions(+), 1630 deletions(-)
delete mode 100644 app/AGENTS_MQTT.md
rename app/{bot.py => fanout/bot_exec.py} (72%)
rename app/{ => fanout}/community_mqtt.py (91%)
rename app/{ => fanout}/mqtt.py (59%)
rename app/{ => fanout}/mqtt_base.py (92%)
diff --git a/AGENTS.md b/AGENTS.md
index 94f832c4..8d6e5537 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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:` — decrypted DM
-- `meshcore/gm:` — decrypted channel message
-- `meshcore/raw/dm:` — raw packet attributed to a DM contact
-- `meshcore/raw/gm:` — 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.
diff --git a/app/AGENTS.md b/app/AGENTS.md
index 9820ae10..dd94b7dc 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -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
diff --git a/app/AGENTS_MQTT.md b/app/AGENTS_MQTT.md
deleted file mode 100644
index b2299933..00000000
--- a/app/AGENTS_MQTT.md
+++ /dev/null
@@ -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.
diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md
index 9e66d56f..84e3766d 100644
--- a/app/fanout/AGENTS_fanout.md
+++ b/app/fanout/AGENTS_fanout.md
@@ -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
diff --git a/app/fanout/bot.py b/app/fanout/bot.py
index 715b9adc..696448cc 100644
--- a/app/fanout/bot.py
+++ b/app/fanout/bot.py
@@ -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()
diff --git a/app/bot.py b/app/fanout/bot_exec.py
similarity index 72%
rename from app/bot.py
rename to app/fanout/bot_exec.py
index a1fcbfea..e50e2c34 100644
--- a/app/bot.py
+++ b/app/fanout/bot_exec.py
@@ -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)
diff --git a/app/community_mqtt.py b/app/fanout/community_mqtt.py
similarity index 91%
rename from app/community_mqtt.py
rename to app/fanout/community_mqtt.py
index ba9836f0..fa73ee50 100644
--- a/app/community_mqtt.py
+++ b/app/fanout/community_mqtt.py
@@ -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()
diff --git a/app/mqtt.py b/app/fanout/mqtt.py
similarity index 59%
rename from app/mqtt.py
rename to app/fanout/mqtt.py
index fb96989c..c2965a4a 100644
--- a/app/mqtt.py
+++ b/app/fanout/mqtt.py
@@ -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
diff --git a/app/mqtt_base.py b/app/fanout/mqtt_base.py
similarity index 92%
rename from app/mqtt_base.py
rename to app/fanout/mqtt_base.py
index ead961fc..1427b5df 100644
--- a/app/mqtt_base.py
+++ b/app/fanout/mqtt_base.py
@@ -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.
diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py
index 0cd5590e..f470cd15 100644
--- a/app/fanout/mqtt_community.py
+++ b/app/fanout/mqtt_community.py
@@ -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),
diff --git a/app/fanout/mqtt_private.py b/app/fanout/mqtt_private.py
index b016282f..9b2905ae 100644
--- a/app/fanout/mqtt_private.py
+++ b/app/fanout/mqtt_private.py
@@ -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,
)
diff --git a/app/migrations.py b/app/migrations.py
index 28805b12..a13c1254 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -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()
diff --git a/app/models.py b/app/models.py
index 43bfabb8..ccf615df 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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
diff --git a/app/repository/settings.py b/app/repository/settings.py
index 2914647c..351b910e 100644
--- a/app/repository/settings.py
+++ b/app/repository/settings.py
@@ -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)
diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx
index 3b125dc0..6bc26309 100644
--- a/frontend/src/test/appFavorites.test.tsx
+++ b/frontend/src/test/appFavorites.test.tsx
@@ -185,7 +185,6 @@ const baseSettings = {
preferences_migrated: false,
advert_interval: 0,
last_advert_time: 0,
- bots: [],
};
const publicChannel = {
diff --git a/frontend/src/test/appSearchJump.test.tsx b/frontend/src/test/appSearchJump.test.tsx
index bb942d2d..dd4075f3 100644
--- a/frontend/src/test/appSearchJump.test.tsx
+++ b/frontend/src/test/appSearchJump.test.tsx
@@ -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([
diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx
index a4f65664..c738a68c 100644
--- a/frontend/src/test/appStartupHash.test.tsx
+++ b/frontend/src/test/appStartupHash.test.tsx
@@ -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]);
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index c6f679b4..65c3fae4 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -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: [],
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index e565d610..06c1c479 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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[];
diff --git a/tests/e2e/specs/apprise.spec.ts b/tests/e2e/specs/apprise.spec.ts
index c9dc4e7d..1fc1303c 100644
--- a/tests/e2e/specs/apprise.spec.ts
+++ b/tests/e2e/specs/apprise.spec.ts
@@ -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();
diff --git a/tests/e2e/specs/webhook.spec.ts b/tests/e2e/specs/webhook.spec.ts
index 1f9f3c98..06422494 100644
--- a/tests/e2e/specs/webhook.spec.ts
+++ b/tests/e2e/specs/webhook.spec.ts
@@ -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();
diff --git a/tests/test_ack_tracking_wiring.py b/tests/test_ack_tracking_wiring.py
index c4e6a5d1..0042200b 100644
--- a/tests/test_ack_tracking_wiring.py
+++ b/tests/test_ack_tracking_wiring.py
@@ -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)
diff --git a/tests/test_bot.py b/tests/test_bot.py
index 68aef4a4..ccc604b1 100644
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -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"),
):
diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py
index 27c1a08b..23d7ca18 100644
--- a/tests/test_community_mqtt.py
+++ b/tests/test_community_mqtt.py
@@ -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:
diff --git a/tests/test_disable_bots.py b/tests/test_disable_bots.py
index 8638eb30..6f0d0ffb 100644
--- a/tests/test_disable_bots.py
+++ b/tests/test_disable_bots.py
@@ -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."""
diff --git a/tests/test_fanout.py b/tests/test_fanout.py
index f69f9ce6..ea0ada93 100644
--- a/tests/test_fanout.py
+++ b/tests/test_fanout.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py
index ea591b42..273e5aa3 100644
--- a/tests/test_fanout_integration.py
+++ b/tests/test_fanout_integration.py
@@ -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"),
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 0097f0a0..04e0ae8f 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -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 = ?",
diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py
index d17ee499..66c7ad58 100644
--- a/tests/test_mqtt.py
+++ b/tests/test_mqtt.py
@@ -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:
diff --git a/tests/test_repository.py b/tests/test_repository.py
index b8ccda7f..baaf882e 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -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
From 7534f0cc54d4d8125dc6adbe06a74e79c1836b1e Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 21:56:10 -0800
Subject: [PATCH 08/28] Patch smome doc issues and do minor bug mop up
(outgoing bot message flaggin, unprotected bot endpoint, contact filtering on
scope selection, don't drop disabled but configured community endpoints
---
AGENTS.md | 8 +++--
LICENSES.md | 35 +++++++++++++++++++
app/AGENTS.md | 6 ++++
app/fanout/bot.py | 2 +-
app/migrations.py | 24 +++++++++----
app/routers/fanout.py | 3 ++
frontend/AGENTS.md | 11 +++---
.../settings/SettingsFanoutSection.tsx | 11 ++++--
8 files changed, 82 insertions(+), 18 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 8d6e5537..0c09b6f5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -75,7 +75,7 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
- Raw packet feed — a debug/observation tool ("radio aquarium"); interesting to watch or copy packets from, but not critical infrastructure
- Map view — visual display of node locations from advertisements
- Network visualizer — force-directed graph of mesh topology
-- Bot system — automated message responses
+- Fanout integrations (MQTT, bots, webhooks, Apprise) — see `app/fanout/AGENTS_fanout.md`
- Read state tracking / mark-all-read — convenience feature for unread badges; no need for transactional atomicity or race-condition hardening
## Error Handling Philosophy
@@ -259,7 +259,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
-| GET | `/api/health` | Connection status |
+| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
| GET | `/api/radio/config` | Radio configuration |
| PATCH | `/api/radio/config` | Update name, location, radio params |
| PUT | `/api/radio/private-key` | Import private key to radio |
@@ -312,6 +312,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
+| GET | `/api/fanout` | List all fanout configs |
+| POST | `/api/fanout` | Create new fanout config |
+| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
+| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
| GET | `/api/statistics` | Aggregated mesh network statistics |
| WS | `/api/ws` | Real-time updates |
diff --git a/LICENSES.md b/LICENSES.md
index 12419d56..965eee3e 100644
--- a/LICENSES.md
+++ b/LICENSES.md
@@ -56,6 +56,41 @@ SOFTWARE.
+### apprise (1.9.7) — BSD-2-Clause
+
+
+Full license text
+
+```
+BSD 2-Clause License
+
+Copyright (c) 2025, Chris Caron
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+
+
### fastapi (0.128.0) — MIT
diff --git a/app/AGENTS.md b/app/AGENTS.md
index dd94b7dc..50d083c1 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -179,6 +179,12 @@ app/
- `POST /settings/blocked-names/toggle`
- `POST /settings/migrate`
+### Fanout
+- `GET /fanout` — list all fanout configs
+- `POST /fanout` — create new fanout config
+- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
+- `DELETE /fanout/{id}` — delete fanout config (stops module)
+
### Statistics
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
diff --git a/app/fanout/bot.py b/app/fanout/bot.py
index 696448cc..ac6c9914 100644
--- a/app/fanout/bot.py
+++ b/app/fanout/bot.py
@@ -58,7 +58,7 @@ class BotModule(FanoutModule):
else:
conversation_key = data.get("conversation_key", "")
sender_key = None
- is_outgoing = False
+ is_outgoing = bool(data.get("outgoing", False))
sender_name = data.get("sender_name")
channel_key = conversation_key
diff --git a/app/migrations.py b/app/migrations.py
index a13c1254..bc5d869d 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -2129,14 +2129,22 @@ async def _migrate_036_create_fanout_configs(conn: aiosqlite.Connection) -> None
sort_order += 1
logger.info("Migrated private MQTT settings to fanout_configs (enabled=%s)", enabled)
- # 4. Migrate community MQTT if enabled
+ # 4. Migrate community MQTT if enabled OR configured (preserve disabled-but-configured)
community_enabled = bool(row["community_mqtt_enabled"])
- if community_enabled:
+ community_iata = row["community_mqtt_iata"] or ""
+ community_host = row["community_mqtt_broker_host"] or ""
+ community_email = row["community_mqtt_email"] or ""
+ community_has_config = bool(
+ community_iata
+ or community_email
+ or (community_host and community_host != "mqtt-us-v1.letsmesh.net")
+ )
+ if community_enabled or community_has_config:
config = {
- "broker_host": row["community_mqtt_broker_host"] or "mqtt-us-v1.letsmesh.net",
+ "broker_host": community_host or "mqtt-us-v1.letsmesh.net",
"broker_port": row["community_mqtt_broker_port"] or 443,
- "iata": row["community_mqtt_iata"] or "",
- "email": row["community_mqtt_email"] or "",
+ "iata": community_iata,
+ "email": community_email,
}
scope = {
@@ -2153,14 +2161,16 @@ async def _migrate_036_create_fanout_configs(conn: aiosqlite.Connection) -> None
str(uuid.uuid4()),
"mqtt_community",
"Community MQTT",
- 1,
+ 1 if community_enabled else 0,
json.dumps(config),
json.dumps(scope),
sort_order,
now,
),
)
- logger.info("Migrated community MQTT settings to fanout_configs")
+ logger.info(
+ "Migrated community MQTT settings to fanout_configs (enabled=%s)", community_enabled
+ )
await conn.commit()
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
index 3a0928a1..a4dc357f 100644
--- a/app/routers/fanout.py
+++ b/app/routers/fanout.py
@@ -167,6 +167,9 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
if existing is None:
raise HTTPException(status_code=404, detail="Fanout config not found")
+ if existing["type"] == "bot" and server_settings.disable_bots:
+ raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
+
kwargs = {}
if body.name is not None:
kwargs["name"] = body.name
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 407e803e..095bd53c 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -241,15 +241,14 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `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`
-`HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`).
-`HealthStatus` also includes `community_mqtt_status` with the same status values.
+Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
+
+`HealthStatus` includes `fanout_statuses: Record` mapping config IDs to `{name, type, status}`. Also includes `bots_disabled: boolean`.
+
+`FanoutConfig` represents a single fanout integration: `{id, type, name, enabled, config, scope, sort_order, created_at}`.
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 5d2d57aa..bac45606 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -477,8 +477,8 @@ function ScopeSelector({
onChange({ ...scope, messages: buildMessages(selectedChannels, current) });
};
- // Non-repeater contacts only (type 0)
- const filteredContacts = contacts.filter((c) => c.type === 0);
+ // Exclude repeaters (2), rooms (3), and sensors (4)
+ const filteredContacts = contacts.filter((c) => c.type === 0 || c.type === 1);
const modeDescriptions: Record = {
all: 'All messages',
@@ -1045,6 +1045,13 @@ export function SettingsFanoutSection({
Integrations are an experimental feature in open beta.
+ {health?.bots_disabled && (
+
+ Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations
+ cannot be created or modified.
+
+ )}
+
Add:
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map((opt) => (
From cb4333df4f9a6577c40fcf428571a539a4bb502f Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 22:27:24 -0800
Subject: [PATCH 09/28] Fanout hitlist fixes: bugs, quality, tests, webhook
HMAC signing
---
app/fanout/apprise_mod.py | 4 +-
app/fanout/base.py | 3 +-
app/fanout/bot.py | 7 +-
app/fanout/manager.py | 5 +-
app/fanout/mqtt_community.py | 4 +-
app/fanout/mqtt_private.py | 4 +-
app/fanout/webhook.py | 21 +-
app/routers/fanout.py | 4 +-
.../settings/SettingsFanoutSection.tsx | 45 +-
frontend/src/test/fanoutSection.test.tsx | 156 +++++
tests/e2e/specs/webhook.spec.ts | 10 +-
tests/test_fanout_hitlist.py | 563 ++++++++++++++++++
tests/test_fanout_integration.py | 68 ++-
13 files changed, 840 insertions(+), 54 deletions(-)
create mode 100644 frontend/src/test/fanoutSection.test.tsx
create mode 100644 tests/test_fanout_hitlist.py
diff --git a/app/fanout/apprise_mod.py b/app/fanout/apprise_mod.py
index a94821bc..0cbf31d8 100644
--- a/app/fanout/apprise_mod.py
+++ b/app/fanout/apprise_mod.py
@@ -88,8 +88,8 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
class AppriseModule(FanoutModule):
"""Sends push notifications via Apprise for incoming messages."""
- def __init__(self, config_id: str, config: dict) -> None:
- super().__init__(config_id, config)
+ def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
+ super().__init__(config_id, config, name=name)
self._last_error: str | None = None
async def on_message(self, data: dict) -> None:
diff --git a/app/fanout/base.py b/app/fanout/base.py
index 9aa4acbb..f0af94c0 100644
--- a/app/fanout/base.py
+++ b/app/fanout/base.py
@@ -12,9 +12,10 @@ class FanoutModule:
Subclasses must override the ``status`` property.
"""
- def __init__(self, config_id: str, config: dict) -> None:
+ def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
self.config_id = config_id
self.config = config
+ self.name = name
async def start(self) -> None:
"""Start the module (e.g. connect to broker). Override for persistent connections."""
diff --git a/app/fanout/bot.py b/app/fanout/bot.py
index ac6c9914..f46ea25c 100644
--- a/app/fanout/bot.py
+++ b/app/fanout/bot.py
@@ -20,8 +20,7 @@ class BotModule(FanoutModule):
"""
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
- super().__init__(config_id, config)
- self._name = name
+ super().__init__(config_id, config, name=name)
async def on_message(self, data: dict) -> None:
"""Kick off bot execution in a background task so we don't block dispatch."""
@@ -110,10 +109,10 @@ class BotModule(FanoutModule):
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
- logger.warning("Bot '%s' execution timed out", self._name)
+ logger.warning("Bot '%s' execution timed out", self.name)
return
except Exception as e:
- logger.warning("Bot '%s' execution error: %s", self._name, e)
+ logger.warning("Bot '%s' execution error: %s", self.name, e)
return
if response:
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
index 23cda0e2..c0833604 100644
--- a/app/fanout/manager.py
+++ b/app/fanout/manager.py
@@ -108,10 +108,7 @@ class FanoutManager:
return
try:
- if config_type == "bot":
- module = cls(config_id, config_blob, name=cfg.get("name", "Bot"))
- else:
- module = cls(config_id, config_blob)
+ module = cls(config_id, config_blob, name=cfg.get("name", ""))
await module.start()
self._modules[config_id] = (module, scope)
logger.info(
diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py
index f470cd15..982efa61 100644
--- a/app/fanout/mqtt_community.py
+++ b/app/fanout/mqtt_community.py
@@ -29,8 +29,8 @@ def _config_to_settings(config: dict) -> SimpleNamespace:
class MqttCommunityModule(FanoutModule):
"""Wraps a CommunityMqttPublisher for community packet sharing."""
- def __init__(self, config_id: str, config: dict) -> None:
- super().__init__(config_id, config)
+ def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
+ super().__init__(config_id, config, name=name)
self._publisher = CommunityMqttPublisher()
async def start(self) -> None:
diff --git a/app/fanout/mqtt_private.py b/app/fanout/mqtt_private.py
index 9b2905ae..2169589a 100644
--- a/app/fanout/mqtt_private.py
+++ b/app/fanout/mqtt_private.py
@@ -29,8 +29,8 @@ def _config_to_settings(config: dict) -> SimpleNamespace:
class MqttPrivateModule(FanoutModule):
"""Wraps an MqttPublisher instance for private MQTT forwarding."""
- def __init__(self, config_id: str, config: dict) -> None:
- super().__init__(config_id, config)
+ def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
+ super().__init__(config_id, config, name=name)
self._publisher = MqttPublisher()
async def start(self) -> None:
diff --git a/app/fanout/webhook.py b/app/fanout/webhook.py
index 4f9a798e..536c6d07 100644
--- a/app/fanout/webhook.py
+++ b/app/fanout/webhook.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import hashlib
+import hmac
import logging
import httpx
@@ -14,8 +16,8 @@ logger = logging.getLogger(__name__)
class WebhookModule(FanoutModule):
"""Delivers message data to an HTTP endpoint via POST (or configurable method)."""
- def __init__(self, config_id: str, config: dict) -> None:
- super().__init__(config_id, config)
+ def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
+ super().__init__(config_id, config, name=name)
self._client: httpx.AsyncClient | None = None
self._last_error: str | None = None
@@ -44,18 +46,25 @@ class WebhookModule(FanoutModule):
method = self.config.get("method", "POST").upper()
extra_headers = self.config.get("headers", {})
- secret = self.config.get("secret", "")
+ hmac_secret = self.config.get("hmac_secret", "")
+ hmac_header = self.config.get("hmac_header", "X-Webhook-Signature")
headers = {
"Content-Type": "application/json",
"X-Webhook-Event": event_type,
**extra_headers,
}
- if secret:
- headers["X-Webhook-Secret"] = secret
+
+ import json as _json
+
+ body_bytes = _json.dumps(data, separators=(",", ":"), sort_keys=True).encode()
+
+ if hmac_secret:
+ sig = hmac.new(hmac_secret.encode(), body_bytes, hashlib.sha256).hexdigest()
+ headers[hmac_header or "X-Webhook-Signature"] = f"sha256={sig}"
try:
- resp = await self._client.request(method, url, json=data, headers=headers)
+ resp = await self._client.request(method, url, content=body_bytes, headers=headers)
resp.raise_for_status()
self._last_error = None
except httpx.HTTPStatusError as exc:
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
index a4dc357f..e3826c9a 100644
--- a/app/routers/fanout.py
+++ b/app/routers/fanout.py
@@ -44,10 +44,10 @@ def _validate_mqtt_private_config(config: dict) -> None:
def _validate_mqtt_community_config(config: dict) -> None:
"""Validate mqtt_community config blob."""
iata = config.get("iata", "")
- if iata and not _IATA_RE.fullmatch(iata.upper().strip()):
+ if not iata or not _IATA_RE.fullmatch(iata.upper().strip()):
raise HTTPException(
status_code=400,
- detail="IATA code must be exactly 3 uppercase alphabetic characters",
+ detail="IATA code is required and must be exactly 3 uppercase alphabetic characters",
)
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index bac45606..baf1ecc1 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -777,19 +777,43 @@ function WebhookConfigEditor({
PATCH
+
-
-
Secret (optional)
-
onChange({ ...config, secret: e.target.value })}
- />
+
+
+
+
HMAC Signing
+
+ When a secret is set, each request includes an HMAC-SHA256 signature of the JSON body in
+ the specified header (e.g. sha256=ab12cd...
+ ).
+
+
+
+ HMAC Secret
+ onChange({ ...config, hmac_secret: e.target.value })}
+ />
+
+
+ Signature Header Name
+ onChange({ ...config, hmac_header: e.target.value })}
+ />
+
+
+
);
}
@@ -398,9 +374,11 @@ function getFilterKeys(filter: unknown): string[] {
function ScopeSelector({
scope,
onChange,
+ showRawPackets = false,
}: {
scope: Record
;
onChange: (scope: Record) => void;
+ showRawPackets?: boolean;
}) {
const [channels, setChannels] = useState([]);
const [contacts, setContacts] = useState([]);
@@ -425,7 +403,9 @@ function ScopeSelector({
}, []);
const messages = scope.messages ?? 'all';
- const mode = getScopeMode(messages);
+ const rawMode = getScopeMode(messages);
+ // When raw packets aren't offered, "none" is not a valid choice — treat as "all"
+ const mode = !showRawPackets && rawMode === 'none' ? 'all' : rawMode;
const isListMode = mode === 'only' || mode === 'except';
const selectedChannels: string[] =
@@ -487,6 +467,19 @@ function ScopeSelector({
except: 'All except listed channels/contacts',
};
+ const rawEnabled = showRawPackets && scope.raw_packets === 'all';
+
+ // Warn when the effective scope matches nothing
+ const messagesEffectivelyNone =
+ mode === 'none' ||
+ (mode === 'only' && selectedChannels.length === 0 && selectedContacts.length === 0) ||
+ (mode === 'except' &&
+ channels.length > 0 &&
+ filteredContacts.length > 0 &&
+ selectedChannels.length >= channels.length &&
+ selectedContacts.length >= filteredContacts.length);
+ const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled;
+
// For "except" mode, checked means the item is in the exclusion list (will be excluded)
const isChannelChecked = (key: string) =>
mode === 'except' ? selectedChannels.includes(key) : selectedChannels.includes(key);
@@ -500,11 +493,28 @@ function ScopeSelector({
const checkboxLabel = mode === 'except' ? 'exclude' : 'include';
+ const messageModes: ScopeMode[] = showRawPackets
+ ? ['all', 'none', 'only', 'except']
+ : ['all', 'only', 'except'];
+
return (
Message Scope
+
+ {showRawPackets && (
+
+ onChange({ ...scope, raw_packets: e.target.checked ? 'all' : 'none' })}
+ className="h-4 w-4 rounded border-border"
+ />
+ Forward raw packets
+
+ )}
+
- {(['all', 'none', 'only', 'except'] as const).map((m) => (
+ {messageModes.map((m) => (
+ {showEmptyScopeWarning && (
+
+ Nothing is selected — this integration will not forward any data.
+
+ )}
+
{isListMode && (
<>
{listHint}
diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx
index c93c88cd..d5b79e93 100644
--- a/frontend/src/test/fanoutSection.test.tsx
+++ b/frontend/src/test/fanoutSection.test.tsx
@@ -125,6 +125,118 @@ describe('SettingsFanoutSection', () => {
});
});
+ it('webhook with persisted "none" scope renders "All messages" selected', async () => {
+ const wh: FanoutConfig = {
+ ...webhookConfig,
+ scope: { messages: 'none', raw_packets: 'none' },
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ // "none" is not a valid mode without raw packets — should fall back to "all"
+ const allRadio = screen.getByLabelText('All messages');
+ expect(allRadio).toBeChecked();
+ });
+
+ it('does not show "No messages" scope option for webhook', async () => {
+ const wh: FanoutConfig = {
+ ...webhookConfig,
+ scope: { messages: 'all', raw_packets: 'none' },
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.getByText('All messages')).toBeInTheDocument();
+ expect(screen.queryByText('No messages')).not.toBeInTheDocument();
+ });
+
+ it('shows empty scope warning when "only" mode has nothing selected', async () => {
+ const wh: FanoutConfig = {
+ ...webhookConfig,
+ scope: { messages: { channels: [], contacts: [] }, raw_packets: 'none' },
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.getByText(/will not forward any data/)).toBeInTheDocument();
+ });
+
+ it('shows warning for private MQTT when both scope axes are off', async () => {
+ const mqtt: FanoutConfig = {
+ id: 'mqtt-1',
+ type: 'mqtt_private',
+ name: 'My MQTT',
+ enabled: true,
+ config: { broker_host: 'localhost', broker_port: 1883 },
+ scope: { messages: 'none', raw_packets: 'none' },
+ sort_order: 0,
+ created_at: 1000,
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.getByText(/will not forward any data/)).toBeInTheDocument();
+ });
+
+ it('private MQTT shows raw packets toggle and No messages option', async () => {
+ const mqtt: FanoutConfig = {
+ id: 'mqtt-1',
+ type: 'mqtt_private',
+ name: 'My MQTT',
+ enabled: true,
+ config: { broker_host: 'localhost', broker_port: 1883 },
+ scope: { messages: 'all', raw_packets: 'all' },
+ sort_order: 0,
+ created_at: 1000,
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.getByText('Forward raw packets')).toBeInTheDocument();
+ expect(screen.getByText('No messages')).toBeInTheDocument();
+ });
+
+ it('private MQTT hides warning when raw packets enabled but messages off', async () => {
+ const mqtt: FanoutConfig = {
+ id: 'mqtt-1',
+ type: 'mqtt_private',
+ name: 'My MQTT',
+ enabled: true,
+ config: { broker_host: 'localhost', broker_port: 1883 },
+ scope: { messages: 'none', raw_packets: 'all' },
+ sort_order: 0,
+ created_at: 1000,
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.queryByText(/will not forward any data/)).not.toBeInTheDocument();
+ });
+
it('navigates to create view when clicking add button', async () => {
const createdWebhook: FanoutConfig = {
id: 'wh-new',
From e72c3abd7fdac2dd2c4a01c23fc8881212c649ad Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 23:46:35 -0800
Subject: [PATCH 13/28] Correct sender name and use non-deprecated loop getter
---
app/fanout/bot.py | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/app/fanout/bot.py b/app/fanout/bot.py
index f46ea25c..36c49e93 100644
--- a/app/fanout/bot.py
+++ b/app/fanout/bot.py
@@ -49,11 +49,14 @@ class BotModule(FanoutModule):
channel_key = None
channel_name = None
- # Look up sender name from contacts
- from app.repository import ContactRepository
+ # Outgoing DMs: sender is us, not the contact
+ if is_outgoing:
+ sender_name = None
+ else:
+ from app.repository import ContactRepository
- contact = await ContactRepository.get_by_key(conversation_key)
- sender_name = contact.name if contact else None
+ contact = await ContactRepository.get_by_key(conversation_key)
+ sender_name = contact.name if contact else None
else:
conversation_key = data.get("conversation_key", "")
sender_key = None
@@ -89,7 +92,7 @@ class BotModule(FanoutModule):
from app.fanout.bot_exec import _bot_executor, _bot_semaphore
async with _bot_semaphore:
- loop = asyncio.get_event_loop()
+ loop = asyncio.get_running_loop()
try:
response = await asyncio.wait_for(
loop.run_in_executor(
From 22e28a9e5b35e58fc33e93fb8d0749bac7caef96 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 23:46:55 -0800
Subject: [PATCH 14/28] Add min length to name, 400 on unknown scope, normalize
IATA
---
app/routers/fanout.py | 24 +++++++++++++++++-------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
index e3826c9a..476c956a 100644
--- a/app/routers/fanout.py
+++ b/app/routers/fanout.py
@@ -26,7 +26,7 @@ class FanoutConfigCreate(BaseModel):
class FanoutConfigUpdate(BaseModel):
- name: str | None = Field(default=None, description="Updated label")
+ name: str | None = Field(default=None, min_length=1, description="Updated label")
config: dict | None = Field(default=None, description="Updated config blob")
scope: dict | None = Field(default=None, description="Updated scope controls")
enabled: bool | None = Field(default=None, description="Enable/disable toggle")
@@ -42,13 +42,14 @@ def _validate_mqtt_private_config(config: dict) -> None:
def _validate_mqtt_community_config(config: dict) -> None:
- """Validate mqtt_community config blob."""
- iata = config.get("iata", "")
- if not iata or not _IATA_RE.fullmatch(iata.upper().strip()):
+ """Validate mqtt_community config blob. Normalizes IATA to uppercase."""
+ iata = config.get("iata", "").upper().strip()
+ if not iata or not _IATA_RE.fullmatch(iata):
raise HTTPException(
status_code=400,
detail="IATA code is required and must be exactly 3 uppercase alphabetic characters",
)
+ config["iata"] = iata
def _validate_bot_config(config: dict) -> None:
@@ -96,15 +97,24 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
if config_type in ("webhook", "apprise"):
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
- messages = "all"
+ raise HTTPException(
+ status_code=400,
+ detail="scope.messages must be 'all', 'none', or a filter object",
+ )
return {"messages": messages, "raw_packets": "none"}
# For mqtt_private, validate scope values
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
- messages = "all"
+ raise HTTPException(
+ status_code=400,
+ detail="scope.messages must be 'all', 'none', or a filter object",
+ )
raw_packets = scope.get("raw_packets", "all")
if raw_packets not in ("all", "none"):
- raw_packets = "all"
+ raise HTTPException(
+ status_code=400,
+ detail="scope.raw_packets must be 'all' or 'none'",
+ )
return {"messages": messages, "raw_packets": raw_packets}
From 4d15c7d8948a491b5ffdbcbc5d529f958368625c Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 23:47:08 -0800
Subject: [PATCH 15/28] Add per-config id lock to reload and remove stale
comment
---
app/fanout/manager.py | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
index 644b643b..b58041bd 100644
--- a/app/fanout/manager.py
+++ b/app/fanout/manager.py
@@ -11,7 +11,7 @@ from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
_DISPATCH_TIMEOUT_SECONDS = 30.0
-# Type string -> module class mapping (extended in Phase 2/3)
+# Type string -> module class mapping
_MODULE_TYPES: dict[str, type] = {}
@@ -122,14 +122,16 @@ class FanoutManager:
async def reload_config(self, config_id: str) -> None:
"""Stop old module (if any) and start updated config."""
- await self.remove_config(config_id)
+ lock = self._restart_locks.setdefault(config_id, asyncio.Lock())
+ async with lock:
+ await self.remove_config(config_id)
- from app.repository.fanout import FanoutConfigRepository
+ from app.repository.fanout import FanoutConfigRepository
- cfg = await FanoutConfigRepository.get(config_id)
- if cfg is None or not cfg["enabled"]:
- return
- await self._start_module(cfg)
+ cfg = await FanoutConfigRepository.get(config_id)
+ if cfg is None or not cfg["enabled"]:
+ return
+ await self._start_module(cfg)
async def remove_config(self, config_id: str) -> None:
"""Stop and remove a module."""
@@ -140,7 +142,6 @@ class FanoutManager:
await module.stop()
except Exception:
logger.exception("Error stopping fanout module %s", config_id)
- self._restart_locks.pop(config_id, None)
async def _dispatch_matching(
self,
From 5e042b7bcccf058b494f1f52d3595c1341c00e05 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 23:47:40 -0800
Subject: [PATCH 16/28] Add health refresh to delete handler and correct
concurrency description
---
frontend/src/components/settings/SettingsFanoutSection.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 7809c2ae..0925ede7 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -335,7 +335,8 @@ function BotConfigEditor({
Note: Bots respond to all messages, including your own. For channel
messages, sender_key is None. Multiple enabled bots run
- serially, with a two-second delay between messages to prevent repeater collision.
+ concurrently. Outgoing messages are serialized with a two-second delay between sends to
+ prevent repeater collision.
@@ -925,6 +926,7 @@ export function SettingsFanoutSection({
await api.deleteFanoutConfig(id);
if (editingId === id) setEditingId(null);
await loadConfigs();
+ if (onHealthRefresh) await onHealthRefresh();
toast.success('Integration deleted');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete');
From 863251d670f2303e5b222e8ad37552a3d06303c7 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 23:47:53 -0800
Subject: [PATCH 17/28] Remove dead on_raw method and move json import
somewhere not dumb
---
app/fanout/webhook.py | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/app/fanout/webhook.py b/app/fanout/webhook.py
index 536c6d07..0ec9c28f 100644
--- a/app/fanout/webhook.py
+++ b/app/fanout/webhook.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import hmac
+import json
import logging
import httpx
@@ -33,9 +34,6 @@ class WebhookModule(FanoutModule):
async def on_message(self, data: dict) -> None:
await self._send(data, event_type="message")
- async def on_raw(self, data: dict) -> None:
- await self._send(data, event_type="raw_packet")
-
async def _send(self, data: dict, *, event_type: str) -> None:
if not self._client:
return
@@ -55,9 +53,7 @@ class WebhookModule(FanoutModule):
**extra_headers,
}
- import json as _json
-
- body_bytes = _json.dumps(data, separators=(",", ":"), sort_keys=True).encode()
+ body_bytes = json.dumps(data, separators=(",", ":"), sort_keys=True).encode()
if hmac_secret:
sig = hmac.new(hmac_secret.encode(), body_bytes, hashlib.sha256).hexdigest()
From bb13d223ca9eae561f8e800f87907aec2637666b Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 5 Mar 2026 23:48:16 -0800
Subject: [PATCH 18/28] Remove unused IATA regex and reduncant community
enabled check that's always true
---
app/fanout/community_mqtt.py | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py
index fa73ee50..22524bc5 100644
--- a/app/fanout/community_mqtt.py
+++ b/app/fanout/community_mqtt.py
@@ -15,7 +15,6 @@ import hashlib
import importlib.metadata
import json
import logging
-import re
import ssl
import time
from datetime import datetime
@@ -42,7 +41,6 @@ _STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
# Ed25519 group order
_L = 2**252 + 27742317777372353535851937790883648493
-_IATA_RE = re.compile(r"^[A-Z]{3}$")
# Route type mapping: bottom 2 bits of first byte
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
@@ -333,12 +331,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
from app.websocket import broadcast_error
s: CommunityMqttSettings | None = self._settings
- if (
- s
- and s.community_mqtt_enabled
- and not has_private_key()
- and not self._key_unavailable_warned
- ):
+ if s and not has_private_key() and not self._key_unavailable_warned:
broadcast_error(
"Community MQTT unavailable",
"Radio firmware does not support private key export.",
From 58daf63d0023978ac9cc8d117357dfbaac9a93aa Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 00:41:34 -0800
Subject: [PATCH 19/28] Change fanout tab name.
---
.../src/components/settings/settingsConstants.ts | 2 +-
tests/e2e/specs/apprise.spec.ts | 12 ++++++------
tests/e2e/specs/bot.spec.ts | 2 +-
tests/e2e/specs/webhook.spec.ts | 10 +++++-----
4 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/frontend/src/components/settings/settingsConstants.ts b/frontend/src/components/settings/settingsConstants.ts
index 652bc44e..0e5809e4 100644
--- a/frontend/src/components/settings/settingsConstants.ts
+++ b/frontend/src/components/settings/settingsConstants.ts
@@ -13,7 +13,7 @@ export const SETTINGS_SECTION_LABELS: Record = {
radio: '📻 Radio',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
- fanout: '📤 MQTT & Forwarding',
+ fanout: '📤 MQTT & Automation',
statistics: '📊 Statistics',
about: 'About',
};
diff --git a/tests/e2e/specs/apprise.spec.ts b/tests/e2e/specs/apprise.spec.ts
index 1fc1303c..e9666031 100644
--- a/tests/e2e/specs/apprise.spec.ts
+++ b/tests/e2e/specs/apprise.spec.ts
@@ -23,9 +23,9 @@ test.describe('Apprise integration settings', () => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
- // Open settings and navigate to MQTT & Forwarding
+ // Open settings and navigate to MQTT & Automation
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click the Apprise add button
await page.getByRole('button', { name: 'Apprise' }).click();
@@ -82,7 +82,7 @@ test.describe('Apprise integration settings', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit on our apprise config
const row = page.getByText('API Apprise').locator('..');
@@ -122,7 +122,7 @@ test.describe('Apprise integration settings', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
const row = page.getByText('Scope Apprise').locator('..');
await row.getByRole('button', { name: 'Edit' }).click();
@@ -156,7 +156,7 @@ test.describe('Apprise integration settings', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Should show "Disabled" status text
const row = page.getByText('Disabled Apprise').locator('..');
@@ -187,7 +187,7 @@ test.describe('Apprise integration settings', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
const row = page.getByText('Delete Me Apprise').locator('..');
await row.getByRole('button', { name: 'Edit' }).click();
diff --git a/tests/e2e/specs/bot.spec.ts b/tests/e2e/specs/bot.spec.ts
index ebaa6b31..4136969e 100644
--- a/tests/e2e/specs/bot.spec.ts
+++ b/tests/e2e/specs/bot.spec.ts
@@ -45,7 +45,7 @@ test.describe('Bot functionality', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// The bot name should be visible in the integration list
await expect(page.getByText('E2E Test Bot')).toBeVisible();
diff --git a/tests/e2e/specs/webhook.spec.ts b/tests/e2e/specs/webhook.spec.ts
index bb16a0c3..86e583af 100644
--- a/tests/e2e/specs/webhook.spec.ts
+++ b/tests/e2e/specs/webhook.spec.ts
@@ -23,9 +23,9 @@ test.describe('Webhook integration settings', () => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
- // Open settings and navigate to MQTT & Forwarding
+ // Open settings and navigate to MQTT & Automation
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click the Webhook add button
await page.getByRole('button', { name: 'Webhook' }).click();
@@ -74,7 +74,7 @@ test.describe('Webhook integration settings', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit on our webhook
const row = page.getByText('API Webhook').locator('..');
@@ -109,7 +109,7 @@ test.describe('Webhook integration settings', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit
const row = page.getByText('Scope Webhook').locator('..');
@@ -144,7 +144,7 @@ test.describe('Webhook integration settings', () => {
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
- await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
+ await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit
const row = page.getByText('Delete Me Webhook').locator('..');
From cba983556881125f48f9e8327d7f91c278b66cce Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 12:59:33 -0800
Subject: [PATCH 20/28] Rework more coverage in e2e tests and don't force radio
restart + better startup error handling
---
app/config.py | 37 ++++
app/radio.py | 23 ++-
app/radio_sync.py | 24 ++-
.../settings/SettingsRadioSection.tsx | 73 +++++---
frontend/src/test/settingsModal.test.tsx | 2 +-
tests/e2e/helpers/meshTrafficTest.ts | 22 +++
tests/e2e/specs/incoming-message.spec.ts | 8 +-
tests/e2e/specs/packet-feed.spec.ts | 5 +-
tests/e2e/specs/webhook-delivery.spec.ts | 160 ++++++++++++++++++
tests/e2e/specs/webhook.spec.ts | 3 +-
tests/e2e/specs/zz-radio-settings.spec.ts | 13 +-
11 files changed, 335 insertions(+), 35 deletions(-)
create mode 100644 tests/e2e/specs/webhook-delivery.spec.ts
diff --git a/app/config.py b/app/config.py
index 36c80e98..20617ec6 100644
--- a/app/config.py
+++ b/app/config.py
@@ -48,6 +48,40 @@ class Settings(BaseSettings):
settings = Settings()
+class _RepeatSquelch(logging.Filter):
+ """Suppress rapid-fire identical messages and emit a summary instead.
+
+ Attached to the ``meshcore`` library logger to catch its repeated
+ "Serial Connection started" lines that flood the log when another
+ process holds the serial port.
+ """
+
+ def __init__(self, threshold: int = 3) -> None:
+ super().__init__()
+ self._last_msg: str | None = None
+ self._repeat_count: int = 0
+ self._threshold = threshold
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ msg = record.getMessage()
+ if msg == self._last_msg:
+ self._repeat_count += 1
+ if self._repeat_count == self._threshold:
+ record.msg = (
+ "%s (repeated %d times — possible serial port contention from another process)"
+ )
+ record.args = (msg, self._repeat_count)
+ record.levelno = logging.WARNING
+ record.levelname = "WARNING"
+ return True
+ # Suppress further repeats beyond the threshold
+ return self._repeat_count < self._threshold
+ else:
+ self._last_msg = msg
+ self._repeat_count = 1
+ return True
+
+
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
@@ -55,3 +89,6 @@ def setup_logging() -> None:
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
+ # Squelch repeated messages from the meshcore library (e.g. rapid-fire
+ # "Serial Connection started" when the port is contended).
+ logging.getLogger("meshcore").addFilter(_RepeatSquelch())
diff --git a/app/radio.py b/app/radio.py
index 078dd1e8..95d5dd0a 100644
--- a/app/radio.py
+++ b/app/radio.py
@@ -470,6 +470,8 @@ class RadioManager:
from app.websocket import broadcast_health
CHECK_INTERVAL_SECONDS = 5
+ UNRESPONSIVE_THRESHOLD = 3
+ consecutive_setup_failures = 0
while True:
try:
@@ -483,6 +485,7 @@ class RadioManager:
logger.warning("Radio connection lost, broadcasting status change")
broadcast_health(False, self._connection_info)
self._last_connected = False
+ consecutive_setup_failures = 0
if not current_connected:
# Attempt reconnection on every loop while disconnected
@@ -492,6 +495,7 @@ class RadioManager:
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
+ consecutive_setup_failures = 0
elif not self._last_connected and current_connected:
# Connection restored (might have reconnected automatically).
@@ -500,19 +504,34 @@ class RadioManager:
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
+ consecutive_setup_failures = 0
elif current_connected and not self._setup_complete:
# Transport connected but setup incomplete — retry
logger.info("Retrying post-connect setup...")
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
+ consecutive_setup_failures = 0
except asyncio.CancelledError:
# Task is being cancelled, exit cleanly
break
except Exception as e:
- # Log error but continue monitoring - don't let the monitor die
- logger.exception("Error in connection monitor, continuing: %s", e)
+ consecutive_setup_failures += 1
+ if consecutive_setup_failures == UNRESPONSIVE_THRESHOLD:
+ logger.error(
+ "Post-connect setup has failed %d times in a row. "
+ "The radio port appears open but the radio is not "
+ "responding to commands. Common causes: another "
+ "process has the serial port open (check for other "
+ "RemoteTerm instances, serial monitors, etc.), the "
+ "firmware is in repeater mode (not client), or the "
+ "radio needs a power cycle. Will keep retrying.",
+ consecutive_setup_failures,
+ )
+ elif consecutive_setup_failures < UNRESPONSIVE_THRESHOLD:
+ logger.exception("Error in connection monitor, continuing: %s", e)
+ # After the threshold, silently retry (avoid log spam)
self._reconnect_task = asyncio.create_task(monitor_loop())
logger.info("Radio connection monitor started")
diff --git a/app/radio_sync.py b/app/radio_sync.py
index e4976aa5..53302be7 100644
--- a/app/radio_sync.py
+++ b/app/radio_sync.py
@@ -117,7 +117,16 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
- logger.error("Failed to get contacts from radio: %s", result)
+ logger.error(
+ "Failed to get contacts from radio: %s. "
+ "If you see this repeatedly, the radio may be visible on the "
+ "serial/TCP/BLE port but not responding to commands. Check for "
+ "another process with the serial port open (other RemoteTerm "
+ "instances, serial monitors, etc.), verify the firmware is "
+ "up-to-date and in client mode (not repeater), or try a "
+ "power cycle.",
+ result,
+ )
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
@@ -662,8 +671,19 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
else:
failed += 1
+ reason = result.payload
+ hint = ""
+ if reason is None:
+ hint = (
+ " (no response from radio — if this repeats, check for "
+ "serial port contention from another process or try a "
+ "power cycle)"
+ )
logger.warning(
- "Failed to load contact %s: %s", contact.public_key[:12], result.payload
+ "Failed to load contact %s: %s%s",
+ contact.public_key[:12],
+ reason,
+ hint,
)
except Exception as e:
failed += 1
diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx
index d7a6f571..33b8e483 100644
--- a/frontend/src/components/settings/SettingsRadioSection.tsx
+++ b/frontend/src/components/settings/SettingsRadioSection.tsx
@@ -141,9 +141,7 @@ export function SettingsRadioSection({
);
};
- const handleSave = async () => {
- setError(null);
-
+ const buildUpdate = (): RadioConfigUpdate | null => {
const parsedLat = parseFloat(lat);
const parsedLon = parseFloat(lon);
const parsedTxPower = parseInt(txPower, 10);
@@ -158,24 +156,46 @@ export function SettingsRadioSection({
)
) {
setError('All numeric fields must have valid values');
- return;
+ return null;
}
- setBusy(true);
+ return {
+ name,
+ lat: parsedLat,
+ lon: parsedLon,
+ tx_power: parsedTxPower,
+ radio: {
+ freq: parsedFreq,
+ bw: parsedBw,
+ sf: parsedSf,
+ cr: parsedCr,
+ },
+ };
+ };
+ const handleSave = async () => {
+ setError(null);
+ const update = buildUpdate();
+ if (!update) return;
+
+ setBusy(true);
+ try {
+ await onSave(update);
+ toast.success('Radio config saved');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleSaveAndReboot = async () => {
+ setError(null);
+ const update = buildUpdate();
+ if (!update) return;
+
+ setBusy(true);
try {
- const update: RadioConfigUpdate = {
- name,
- lat: parsedLat,
- lon: parsedLon,
- tx_power: parsedTxPower,
- radio: {
- freq: parsedFreq,
- bw: parsedBw,
- sf: parsedSf,
- cr: parsedCr,
- },
- };
await onSave(update);
toast.success('Radio config saved, rebooting...');
setRebooting(true);
@@ -413,9 +433,22 @@ export function SettingsRadioSection({
)}
-
- {busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
-
+
+
+ {busy && !rebooting ? 'Saving...' : 'Save'}
+
+
+ {rebooting ? 'Rebooting...' : 'Save & Reboot'}
+
+
+
+ Some settings may require a reboot to take effect on some radios.
+
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index 65c3fae4..96fa50ff 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -295,7 +295,7 @@ describe('SettingsModal', () => {
});
openRadioSection();
- fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Save & Reboot' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledTimes(1);
expect(onReboot).toHaveBeenCalledTimes(1);
diff --git a/tests/e2e/helpers/meshTrafficTest.ts b/tests/e2e/helpers/meshTrafficTest.ts
index d561cf37..1acc6511 100644
--- a/tests/e2e/helpers/meshTrafficTest.ts
+++ b/tests/e2e/helpers/meshTrafficTest.ts
@@ -9,8 +9,15 @@
* When a @mesh-traffic-tagged test fails, an advisory annotation is added
* to the HTML report and a console message is printed, letting the user
* know the failure may be due to low mesh traffic rather than a real bug.
+ *
+ * Call `await nudgeEchoBot()` at the start of any @mesh-traffic test to
+ * send a trigger message to an echo bot on #flightless. If the bot is in
+ * radio range it will generate an incoming packet, potentially saving the
+ * full 3-minute wait. The nudge is best-effort — tests still rely on the
+ * long polling timeout for environments without the bot.
*/
import { test as base, expect } from '@playwright/test';
+import { ensureFlightlessChannel, sendChannelMessage } from './api';
export { expect };
@@ -18,6 +25,21 @@ const TRAFFIC_ADVISORY =
'This test depends on receiving messages from other nodes on the mesh ' +
'network. Failure may indicate insufficient mesh traffic rather than a bug.';
+/**
+ * Best-effort: send a message to #flightless that triggers a remote echo
+ * bot. If the bot is within radio range it will reply, generating the
+ * incoming traffic the test needs. Failures are silently ignored — the
+ * test will fall back to waiting for organic mesh traffic.
+ */
+export async function nudgeEchoBot(): Promise {
+ try {
+ const channel = await ensureFlightlessChannel();
+ await sendChannelMessage(channel.key, '!echo please give incoming message');
+ } catch {
+ // Best-effort — bot may not be reachable
+ }
+}
+
export const test = base.extend<{ _meshTrafficAdvisory: void }>({
_meshTrafficAdvisory: [
async ({}, use, testInfo) => {
diff --git a/tests/e2e/specs/incoming-message.spec.ts b/tests/e2e/specs/incoming-message.spec.ts
index 3987579c..d1099584 100644
--- a/tests/e2e/specs/incoming-message.spec.ts
+++ b/tests/e2e/specs/incoming-message.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '../helpers/meshTrafficTest';
+import { test, expect, nudgeEchoBot } from '../helpers/meshTrafficTest';
import { createChannel, getChannels, getMessages } from '../helpers/api';
/**
@@ -55,6 +55,9 @@ test.describe('Incoming mesh messages', () => {
});
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
+ // Nudge echo bot on #flightless — may generate an incoming packet quickly
+ await nudgeEchoBot();
+
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
@@ -103,6 +106,9 @@ test.describe('Incoming mesh messages', () => {
});
test('incoming message with path shows hop badge and path modal', { tag: '@mesh-traffic' }, async ({ page }) => {
+ // Nudge echo bot on #flightless — may generate an incoming packet quickly
+ await nudgeEchoBot();
+
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
diff --git a/tests/e2e/specs/packet-feed.spec.ts b/tests/e2e/specs/packet-feed.spec.ts
index 8d84fed7..f2ff69c3 100644
--- a/tests/e2e/specs/packet-feed.spec.ts
+++ b/tests/e2e/specs/packet-feed.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '../helpers/meshTrafficTest';
+import { test, expect, nudgeEchoBot } from '../helpers/meshTrafficTest';
test.describe('Packet Feed page', () => {
test('packet feed page loads and shows header', async ({ page }) => {
@@ -11,6 +11,9 @@ test.describe('Packet Feed page', () => {
// This test waits for real RF traffic — needs 180s timeout
test.setTimeout(180_000);
+ // Nudge echo bot on #flightless — may generate a packet quickly
+ await nudgeEchoBot();
+
await page.goto('/#raw');
await expect(page.getByText('Raw Packet Feed')).toBeVisible({ timeout: 10_000 });
diff --git a/tests/e2e/specs/webhook-delivery.spec.ts b/tests/e2e/specs/webhook-delivery.spec.ts
new file mode 100644
index 00000000..f5c18094
--- /dev/null
+++ b/tests/e2e/specs/webhook-delivery.spec.ts
@@ -0,0 +1,160 @@
+import { test, expect } from '@playwright/test';
+import http from 'http';
+import {
+ createFanoutConfig,
+ deleteFanoutConfig,
+ ensureFlightlessChannel,
+ sendChannelMessage,
+} from '../helpers/api';
+
+/**
+ * Spin up a local HTTP server that captures incoming webhook requests.
+ * Returns the server, its URL, and a promise-based helper to wait for
+ * the next request body.
+ */
+function createWebhookReceiver() {
+ const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
+ let resolve: (() => void) | null = null;
+
+ const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (chunk) => (body += chunk));
+ req.on('end', () => {
+ requests.push({ body, headers: req.headers });
+ resolve?.();
+ resolve = null;
+ res.writeHead(200);
+ res.end('ok');
+ });
+ });
+
+ return {
+ server,
+ requests,
+ /** Wait until at least `count` requests have been received. */
+ waitForRequests(count: number, timeoutMs = 30_000): Promise {
+ if (requests.length >= count) return Promise.resolve();
+ return new Promise((res, rej) => {
+ const timer = setTimeout(
+ () => rej(new Error(`Timed out waiting for ${count} webhook request(s), got ${requests.length}`)),
+ timeoutMs
+ );
+ const check = () => {
+ if (requests.length >= count) {
+ clearTimeout(timer);
+ res();
+ } else {
+ resolve = check;
+ }
+ };
+ resolve = check;
+ });
+ },
+ /** Start listening on a random port and return the URL. */
+ async listen(): Promise {
+ return new Promise((res) => {
+ server.listen(0, '127.0.0.1', () => {
+ const addr = server.address();
+ if (typeof addr === 'object' && addr) {
+ res(`http://127.0.0.1:${addr.port}`);
+ }
+ });
+ });
+ },
+ };
+}
+
+test.describe('Webhook delivery', () => {
+ let webhookId: string | null = null;
+ let receiver: ReturnType;
+ let webhookUrl: string;
+
+ test.beforeAll(async () => {
+ await ensureFlightlessChannel();
+ receiver = createWebhookReceiver();
+ webhookUrl = await receiver.listen();
+ });
+
+ test.afterAll(async () => {
+ receiver.server.close();
+ if (webhookId) {
+ try {
+ await deleteFanoutConfig(webhookId);
+ } catch {
+ // Best-effort cleanup
+ }
+ }
+ });
+
+ test('webhook receives message payload when a channel message is sent', async () => {
+ // Create an enabled webhook pointing at our local receiver
+ const webhook = await createFanoutConfig({
+ type: 'webhook',
+ name: 'E2E Delivery Test',
+ config: { url: webhookUrl, method: 'POST', headers: {} },
+ enabled: true,
+ });
+ webhookId = webhook.id;
+
+ // Send a message via API — this triggers broadcast_event → fanout → webhook
+ const channel = await ensureFlightlessChannel();
+ const testText = `webhook-delivery-${Date.now()}`;
+ await sendChannelMessage(channel.key, testText);
+
+ // Wait for the webhook to receive the request
+ await receiver.waitForRequests(1);
+
+ const req = receiver.requests[0];
+ expect(req.headers['content-type']).toBe('application/json');
+ expect(req.headers['x-webhook-event']).toBe('message');
+
+ const payload = JSON.parse(req.body);
+ expect(payload.text).toContain(testText);
+ expect(payload.type).toBe('CHAN');
+ expect(payload.conversation_key).toBe(channel.key);
+ });
+
+ test('webhook respects HMAC signing when configured', async () => {
+ // Clean up previous webhook
+ if (webhookId) {
+ await deleteFanoutConfig(webhookId);
+ }
+
+ const hmacSecret = 'e2e-test-secret';
+ const webhook = await createFanoutConfig({
+ type: 'webhook',
+ name: 'E2E HMAC Test',
+ config: {
+ url: webhookUrl,
+ method: 'POST',
+ headers: {},
+ hmac_secret: hmacSecret,
+ },
+ enabled: true,
+ });
+ webhookId = webhook.id;
+
+ // Clear previous requests
+ const baselineCount = receiver.requests.length;
+
+ const channel = await ensureFlightlessChannel();
+ const testText = `hmac-test-${Date.now()}`;
+ await sendChannelMessage(channel.key, testText);
+
+ await receiver.waitForRequests(baselineCount + 1);
+
+ const req = receiver.requests[baselineCount];
+ const signature = req.headers['x-webhook-signature'];
+ expect(signature).toBeDefined();
+ expect(typeof signature).toBe('string');
+ expect((signature as string).startsWith('sha256=')).toBe(true);
+
+ // Verify the HMAC is valid
+ const crypto = await import('crypto');
+ const expectedSig = crypto
+ .createHmac('sha256', hmacSecret)
+ .update(req.body)
+ .digest('hex');
+ expect(signature).toBe(`sha256=${expectedSig}`);
+ });
+});
diff --git a/tests/e2e/specs/webhook.spec.ts b/tests/e2e/specs/webhook.spec.ts
index 86e583af..0c96483a 100644
--- a/tests/e2e/specs/webhook.spec.ts
+++ b/tests/e2e/specs/webhook.spec.ts
@@ -115,10 +115,9 @@ test.describe('Webhook integration settings', () => {
const row = page.getByText('Scope Webhook').locator('..');
await row.getByRole('button', { name: 'Edit' }).click();
- // Verify scope selector is visible with all four modes
+ // Verify scope selector is visible with the three webhook-applicable modes
await expect(page.getByText('Message Scope')).toBeVisible();
await expect(page.getByText('All messages')).toBeVisible();
- await expect(page.getByText('No messages')).toBeVisible();
await expect(page.getByText('Only listed channels/contacts')).toBeVisible();
await expect(page.getByText('All except listed channels/contacts')).toBeVisible();
diff --git a/tests/e2e/specs/zz-radio-settings.spec.ts b/tests/e2e/specs/zz-radio-settings.spec.ts
index 08b2e184..068089e5 100644
--- a/tests/e2e/specs/zz-radio-settings.spec.ts
+++ b/tests/e2e/specs/zz-radio-settings.spec.ts
@@ -23,16 +23,17 @@ test.describe('Radio settings', () => {
await nameInput.clear();
await nameInput.fill(testName);
- await page.getByRole('button', { name: 'Save Radio Config & Reboot' }).click();
- await expect(page.getByText('Radio config saved, rebooting...')).toBeVisible({ timeout: 10_000 });
+ // Use "Save" (no reboot) — name changes apply immediately
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Radio config saved')).toBeVisible({ timeout: 10_000 });
+
+ // --- Step 2: Verify via API (send_appstart refreshes cached info) ---
+ const config = await getRadioConfig();
+ expect(config.name).toBe(testName);
// Exit settings page mode
await page.getByRole('button', { name: /Back to Chat/i }).click();
- // --- Step 2: Verify via API (now returns fresh data after send_appstart fix) ---
- const config = await getRadioConfig();
- expect(config.name).toBe(testName);
-
// --- Step 3: Verify persistence across page reload ---
await page.reload();
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 });
From 929a931ce928e3478cfc0548298943e14e5d6a81 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 14:05:54 -0800
Subject: [PATCH 21/28] Add channel name to broadcasts
---
app/models.py | 1 +
app/packet_processor.py | 2 ++
frontend/src/types.ts | 1 +
tests/test_event_handlers.py | 1 +
4 files changed, 5 insertions(+)
diff --git a/app/models.py b/app/models.py
index ccf615df..2fb62a87 100644
--- a/app/models.py
+++ b/app/models.py
@@ -196,6 +196,7 @@ class Message(BaseModel):
outgoing: bool = False
acked: int = 0
sender_name: str | None = None
+ channel_name: str | None = None
class MessagesAroundResponse(BaseModel):
diff --git a/app/packet_processor.py b/app/packet_processor.py
index cbe49d18..5595ea76 100644
--- a/app/packet_processor.py
+++ b/app/packet_processor.py
@@ -208,6 +208,7 @@ async def create_message_from_decrypted(
paths=paths,
sender_name=sender,
sender_key=resolved_sender_key,
+ channel_name=channel_name,
).model_dump(),
realtime=trigger_bot,
)
@@ -301,6 +302,7 @@ async def create_dm_message_from_decrypted(
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
# Broadcast new message to connected clients (and fanout modules when realtime)
+ sender_name = contact.name if contact and not outgoing else None
broadcast_event(
"message",
Message(
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 06c1c479..8498ec36 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -172,6 +172,7 @@ export interface Message {
/** ACK count: 0 = not acked, 1+ = number of acks/flood echoes received */
acked: number;
sender_name: string | null;
+ channel_name?: string | null;
}
export interface MessagesAroundResponse {
diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py
index 3d5e88a5..a62212a4 100644
--- a/tests/test_event_handlers.py
+++ b/tests/test_event_handlers.py
@@ -290,6 +290,7 @@ class TestContactMessageCLIFiltering:
"outgoing",
"acked",
"sender_name",
+ "channel_name",
}
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
From 9d03844371a790a2123ae8487bcfe69383219242 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 14:09:30 -0800
Subject: [PATCH 22/28] Improve module lifecycling
---
app/fanout/bot.py | 17 ++-
app/fanout/manager.py | 1 +
tests/test_fanout_integration.py | 214 +++++++++++++++++++++++++++++++
3 files changed, 230 insertions(+), 2 deletions(-)
diff --git a/app/fanout/bot.py b/app/fanout/bot.py
index 36c49e93..5b6d8e00 100644
--- a/app/fanout/bot.py
+++ b/app/fanout/bot.py
@@ -21,10 +21,23 @@ class BotModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
super().__init__(config_id, config, name=name)
+ self._tasks: set[asyncio.Task] = set()
+ self._active = True
+
+ async def stop(self) -> None:
+ self._active = False
+ for task in self._tasks:
+ task.cancel()
+ # Wait briefly for tasks to acknowledge cancellation
+ if self._tasks:
+ await asyncio.gather(*self._tasks, return_exceptions=True)
+ self._tasks.clear()
async def on_message(self, data: dict) -> None:
"""Kick off bot execution in a background task so we don't block dispatch."""
- asyncio.create_task(self._run_for_message(data))
+ task = asyncio.create_task(self._run_for_message(data))
+ self._tasks.add(task)
+ task.add_done_callback(self._tasks.discard)
async def _run_for_message(self, data: dict) -> None:
from app.fanout.bot_exec import (
@@ -118,7 +131,7 @@ class BotModule(FanoutModule):
logger.warning("Bot '%s' execution error: %s", self.name, e)
return
- if response:
+ if response and self._active:
await process_bot_response(response, is_dm, sender_key or "", channel_key)
@property
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
index b58041bd..665179a3 100644
--- a/app/fanout/manager.py
+++ b/app/fanout/manager.py
@@ -194,6 +194,7 @@ class FanoutManager:
await module.start()
except Exception:
logger.exception("Failed to restart timed-out fanout module %s", config_id)
+ self._modules.pop(config_id, None)
async def broadcast_message(self, data: dict) -> None:
"""Dispatch a decoded message to modules whose scope matches."""
diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py
index 42c7e68e..22df2579 100644
--- a/tests/test_fanout_integration.py
+++ b/tests/test_fanout_integration.py
@@ -1235,3 +1235,217 @@ class TestFanoutAppriseIntegration:
body_text = str(results[0])
assert "Eve" in body_text
assert "routed msg" in body_text
+
+
+# ---------------------------------------------------------------------------
+# Bot lifecycle tests
+# ---------------------------------------------------------------------------
+
+
+class TestBotModuleLifecycle:
+ """BotModule.stop() must cancel in-flight tasks and prevent response delivery."""
+
+ @pytest.mark.asyncio
+ async def test_stop_cancels_pending_tasks(self):
+ """Stopping a bot module cancels tasks still in the settle delay."""
+ from app.fanout.bot import BotModule
+
+ mod = BotModule("bot1", {"code": "def bot(**k): return 'hi'"}, name="Test Bot")
+ mod._active = True
+
+ # Fire off a message — it will enter the 2s settle sleep
+ await mod.on_message(
+ {"type": "PRIV", "conversation_key": "abc123", "text": "hello", "outgoing": False}
+ )
+ assert len(mod._tasks) == 1
+
+ # Stop immediately — should cancel the pending task
+ await mod.stop()
+
+ assert mod._active is False
+ assert len(mod._tasks) == 0
+
+ @pytest.mark.asyncio
+ async def test_stop_prevents_response_delivery(self):
+ """Even if bot code returns a response, stop() prevents it from being sent."""
+ from unittest.mock import AsyncMock, patch
+
+ from app.fanout.bot import BotModule
+
+ mod = BotModule("bot1", {"code": "def bot(**k): return 'reply'"}, name="Test Bot")
+
+ mock_process = AsyncMock()
+ with patch("app.fanout.bot.asyncio.sleep", new_callable=AsyncMock):
+ # Manually run the handler with _active=True, then set _active=False
+ # before process_bot_response would be called
+ original_run = mod._run_for_message
+
+ async def run_then_deactivate(data):
+ # Deactivate mid-flight by stopping
+ mod._active = False
+ await original_run(data)
+
+ with patch.object(mod, "_run_for_message", run_then_deactivate):
+ await mod.on_message(
+ {
+ "type": "PRIV",
+ "conversation_key": "abc123",
+ "text": "hello",
+ "outgoing": False,
+ }
+ )
+ # Wait for the task to finish
+ if mod._tasks:
+ await asyncio.gather(*mod._tasks, return_exceptions=True)
+
+ # process_bot_response should never have been called
+ mock_process.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_multiple_tasks_all_cancelled(self):
+ """Multiple in-flight tasks are all cancelled on stop."""
+ from app.fanout.bot import BotModule
+
+ mod = BotModule("bot1", {"code": "def bot(**k): return 'hi'"}, name="Test Bot")
+ mod._active = True
+
+ # Fire off several messages
+ for i in range(5):
+ await mod.on_message(
+ {
+ "type": "PRIV",
+ "conversation_key": f"key{i}",
+ "text": f"msg{i}",
+ "outgoing": False,
+ }
+ )
+ assert len(mod._tasks) == 5
+
+ await mod.stop()
+
+ assert mod._active is False
+ assert len(mod._tasks) == 0
+
+
+# ---------------------------------------------------------------------------
+# Manager restart failure tests
+# ---------------------------------------------------------------------------
+
+
+class TestManagerRestartFailure:
+ """_restart_module removes dead module from dispatch table on failure."""
+
+ @pytest.mark.asyncio
+ async def test_failed_restart_removes_module(self):
+ """When module.start() fails during restart, the module is removed from _modules."""
+
+ from app.fanout.base import FanoutModule
+
+ class FailingModule(FanoutModule):
+ def __init__(self):
+ super().__init__("fail1", {}, name="Failer")
+ self.stop_called = False
+ self.start_calls = 0
+
+ async def stop(self):
+ self.stop_called = True
+
+ async def start(self):
+ self.start_calls += 1
+ raise ConnectionError("broker down")
+
+ @property
+ def status(self):
+ return "error"
+
+ manager = FanoutManager()
+ mod = FailingModule()
+ manager._modules["fail1"] = (mod, {"messages": "all", "raw_packets": "none"})
+
+ # Restart should catch the error and remove the module
+ await manager._restart_module("fail1", mod)
+
+ assert mod.stop_called
+ assert mod.start_calls == 1
+ assert "fail1" not in manager._modules
+
+ @pytest.mark.asyncio
+ async def test_successful_restart_keeps_module(self):
+ """When restart succeeds, the module stays in _modules."""
+ from app.fanout.base import FanoutModule
+
+ class GoodModule(FanoutModule):
+ def __init__(self):
+ super().__init__("good1", {}, name="Goodie")
+
+ async def stop(self):
+ pass
+
+ async def start(self):
+ pass
+
+ @property
+ def status(self):
+ return "connected"
+
+ manager = FanoutManager()
+ mod = GoodModule()
+ scope = {"messages": "all", "raw_packets": "none"}
+ manager._modules["good1"] = (mod, scope)
+
+ await manager._restart_module("good1", mod)
+
+ assert "good1" in manager._modules
+
+ @pytest.mark.asyncio
+ async def test_dead_module_not_dispatched_after_failed_restart(self):
+ """After failed restart, the dead module does not receive further dispatches."""
+ from app.fanout.base import FanoutModule
+
+ class TrackingModule(FanoutModule):
+ def __init__(self, config_id):
+ super().__init__(config_id, {}, name=config_id)
+ self.messages_received = []
+
+ async def start(self):
+ raise RuntimeError("can't start")
+
+ async def stop(self):
+ pass
+
+ async def on_message(self, data):
+ self.messages_received.append(data)
+
+ @property
+ def status(self):
+ return "error"
+
+ class HealthyModule(FanoutModule):
+ def __init__(self):
+ super().__init__("healthy", {}, name="Healthy")
+ self.messages_received = []
+
+ async def on_message(self, data):
+ self.messages_received.append(data)
+
+ @property
+ def status(self):
+ return "connected"
+
+ manager = FanoutManager()
+ dead = TrackingModule("dead1")
+ healthy = HealthyModule()
+
+ scope = {"messages": "all", "raw_packets": "none"}
+ manager._modules["dead1"] = (dead, scope)
+ manager._modules["healthy"] = (healthy, scope)
+
+ # Simulate failed restart of dead module
+ await manager._restart_module("dead1", dead)
+ assert "dead1" not in manager._modules
+
+ # Now broadcast — only the healthy module should receive
+ await manager.broadcast_message({"type": "PRIV", "conversation_key": "k1", "text": "hi"})
+
+ assert len(healthy.messages_received) == 1
+ assert len(dead.messages_received) == 0
From d7d06ec1f8dfe6b649a39c845da9538157e5bb8e Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 14:11:23 -0800
Subject: [PATCH 23/28] Remove some dead code and unify param names around not
sending for actual real life messages vs. historical decrypt
---
app/packet_processor.py | 14 +++++++-------
app/routers/packets.py | 2 +-
.../components/settings/SettingsFanoutSection.tsx | 7 ++-----
tests/test_packet_pipeline.py | 6 +++---
4 files changed, 13 insertions(+), 16 deletions(-)
diff --git a/app/packet_processor.py b/app/packet_processor.py
index 5595ea76..753c2471 100644
--- a/app/packet_processor.py
+++ b/app/packet_processor.py
@@ -129,7 +129,7 @@ async def create_message_from_decrypted(
received_at: int | None = None,
path: str | None = None,
channel_name: str | None = None,
- trigger_bot: bool = True,
+ realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted channel packet content.
@@ -145,7 +145,7 @@ async def create_message_from_decrypted(
timestamp: Sender timestamp from the packet
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path
- trigger_bot: Whether to trigger bot response (False for historical decryption)
+ realtime: If False, skip fanout dispatch (used for historical decryption)
Returns the message ID if created, None if duplicate.
"""
@@ -210,7 +210,7 @@ async def create_message_from_decrypted(
sender_key=resolved_sender_key,
channel_name=channel_name,
).model_dump(),
- realtime=trigger_bot,
+ realtime=realtime,
)
return msg_id
@@ -224,7 +224,7 @@ async def create_dm_message_from_decrypted(
received_at: int | None = None,
path: str | None = None,
outgoing: bool = False,
- trigger_bot: bool = True,
+ realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted direct message packet content.
@@ -239,7 +239,7 @@ async def create_dm_message_from_decrypted(
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path
outgoing: Whether this is an outgoing message (we sent it)
- trigger_bot: Whether to trigger bot response (False for historical decryption)
+ realtime: If False, skip fanout dispatch (used for historical decryption)
Returns the message ID if created, None if duplicate.
"""
@@ -317,7 +317,7 @@ async def create_dm_message_from_decrypted(
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
).model_dump(),
- realtime=trigger_bot,
+ realtime=realtime,
)
# Update contact's last_contacted timestamp (for sorting)
@@ -392,7 +392,7 @@ async def run_historical_dm_decryption(
received_at=packet_timestamp,
path=path_hex,
outgoing=outgoing,
- trigger_bot=False, # Historical decryption should not trigger bot
+ realtime=False, # Historical decryption should not trigger fanout
)
if msg_id is not None:
diff --git a/app/routers/packets.py b/app/routers/packets.py
index 734e8787..b08754b4 100644
--- a/app/routers/packets.py
+++ b/app/routers/packets.py
@@ -71,7 +71,7 @@ async def _run_historical_channel_decryption(
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
- trigger_bot=False, # Historical decryption should not trigger bot
+ realtime=False, # Historical decryption should not trigger fanout
)
if msg_id is not None:
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 0925ede7..4c2c1375 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -481,11 +481,8 @@ function ScopeSelector({
selectedContacts.length >= filteredContacts.length);
const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled;
- // For "except" mode, checked means the item is in the exclusion list (will be excluded)
- const isChannelChecked = (key: string) =>
- mode === 'except' ? selectedChannels.includes(key) : selectedChannels.includes(key);
- const isContactChecked = (key: string) =>
- mode === 'except' ? selectedContacts.includes(key) : selectedContacts.includes(key);
+ const isChannelChecked = (key: string) => selectedChannels.includes(key);
+ const isContactChecked = (key: string) => selectedContacts.includes(key);
const listHint =
mode === 'only'
diff --git a/tests/test_packet_pipeline.py b/tests/test_packet_pipeline.py
index af1a375d..e351e9ae 100644
--- a/tests/test_packet_pipeline.py
+++ b/tests/test_packet_pipeline.py
@@ -1852,8 +1852,8 @@ class TestRunHistoricalDmDecryption:
assert len(messages) == 0
@pytest.mark.asyncio
- async def test_sets_trigger_bot_false(self, test_db, captured_broadcasts):
- """Historical decryption calls create_dm_message_from_decrypted with trigger_bot=False."""
+ async def test_sets_realtime_false(self, test_db, captured_broadcasts):
+ """Historical decryption calls create_dm_message_from_decrypted with realtime=False."""
from app.packet_processor import run_historical_dm_decryption
raw = self._make_text_message_bytes(b"\x20")
@@ -1891,7 +1891,7 @@ class TestRunHistoricalDmDecryption:
mock_create.assert_awaited_once()
call_kwargs = mock_create.call_args[1]
- assert call_kwargs["trigger_bot"] is False
+ assert call_kwargs["realtime"] is False
@pytest.mark.asyncio
async def test_broadcasts_success_when_decrypted(self, test_db, captured_broadcasts):
From 819470cb407c8831a7c823ff64eba74ca9247f6d Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 15:39:11 -0800
Subject: [PATCH 24/28] Add missing httpx dep
---
pyproject.toml | 1 +
uv.lock | 2 ++
2 files changed, 3 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index 5372c137..73282047 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,6 +9,7 @@ dependencies = [
"uvicorn[standard]>=0.32.0",
"pydantic-settings>=2.0.0",
"aiosqlite>=0.19.0",
+ "httpx>=0.28.1",
"pycryptodome>=3.20.0",
"pynacl>=1.5.0",
"meshcore",
diff --git a/uv.lock b/uv.lock
index d7b6ef2a..0f87014e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1055,6 +1055,7 @@ dependencies = [
{ name = "aiosqlite" },
{ name = "apprise" },
{ name = "fastapi" },
+ { name = "httpx" },
{ name = "meshcore" },
{ name = "pycryptodome" },
{ name = "pydantic-settings" },
@@ -1087,6 +1088,7 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.19.0" },
{ name = "apprise", specifier = ">=1.9.7" },
{ name = "fastapi", specifier = ">=0.115.0" },
+ { name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
{ name = "meshcore" },
{ name = "pycryptodome", specifier = ">=3.20.0" },
From 3144910cd9a28d98dfac011745e7cf8cbcc8951d Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 15:41:47 -0800
Subject: [PATCH 25/28] Fix regression around direct path DMs
---
app/packet_processor.py | 2 +-
tests/test_echo_dedup.py | 56 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 57 insertions(+), 1 deletion(-)
diff --git a/app/packet_processor.py b/app/packet_processor.py
index 753c2471..649bdbe8 100644
--- a/app/packet_processor.py
+++ b/app/packet_processor.py
@@ -871,7 +871,7 @@ async def _process_direct_message(
their_public_key=contact.public_key,
our_public_key=our_public_key.hex(),
received_at=timestamp,
- path=packet_info.path.hex() if packet_info.path else None,
+ path=packet_info.path.hex() if packet_info else None,
outgoing=is_outgoing,
)
diff --git a/tests/test_echo_dedup.py b/tests/test_echo_dedup.py
index 7475f65b..7c94fd32 100644
--- a/tests/test_echo_dedup.py
+++ b/tests/test_echo_dedup.py
@@ -629,6 +629,62 @@ class TestDirectMessageDirectionDetection:
assert len(messages) == 1
assert messages[0].outgoing is False
+ @pytest.mark.asyncio
+ async def test_incoming_zero_hop_dm_preserves_empty_path(self, test_db, captured_broadcasts):
+ """A 0-hop DM should preserve path='' rather than dropping path data."""
+ from app.packet_processor import _process_direct_message
+
+ packet_info = MagicMock()
+ packet_info.payload = bytes([0xFA, 0xA1, 0x00, 0x00]) + b"\x00" * 20
+ packet_info.path = b""
+
+ await ContactRepository.upsert(
+ {
+ "public_key": self.DIFFERENT_CONTACT_PUB,
+ "name": "TestContact",
+ "type": 1,
+ }
+ )
+
+ decrypted = DecryptedDirectMessage(
+ timestamp=SENDER_TIMESTAMP,
+ flags=0,
+ message="Zero hop DM",
+ dest_hash=self.OUR_FIRST_BYTE,
+ src_hash=self.DIFFERENT_FIRST_BYTE,
+ )
+
+ pkt_id, _ = await RawPacketRepository.create(b"dir_test_zero_hop", SENDER_TIMESTAMP)
+
+ broadcasts, mock_broadcast = captured_broadcasts
+
+ with (
+ patch("app.packet_processor.has_private_key", return_value=True),
+ patch("app.packet_processor.get_private_key", return_value=b"\x00" * 32),
+ patch("app.packet_processor.get_public_key", return_value=self.OUR_PUB_BYTES),
+ patch("app.packet_processor.try_decrypt_dm", return_value=decrypted),
+ patch("app.packet_processor.broadcast_event", mock_broadcast),
+ ):
+ result = await _process_direct_message(
+ b"\x00" * 40, pkt_id, SENDER_TIMESTAMP, packet_info
+ )
+
+ assert result is not None
+
+ messages = await MessageRepository.get_all(
+ msg_type="PRIV", conversation_key=self.DIFFERENT_CONTACT_PUB.lower(), limit=10
+ )
+ assert len(messages) == 1
+ assert messages[0].paths is not None
+ assert len(messages[0].paths) == 1
+ assert messages[0].paths[0].path == ""
+
+ message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
+ assert len(message_broadcasts) == 1
+ assert message_broadcasts[0]["data"]["paths"] == [
+ {"path": "", "received_at": SENDER_TIMESTAMP}
+ ]
+
@pytest.mark.asyncio
async def test_outgoing_message_detected(self, test_db, captured_broadcasts):
"""src_hash matches us, dest_hash doesn't → outgoing."""
From 3330028d27b77a5f9cee9bb0b983cb9e9bd56b2a Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 15:43:29 -0800
Subject: [PATCH 26/28] Elevate error logging for message poll loop issues
---
app/radio_sync.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/radio_sync.py b/app/radio_sync.py
index 53302be7..9e77c9a2 100644
--- a/app/radio_sync.py
+++ b/app/radio_sync.py
@@ -325,7 +325,7 @@ async def drain_pending_messages(mc: MeshCore) -> int:
except asyncio.TimeoutError:
break
except Exception as e:
- logger.debug("Error draining messages: %s", e)
+ logger.warning("Error draining messages: %s", e, exc_info=True)
break
return count
@@ -359,7 +359,7 @@ async def poll_for_messages(mc: MeshCore) -> int:
except asyncio.TimeoutError:
pass
except Exception as e:
- logger.debug("Message poll exception: %s", e)
+ logger.warning("Message poll exception: %s", e, exc_info=True)
return count
@@ -393,7 +393,7 @@ async def _message_poll_loop():
except asyncio.CancelledError:
break
except Exception as e:
- logger.debug("Error in message poll loop: %s", e)
+ logger.warning("Error in message poll loop: %s", e, exc_info=True)
def start_message_polling():
From dd13768a44d663a36677f2a7ffff3ce1554a0b85 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 15:55:04 -0800
Subject: [PATCH 27/28] Tighten up message broadcast contract
---
LICENSES.md | 22 ++++++++
app/fanout/bot.py | 19 ++++---
app/routers/messages.py | 3 +
tests/test_fanout_hitlist.py | 95 ++++++++++++++++++++++++++++++++
tests/test_fanout_integration.py | 86 +++++++++++++++++++++++++++++
tests/test_send_messages.py | 3 +
6 files changed, 220 insertions(+), 8 deletions(-)
diff --git a/LICENSES.md b/LICENSES.md
index 965eee3e..10971fae 100644
--- a/LICENSES.md
+++ b/LICENSES.md
@@ -122,6 +122,28 @@ THE SOFTWARE.
+### httpx (0.28.1) — BSD License
+
+
+Full license text
+
+```
+Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/).
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+
+
### meshcore (2.2.5) — MIT
diff --git a/app/fanout/bot.py b/app/fanout/bot.py
index 5b6d8e00..40a1193f 100644
--- a/app/fanout/bot.py
+++ b/app/fanout/bot.py
@@ -56,7 +56,7 @@ class BotModule(FanoutModule):
# Extract bot parameters from broadcast data
if is_dm:
conversation_key = data.get("conversation_key", "")
- sender_key = conversation_key
+ sender_key = data.get("sender_key") or conversation_key
is_outgoing = data.get("outgoing", False)
message_text = data.get("text", "")
channel_key = None
@@ -66,10 +66,12 @@ class BotModule(FanoutModule):
if is_outgoing:
sender_name = None
else:
- from app.repository import ContactRepository
+ sender_name = data.get("sender_name")
+ if sender_name is None:
+ from app.repository import ContactRepository
- contact = await ContactRepository.get_by_key(conversation_key)
- sender_name = contact.name if contact else None
+ contact = await ContactRepository.get_by_key(conversation_key)
+ sender_name = contact.name if contact else None
else:
conversation_key = data.get("conversation_key", "")
sender_key = None
@@ -77,11 +79,12 @@ class BotModule(FanoutModule):
sender_name = data.get("sender_name")
channel_key = conversation_key
- # Look up channel name
- from app.repository import ChannelRepository
+ channel_name = data.get("channel_name")
+ if channel_name is None:
+ from app.repository import ChannelRepository
- channel = await ChannelRepository.get_by_key(conversation_key)
- channel_name = channel.name if channel else None
+ channel = await ChannelRepository.get_by_key(conversation_key)
+ channel_name = channel.name if channel else None
# Strip "sender: " prefix from channel message text
text = data.get("text", "")
diff --git a/app/routers/messages.py b/app/routers/messages.py
index e8e13548..9aabb6f7 100644
--- a/app/routers/messages.py
+++ b/app/routers/messages.py
@@ -296,6 +296,7 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
acked=0,
sender_name=radio_name or None,
sender_key=our_public_key,
+ channel_name=db_channel.name,
).model_dump(),
)
@@ -316,6 +317,7 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
paths=paths,
sender_name=radio_name or None,
sender_key=our_public_key,
+ channel_name=db_channel.name,
)
return message
@@ -444,6 +446,7 @@ async def resend_channel_message(
acked=0,
sender_name=radio_name or None,
sender_key=resend_public_key,
+ channel_name=db_channel.name,
).model_dump(),
)
diff --git a/tests/test_fanout_hitlist.py b/tests/test_fanout_hitlist.py
index 62fb7634..cce6b276 100644
--- a/tests/test_fanout_hitlist.py
+++ b/tests/test_fanout_hitlist.py
@@ -203,6 +203,101 @@ class TestBotModuleParameterExtraction:
assert captured["message_text"] == "the actual message"
assert captured["sender_name"] == "Alice"
+ @pytest.mark.asyncio
+ async def test_channel_name_uses_payload_before_db_lookup(self):
+ """Channel fanout payload channel_name is preserved even if the DB lookup misses."""
+ from app.fanout.bot import BotModule
+
+ captured = {}
+
+ def fake_execute(
+ code,
+ sender_name,
+ sender_key,
+ message_text,
+ is_dm,
+ channel_key,
+ channel_name,
+ sender_timestamp,
+ path,
+ is_outgoing,
+ ):
+ captured["channel_name"] = channel_name
+ return None
+
+ mod = BotModule("test", {"code": "def bot(**k): pass"}, name="Test")
+
+ with (
+ patch("app.fanout.bot_exec.execute_bot_code", side_effect=fake_execute),
+ patch(
+ "app.fanout.bot_exec._bot_semaphore",
+ MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
+ ),
+ patch("app.fanout.bot.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.repository.ChannelRepository") as mock_chan,
+ ):
+ mock_chan.get_by_key = AsyncMock(return_value=None)
+ await mod._run_for_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch1",
+ "channel_name": "#payload",
+ "text": "Alice: hello",
+ "sender_name": "Alice",
+ }
+ )
+
+ assert captured["channel_name"] == "#payload"
+
+ @pytest.mark.asyncio
+ async def test_dm_sender_name_uses_payload_before_db_lookup(self):
+ """Incoming DM sender_name from the message payload should be preserved."""
+ from app.fanout.bot import BotModule
+
+ captured = {}
+
+ def fake_execute(
+ code,
+ sender_name,
+ sender_key,
+ message_text,
+ is_dm,
+ channel_key,
+ channel_name,
+ sender_timestamp,
+ path,
+ is_outgoing,
+ ):
+ captured["sender_name"] = sender_name
+ captured["sender_key"] = sender_key
+ return None
+
+ mod = BotModule("test", {"code": "def bot(**k): pass"}, name="Test")
+
+ with (
+ patch("app.fanout.bot_exec.execute_bot_code", side_effect=fake_execute),
+ patch(
+ "app.fanout.bot_exec._bot_semaphore",
+ MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
+ ),
+ patch("app.fanout.bot.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.repository.ContactRepository") as mock_contact,
+ ):
+ mock_contact.get_by_key = AsyncMock(return_value=None)
+ await mod._run_for_message(
+ {
+ "type": "PRIV",
+ "conversation_key": "pk1",
+ "sender_name": "PayloadAlice",
+ "sender_key": "pk1",
+ "text": "hello",
+ "outgoing": False,
+ }
+ )
+
+ assert captured["sender_name"] == "PayloadAlice"
+ assert captured["sender_key"] == "pk1"
+
# ---------------------------------------------------------------------------
# T2: Migration 036, 037, 038 tests
diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py
index 22df2579..e6254d1f 100644
--- a/tests/test_fanout_integration.py
+++ b/tests/test_fanout_integration.py
@@ -196,6 +196,54 @@ class TestFanoutMqttIntegration:
assert "alpha/dm:pk1" in topics
assert "beta/dm:pk1" in topics
+ @pytest.mark.asyncio
+ async def test_private_mqtt_preserves_full_message_payload(self, mqtt_broker, integration_db):
+ """Private MQTT publishes the full message payload without dropping fields."""
+ from unittest.mock import patch
+
+ cfg = await FanoutConfigRepository.create(
+ config_type="mqtt_private",
+ name="Full Payload",
+ config=_private_config(mqtt_broker.port, "mesh"),
+ scope={"messages": "all", "raw_packets": "all"},
+ enabled=True,
+ )
+
+ payload = {
+ "type": "CHAN",
+ "conversation_key": "ch1",
+ "channel_name": "#general",
+ "text": "Alice: hello mqtt",
+ "sender_name": "Alice",
+ "sender_key": "ab" * 32,
+ "sender_timestamp": 1700000000,
+ "received_at": 1700000001,
+ "paths": [{"path": "aabb", "received_at": 1700000001}],
+ "outgoing": False,
+ "acked": 2,
+ }
+
+ manager = FanoutManager()
+ with (
+ patch("app.fanout.mqtt_base._broadcast_health"),
+ patch("app.websocket.broadcast_success"),
+ patch("app.websocket.broadcast_error"),
+ patch("app.websocket.broadcast_health"),
+ ):
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(payload)
+ messages = await mqtt_broker.wait_for(1)
+ finally:
+ await manager.stop_all()
+
+ assert len(messages) == 1
+ topic, body = messages[0]
+ assert topic == "mesh/gm:ch1"
+ assert body == payload
+
@pytest.mark.asyncio
async def test_one_disabled_only_enabled_receives(self, mqtt_broker, integration_db):
"""Disabled integration must not publish any messages."""
@@ -565,6 +613,44 @@ class TestFanoutWebhookIntegration:
assert len(results) == 1
assert results[0]["headers"].get("x-custom") == "my-value"
+ @pytest.mark.asyncio
+ async def test_webhook_preserves_full_message_payload(self, webhook_server, integration_db):
+ """Webhook delivers the full message payload body without dropping fields."""
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Full Payload Hook",
+ config=_webhook_config(webhook_server.port),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ payload = {
+ "type": "CHAN",
+ "conversation_key": "ch1",
+ "channel_name": "#general",
+ "text": "Alice: hello webhook",
+ "sender_name": "Alice",
+ "sender_key": "ab" * 32,
+ "sender_timestamp": 1700000000,
+ "received_at": 1700000001,
+ "paths": [{"path": "aabb", "received_at": 1700000001}],
+ "outgoing": False,
+ "acked": 2,
+ }
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg["id"])
+
+ await manager.broadcast_message(payload)
+ results = await webhook_server.wait_for(1)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) == 1
+ assert results[0]["body"] == payload
+
@pytest.mark.asyncio
async def test_webhook_hmac_signature(self, webhook_server, integration_db):
"""Webhook sends HMAC-SHA256 signature when hmac_secret is configured."""
diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py
index d789a134..0e9959ce 100644
--- a/tests/test_send_messages.py
+++ b/tests/test_send_messages.py
@@ -157,6 +157,7 @@ class TestOutgoingChannelBroadcast:
assert data["type"] == "CHAN"
assert data["conversation_key"] == chan_key.upper()
assert data["sender_name"] == "MyNode"
+ assert data["channel_name"] == "#general"
@pytest.mark.asyncio
async def test_send_channel_msg_response_includes_current_ack_count(self, test_db):
@@ -177,6 +178,7 @@ class TestOutgoingChannelBroadcast:
# Fresh message has acked=0
assert message.id is not None
assert message.acked == 0
+ assert message.channel_name == "#acked"
@pytest.mark.asyncio
async def test_send_channel_msg_includes_sender_key(self, test_db):
@@ -498,6 +500,7 @@ class TestResendChannelMessage:
assert event_type == "message"
assert event_data["id"] == result["message_id"]
assert event_data["outgoing"] is True
+ assert event_data["channel_name"] == "#broadcast"
@pytest.mark.asyncio
async def test_resend_byte_perfect_still_enforces_window(self, test_db):
From 8ffae50b87d8487b1db62e0262933f4972dcf1ec Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 6 Mar 2026 16:06:13 -0800
Subject: [PATCH 28/28] Add some brutal tests for webhooks
---
tests/test_fanout_integration.py | 255 +++++++++++++++++++++++++++++++
1 file changed, 255 insertions(+)
diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py
index e6254d1f..81f98fe1 100644
--- a/tests/test_fanout_integration.py
+++ b/tests/test_fanout_integration.py
@@ -882,6 +882,88 @@ class TestFanoutWebhookIntegration:
assert "a" in hook_ids
assert "b" in hook_ids
+ @pytest.mark.asyncio
+ async def test_two_webhooks_with_different_channel_scopes_receive_only_matches(
+ self, webhook_server, integration_db
+ ):
+ """Scoped webhooks run in parallel and each receive only their selected room."""
+ cfg_a = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Hook A",
+ config=_webhook_config(webhook_server.port, extra_headers={"X-Hook-Id": "a"}),
+ scope={"messages": {"channels": ["ch-a"], "contacts": "none"}, "raw_packets": "none"},
+ enabled=True,
+ )
+ cfg_b = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Hook B",
+ config=_webhook_config(webhook_server.port, extra_headers={"X-Hook-Id": "b"}),
+ scope={"messages": {"channels": ["ch-b"], "contacts": "none"}, "raw_packets": "none"},
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, cfg_a["id"])
+ await _wait_connected(manager, cfg_b["id"])
+
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch-a", "text": "room a"}
+ )
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch-b", "text": "room b"}
+ )
+ await manager.broadcast_message(
+ {"type": "CHAN", "conversation_key": "ch-c", "text": "room c"}
+ )
+
+ results = await webhook_server.wait_for(2)
+ await asyncio.sleep(0.3)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) == 2
+ seen = {(r["headers"].get("x-hook-id"), r["body"]["conversation_key"]) for r in results}
+ assert ("a", "ch-a") in seen
+ assert ("b", "ch-b") in seen
+
+ @pytest.mark.asyncio
+ async def test_fifty_webhooks_same_target_all_deliver(self, webhook_server, integration_db):
+ """A large number of webhook modules targeting one endpoint all deliver."""
+ config_ids: list[str] = []
+ webhook_count = 50
+
+ for i in range(webhook_count):
+ cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name=f"Hook {i}",
+ config=_webhook_config(webhook_server.port, extra_headers={"X-Hook-Id": str(i)}),
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+ config_ids.append(cfg["id"])
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ for config_id in config_ids:
+ await _wait_connected(manager, config_id)
+
+ await manager.broadcast_message(
+ {"type": "PRIV", "conversation_key": "pk1", "text": "fanout storm"}
+ )
+
+ results = await webhook_server.wait_for(webhook_count, timeout=10.0)
+ await asyncio.sleep(0.5)
+ finally:
+ await manager.stop_all()
+
+ assert len(results) == webhook_count
+ hook_ids = {r["headers"].get("x-hook-id") for r in results}
+ assert hook_ids == {str(i) for i in range(webhook_count)}
+ assert all(r["body"]["text"] == "fanout storm" for r in results)
+
@pytest.mark.asyncio
async def test_webhook_disable_stops_delivery(self, webhook_server, integration_db):
"""Disabling a webhook stops delivery immediately."""
@@ -1284,6 +1366,179 @@ class TestFanoutAppriseIntegration:
body_text = str(apprise_capture_server.received[0])
assert "included" in body_text
+ @pytest.mark.asyncio
+ async def test_two_apprise_modules_with_different_channel_scopes_receive_only_matches(
+ self, integration_db
+ ):
+ """Scoped Apprise modules run in parallel and each receive only their selected room."""
+ server_a = AppriseJsonCaptureServer()
+ server_b = AppriseJsonCaptureServer()
+ await server_a.start()
+ await server_b.start()
+ try:
+ cfg_a = await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="Apprise A",
+ config={
+ "urls": f"json://127.0.0.1:{server_a.port}",
+ "include_path": False,
+ },
+ scope={
+ "messages": {"channels": ["ch-a"], "contacts": "none"},
+ "raw_packets": "none",
+ },
+ enabled=True,
+ )
+ cfg_b = await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="Apprise B",
+ config={
+ "urls": f"json://127.0.0.1:{server_b.port}",
+ "include_path": False,
+ },
+ scope={
+ "messages": {"channels": ["ch-b"], "contacts": "none"},
+ "raw_packets": "none",
+ },
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ assert cfg_a["id"] in manager._modules
+ assert cfg_b["id"] in manager._modules
+
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch-a",
+ "channel_name": "#a",
+ "text": "room a",
+ "sender_name": "Alice",
+ }
+ )
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch-b",
+ "channel_name": "#b",
+ "text": "room b",
+ "sender_name": "Bob",
+ }
+ )
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch-c",
+ "channel_name": "#c",
+ "text": "room c",
+ "sender_name": "Carol",
+ }
+ )
+
+ results_a = await server_a.wait_for(1)
+ results_b = await server_b.wait_for(1)
+ await asyncio.sleep(1.0)
+ finally:
+ await manager.stop_all()
+
+ assert len(results_a) == 1
+ assert len(results_b) == 1
+ assert "#a" in str(results_a[0])
+ assert "room a" in str(results_a[0])
+ assert "#b" in str(results_b[0])
+ assert "room b" in str(results_b[0])
+ finally:
+ await server_a.stop()
+ await server_b.stop()
+
+ @pytest.mark.asyncio
+ async def test_webhook_and_apprise_with_different_channel_scopes_receive_only_matches(
+ self, integration_db
+ ):
+ """Webhook and Apprise dispatch in parallel and each honor their own room scope."""
+ webhook = WebhookCaptureServer()
+ apprise = AppriseJsonCaptureServer()
+ await webhook.start()
+ await apprise.start()
+ try:
+ webhook_cfg = await FanoutConfigRepository.create(
+ config_type="webhook",
+ name="Room A Hook",
+ config=_webhook_config(webhook.port),
+ scope={
+ "messages": {"channels": ["ch-a"], "contacts": "none"},
+ "raw_packets": "none",
+ },
+ enabled=True,
+ )
+ apprise_cfg = await FanoutConfigRepository.create(
+ config_type="apprise",
+ name="Room B Apprise",
+ config={
+ "urls": f"json://127.0.0.1:{apprise.port}",
+ "include_path": False,
+ },
+ scope={
+ "messages": {"channels": ["ch-b"], "contacts": "none"},
+ "raw_packets": "none",
+ },
+ enabled=True,
+ )
+
+ manager = FanoutManager()
+ try:
+ await manager.load_from_db()
+ await _wait_connected(manager, webhook_cfg["id"])
+ assert apprise_cfg["id"] in manager._modules
+
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch-a",
+ "channel_name": "#a",
+ "text": "room a",
+ "sender_name": "Alice",
+ }
+ )
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch-b",
+ "channel_name": "#b",
+ "text": "room b",
+ "sender_name": "Bob",
+ }
+ )
+ await manager.broadcast_message(
+ {
+ "type": "CHAN",
+ "conversation_key": "ch-c",
+ "channel_name": "#c",
+ "text": "room c",
+ "sender_name": "Carol",
+ }
+ )
+
+ webhook_results = await webhook.wait_for(1)
+ apprise_results = await apprise.wait_for(1)
+ await asyncio.sleep(1.0)
+ finally:
+ await manager.stop_all()
+
+ assert len(webhook_results) == 1
+ assert webhook_results[0]["body"]["conversation_key"] == "ch-a"
+ assert webhook_results[0]["body"]["text"] == "room a"
+
+ assert len(apprise_results) == 1
+ apprise_body = str(apprise_results[0])
+ assert "#b" in apprise_body
+ assert "room b" in apprise_body
+ finally:
+ await webhook.stop()
+ await apprise.stop()
+
@pytest.mark.asyncio
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
"""Apprise with include_path=True shows routing hops in the body."""