diff --git a/CHANGELOG.md b/CHANGELOG.md index df26c23..1070eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,56 @@ All notable changes to MeshCore GUI are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). +--- + +## [1.15.0] - 2026-03-16 + +### ADDED +- **BOT panel** (`gui/panels/bot_panel.py`): new dedicated panel in the main menu + (between RX LOG and BBS) with enable toggle, private mode toggle and interactive + channel assignment via checkboxes built from the live device channel list. +- **BotConfigStore** (`services/bot_config_store.py`): persistent bot configuration + per device stored at `~/.meshcore-gui/bot/__bot.json`. Saves enabled + flag, private mode state and selected channel set across restarts. +- **Private mode**: when enabled the bot only replies to pinned contacts. Guard 1.5 + added to `MeshBot.check_and_reply` — reads live from `BotConfigStore` so changes + take effect immediately without restart. +- **Private mode constraint**: private mode can only be activated when at least one + contact is pinned. The toggle is disabled (greyed out) with an explanation label + when no pinned contacts exist; auto-disables if all pins are removed. +- **Interactive channel assignment**: BOT panel shows a checkbox per discovered + channel; selection persisted via `BotConfigStore.set_channels()` on Save. +- **`BOT_DIR`** config constant (`~/.meshcore-gui/bot/`) centralising the storage + root for bot configuration files. + +### CHANGED +- **BOT toggle removed from ActionsPanel**: `actions_panel.py` no longer contains + the BOT checkbox or `set_bot_enabled` wiring; the panel is now solely for Refresh, + Advertise and Set device name. +- **`MeshBot`** gains two optional constructor arguments: `config_store` + (`BotConfigStore`) for live channel/private-mode reads, and `pinned_check` + (`Callable[[str], bool]`) for pin lookups. Fully backwards-compatible — both + default to `None` and existing behaviour is preserved when absent. +- **`MeshBot.check_and_reply`** gains optional `sender_pubkey` kwarg used by Guard 1.5. +- **`_BaseWorker`** now accepts optional `pin_store` kwarg; wires `pinned_check` and + `config_store` into `MeshBot` at construction time. +- **`create_worker`** forwards optional `pin_store` kwarg to subworkers. +- **`DashboardPage`** receives `BotConfigStore` instance; `ActionsPanel` call no + longer passes `set_bot_enabled`. + +### IMPACT +- `ble/events.py`: both `check_and_reply` call sites now pass `sender_pubkey=`. +- `ble/worker.py`: `_BaseWorker`, `SerialWorker`, `BLEWorker`, `create_worker` updated. +- `gui/dashboard.py`: `BotPanel` registered as panel `'bot'`; menu item `🤖 BOT` added. +- `gui/panels/actions_panel.py`: BOT toggle removed; `ActionsPanel.__init__` signature + simplified to `(put_command)`. +- `config.py`: `VERSION` bumped to `1.15.0`; `BOT_DIR` constant added. + +### RATIONALE +Bot functionality was embedded in the Actions panel and had no persistence. Extracting +it to a dedicated panel and a config store aligns with the existing modularity of the +codebase (cf. BBS panel / BbsConfigStore) and enables future extension. Private mode +fulfils the requirement to restrict bot replies to trusted contacts only. --- diff --git a/README.md b/README.md index 5f0763c..83c896a 100644 --- a/README.md +++ b/README.md @@ -732,23 +732,27 @@ If the connection fails (serial or BLE), the GUI remains usable with cached data ### 9.10. Keyword Bot -The built-in bot automatically replies to messages containing recognised keywords. Enable or disable it via the 🤖 BOT checkbox in the filter bar. +The built-in bot automatically replies to messages containing recognised keywords. Configure it via the dedicated **🤖 BOT** menu item. - -**Device name switching:** When the BOT checkbox is enabled, the device name is automatically changed to the configured `BOT_DEVICE_NAME` (default: `;NL-OV-ZWL-STDSHGN-WKC Bot`). The original device name is saved and restored when bot mode is disabled. This allows the mesh network to identify the node as a bot by its name. + +**BOT panel features:** +- **Enable / disable toggle** — activating the bot changes the device name to the configured `BOT_DEVICE_NAME`; disabling restores the original name. +- **Interactive channel assignment** — checkboxes for each discovered channel; selection is saved per device to `~/.meshcore-gui/bot/__bot.json`. +- **Private mode** — when enabled the bot only responds to pinned contacts. The toggle is disabled until at least one contact is pinned; auto-disables if all pins are removed. + +**Device name switching:** When the bot is enabled, the device name is automatically changed to the configured `BOT_DEVICE_NAME` (default: `ZwolsBotje`). The original device name is saved and restored when bot mode is disabled. This allows the mesh network to identify the node as a bot by its name. **Default keywords:** - - | Keyword | Reply | |---------|-------| -| `test` | `, rcvd \| SNR \| path(); ` | +| `test` | `, rcvd \| SNR \| path()` | | `ping` | `Pong!` | | `help` | `test, ping, help` | **Safety guards:** -- Only replies on configured channels (`BOT_CHANNELS`) +- Only replies on configured channels (interactive selection in BOT panel) +- Private mode: optionally restricts replies to pinned contacts only - Ignores own messages and messages from other bots (names ending in "Bot") - Cooldown period between replies (default: 5 seconds) @@ -760,6 +764,7 @@ The built-in bot automatically replies to messages containing recognised keyword ### 9.12. Actions - Refresh data - Send advertisement +- Set device name ## 10. Architecture diff --git a/meshcore_gui/__main__.py b/meshcore_gui/__main__.py index 5eaf7be..758e279 100644 --- a/meshcore_gui/__main__.py +++ b/meshcore_gui/__main__.py @@ -49,6 +49,7 @@ from meshcore_gui.gui.panels.bbs_panel import BbsSettingsPage from meshcore_gui.gui.archive_page import ArchivePage from meshcore_gui.services.pin_store import PinStore from meshcore_gui.services.room_password_store import RoomPasswordStore +from meshcore_gui.services.bot_config_store import BotConfigStore # Global instances (needed by NiceGUI page decorators) @@ -60,13 +61,14 @@ _bbs_config_store_main = None _archive_page = None _pin_store = None _room_password_store = None +_bot_config_store = None @ui.page('/') def _page_dashboard(): """NiceGUI page handler — main dashboard.""" if _shared and _pin_store and _room_password_store: - DashboardPage(_shared, _pin_store, _room_password_store).render() + DashboardPage(_shared, _pin_store, _room_password_store, _bot_config_store).render() @ui.page('/route/{msg_key}') @@ -165,7 +167,7 @@ def main(): Parses CLI arguments, auto-detects the transport, initialises all components and starts the NiceGUI server. """ - global _shared, _dashboard, _route_page, _bbs_settings_page, _archive_page, _pin_store, _room_password_store + global _shared, _dashboard, _route_page, _bbs_settings_page, _archive_page, _pin_store, _room_password_store, _bot_config_store args, flags = _parse_flags(sys.argv[1:]) @@ -266,7 +268,8 @@ def main(): _shared = SharedData(device_id) _pin_store = PinStore(device_id) _room_password_store = RoomPasswordStore(device_id) - _dashboard = DashboardPage(_shared, _pin_store, _room_password_store) + _bot_config_store = BotConfigStore(device_id) + _dashboard = DashboardPage(_shared, _pin_store, _room_password_store, _bot_config_store) _route_page = RoutePage(_shared) _archive_page = ArchivePage(_shared) from meshcore_gui.services.bbs_config_store import BbsConfigStore as _BCS @@ -278,6 +281,7 @@ def main(): _shared, baudrate=config.SERIAL_BAUDRATE, cx_dly=config.SERIAL_CX_DELAY, + pin_store=_pin_store, ) worker.start() diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 4a818f9..af1168a 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -207,6 +207,7 @@ class EventHandler: snr=snr_msg, path_len=decoded.path_length, path_hashes=decoded.path_hashes, + sender_pubkey=sender_pubkey, ) # BBS channel hook: auto-whitelist sender and reply @@ -356,6 +357,7 @@ class EventHandler: channel_idx=ch_idx, snr=snr, path_len=payload.get('path_len', 0), + sender_pubkey=sender_pubkey, ) # ------------------------------------------------------------------ diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index 4b45090..d81a553 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -56,9 +56,11 @@ from meshcore_gui.ble.packet_decoder import PacketDecoder from meshcore_gui.services.bot import BotConfig, MeshBot from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService from meshcore_gui.services.bbs_config_store import BbsConfigStore +from meshcore_gui.services.bot_config_store import BotConfigStore from meshcore_gui.services.cache import DeviceCache from meshcore_gui.services.dedup import DualDeduplicator from meshcore_gui.services.device_identity import write_device_identity +from meshcore_gui.services.pin_store import PinStore # Seconds between background retry attempts for missing channel keys. @@ -76,17 +78,18 @@ def create_worker(device_id: str, shared: SharedDataWriter, **kwargs): """Return the appropriate worker for *device_id*. Keyword arguments are forwarded to the worker constructor - (e.g. ``baudrate``, ``cx_dly`` for serial). + (e.g. ``baudrate``, ``cx_dly`` for serial, ``pin_store`` for all). """ from meshcore_gui.config import is_ble_address if is_ble_address(device_id): - return BLEWorker(device_id, shared) + return BLEWorker(device_id, shared, pin_store=kwargs.get("pin_store")) return SerialWorker( device_id, shared, baudrate=kwargs.get("baudrate", _config.SERIAL_BAUDRATE), cx_dly=kwargs.get("cx_dly", _config.SERIAL_CX_DELAY), + pin_store=kwargs.get("pin_store"), ) @@ -107,7 +110,7 @@ class _BaseWorker(abc.ABC): a broken connection """ - def __init__(self, device_id: str, shared: SharedDataWriter) -> None: + def __init__(self, device_id: str, shared: SharedDataWriter, pin_store: Optional[PinStore] = None) -> None: self.device_id = device_id self.shared = shared self.mc: Optional[MeshCore] = None @@ -117,6 +120,16 @@ class _BaseWorker(abc.ABC): # Local cache (one file per device) self._cache = DeviceCache(device_id) + # Bot config store — persists channel selection and private mode. + self._bot_config_store = BotConfigStore(device_id) + + # Sync persisted bot-enabled flag to SharedData so the bot starts + # in the correct state after a restart. Without this, SharedData + # defaults to bot_enabled=False every run regardless of what the + # user saved in the BOT panel. + if self._bot_config_store.get_settings().enabled: + shared.set_bot_enabled(True) + # Collaborators (created eagerly, wired after connection) self._decoder = PacketDecoder() self._dedup = DualDeduplicator(max_size=200) @@ -124,6 +137,8 @@ class _BaseWorker(abc.ABC): config=BotConfig(), command_sink=shared.put_command, enabled_check=shared.is_bot_enabled, + config_store=self._bot_config_store, + pinned_check=pin_store.is_pinned if pin_store is not None else None, ) # BBS handler — wired directly into EventHandler for DM routing. @@ -723,8 +738,9 @@ class SerialWorker(_BaseWorker): shared: SharedDataWriter, baudrate: int = _config.SERIAL_BAUDRATE, cx_dly: float = _config.SERIAL_CX_DELAY, + pin_store: Optional[PinStore] = None, ) -> None: - super().__init__(port, shared) + super().__init__(port, shared, pin_store=pin_store) self.port = port self.baudrate = baudrate self.cx_dly = cx_dly @@ -866,8 +882,8 @@ class BLEWorker(_BaseWorker): shared: SharedDataWriter for thread-safe communication. """ - def __init__(self, address: str, shared: SharedDataWriter) -> None: - super().__init__(address, shared) + def __init__(self, address: str, shared: SharedDataWriter, pin_store: Optional[PinStore] = None) -> None: + super().__init__(address, shared, pin_store=pin_store) self.address = address # BLE PIN agent — imported lazily so serial-only installs diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index f489cbf..9b437d4 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.14.2" +VERSION: str = "1.15.0" # ============================================================================== @@ -75,6 +75,10 @@ DATA_DIR: Path = Path.home() / ".meshcore-gui" # Log directory for debug and error log files. LOG_DIR: Path = DATA_DIR / "logs" +# Bot configuration directory — bot JSON files live here. +# File naming: __bot.json (e.g. _dev_ttyUSB1_bot.json). +BOT_DIR: Path = DATA_DIR / "bot" + # Log file path (rotating: max 5 MB per file, 3 backups = 20 MB total). LOG_FILE: Path = LOG_DIR / "meshcore_gui.log" diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py index 411e67c..ca2c821 100644 --- a/meshcore_gui/gui/dashboard.py +++ b/meshcore_gui/gui/dashboard.py @@ -17,6 +17,7 @@ from meshcore_gui.core.protocols import SharedDataReader from meshcore_gui.gui.panels import ( ActionsPanel, BbsPanel, + BotPanel, ContactsPanel, DevicePanel, MapPanel, @@ -27,6 +28,7 @@ from meshcore_gui.gui.panels import ( from meshcore_gui.gui.archive_page import ArchivePage from meshcore_gui.services.bbs_config_store import BbsConfigStore from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService +from meshcore_gui.services.bot_config_store import BotConfigStore from meshcore_gui.services.pin_store import PinStore from meshcore_gui.services.room_password_store import RoomPasswordStore @@ -267,6 +269,7 @@ _STANDALONE_ITEMS = [ ('\U0001f4e1', 'DEVICE', 'device'), ('\u26a1', 'ACTIONS', 'actions'), ('\U0001f4ca', 'RX LOG', 'rxlog'), + ('\U0001f916', 'BOT', 'bot'), ('\U0001f4cb', 'BBS', 'bbs'), ] @@ -294,7 +297,7 @@ class DashboardPage: shared: SharedDataReader for data access and command dispatch. """ - def __init__(self, shared: SharedDataReader, pin_store: PinStore, room_password_store: RoomPasswordStore) -> None: + def __init__(self, shared: SharedDataReader, pin_store: PinStore, room_password_store: RoomPasswordStore, bot_config_store: BotConfigStore | None = None) -> None: self._shared = shared self._pin_store = pin_store self._room_password_store = room_password_store @@ -306,6 +309,11 @@ class DashboardPage: self._bbs_service, self._bbs_config_store ) + # Bot config store — injected from __main__ so dashboard and worker + # share the same device-scoped file. Falls back to a default-scoped + # instance when not provided (e.g. unit tests, legacy callers). + self._bot_config_store = bot_config_store if bot_config_store is not None else BotConfigStore() + # Panels (created fresh on each render) self._device: DevicePanel | None = None self._contacts: ContactsPanel | None = None @@ -315,6 +323,7 @@ class DashboardPage: self._rxlog: RxLogPanel | None = None self._room_server: RoomServerPanel | None = None self._bbs: BbsPanel | None = None + self._bot: BotPanel | None = None # Header status label self._status_label = None @@ -358,10 +367,16 @@ class DashboardPage: self._contacts = ContactsPanel(put_cmd, self._pin_store, self._shared.set_auto_add_enabled, self._on_add_room_server) self._map = MapPanel() self._messages = MessagesPanel(put_cmd) - self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled) + self._actions = ActionsPanel(put_cmd) self._rxlog = RxLogPanel() self._room_server = RoomServerPanel(put_cmd, self._room_password_store) self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store) + self._bot = BotPanel( + put_cmd, + self._shared.set_bot_enabled, + self._bot_config_store, + self._pin_store, + ) # Inject DOMCA theme (fonts + CSS variables) ui.add_head_html(_DOMCA_HEAD) @@ -523,6 +538,7 @@ class DashboardPage: ('rxlog', self._rxlog), ('rooms', self._room_server), ('bbs', self._bbs), + ('bot', self._bot), ] for panel_id, panel_obj in panel_defs: @@ -752,6 +768,9 @@ class DashboardPage: elif self._active_panel == 'bbs': if self._bbs: self._bbs.update(data) + elif self._active_panel == 'bot': + if self._bot: + self._bot.update(data) # ------------------------------------------------------------------ # Room Server callback (from ContactsPanel) @@ -838,6 +857,10 @@ class DashboardPage: if self._bbs: self._bbs.update(data) + elif self._active_panel == 'bot': + if self._bot: + self._bot.update(data) + # Signal worker that GUI is ready for data if is_first and data['channels'] and data['contacts']: self._shared.mark_gui_initialized() diff --git a/meshcore_gui/gui/panels/__init__.py b/meshcore_gui/gui/panels/__init__.py index f9245f4..d644fd4 100644 --- a/meshcore_gui/gui/panels/__init__.py +++ b/meshcore_gui/gui/panels/__init__.py @@ -16,3 +16,4 @@ from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401 from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401 from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401 from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401 +from meshcore_gui.gui.panels.bot_panel import BotPanel # noqa: F401 diff --git a/meshcore_gui/gui/panels/actions_panel.py b/meshcore_gui/gui/panels/actions_panel.py index e52722c..88a6e88 100644 --- a/meshcore_gui/gui/panels/actions_panel.py +++ b/meshcore_gui/gui/panels/actions_panel.py @@ -1,4 +1,4 @@ -"""Actions panel — refresh, advertise buttons and bot toggle.""" +"""Actions panel -- refresh, advertise buttons and device name setter.""" from typing import Callable, Dict @@ -6,19 +6,18 @@ from nicegui import ui class ActionsPanel: - """Action buttons and bot toggle in the right column. + """Action buttons in the right column. + + Provides Refresh, Advertise and Set device name controls. + The BOT toggle has been moved to the dedicated BotPanel. Args: - put_command: Callable to enqueue a command dict for the worker. - set_bot_enabled: Callable to toggle the bot in SharedData. + put_command: Callable to enqueue a command dict for the worker. """ - def __init__(self, put_command: Callable[[Dict], None], set_bot_enabled: Callable[[bool], None]) -> None: + def __init__(self, put_command: Callable[[Dict], None]) -> None: self._put_command = put_command - self._set_bot_enabled = set_bot_enabled - self._bot_checkbox = None self._name_input = None - self._suppress_bot_event = False def render(self) -> None: with ui.card().classes('w-full'): @@ -32,24 +31,9 @@ class ActionsPanel: placeholder='Set device name', ).classes('flex-grow') ui.button('Set', on_click=self._set_name) - self._bot_checkbox = ui.checkbox( - '🤖 BOT', - value=False, - on_change=lambda e: self._on_bot_toggle(e.value), - ) - self._bot_checkbox.tooltip('Enabling BOT changes the device name') - ui.label('⚠️ BOT changes device name').classes( - 'text-xs text-amber-500' - ) - def update(self, data: Dict) -> None: - """Update BOT checkbox state from snapshot data.""" - if self._bot_checkbox is not None: - desired = data.get('bot_enabled', False) - if self._bot_checkbox.value != desired: - self._suppress_bot_event = True - self._bot_checkbox.value = desired - self._suppress_bot_event = False + def update(self, data: Dict) -> None: # noqa: ARG002 + """No-op — actions panel has no dynamic state after bot removal.""" def _refresh(self) -> None: self._put_command({'action': 'refresh'}) @@ -57,16 +41,6 @@ class ActionsPanel: def _advert(self) -> None: self._put_command({'action': 'send_advert'}) - def _on_bot_toggle(self, value: bool) -> None: - """Handle BOT checkbox toggle: update flag and queue name change.""" - if self._suppress_bot_event: - return - self._set_bot_enabled(value) - self._put_command({ - 'action': 'set_device_name', - 'bot_enabled': value, - }) - def _set_name(self) -> None: """Send an explicit device name update.""" if self._name_input is None: diff --git a/meshcore_gui/gui/panels/bot_panel.py b/meshcore_gui/gui/panels/bot_panel.py new file mode 100644 index 0000000..7cdad28 --- /dev/null +++ b/meshcore_gui/gui/panels/bot_panel.py @@ -0,0 +1,248 @@ +"""Bot panel -- enable/disable, private mode and channel assignment for MeshBot.""" + +from typing import Callable, Dict, List, Optional, Set + +from nicegui import ui + +from meshcore_gui.services.bot_config_store import BotConfigStore +from meshcore_gui.services.pin_store import PinStore + + +class BotPanel: + """Dedicated BOT configuration panel. + + Provides: + - Enable / disable toggle (immediately reflected in SharedData and + persisted to BotConfigStore). + - Private mode toggle: bot only replies to pinned contacts. + Disabled (greyed out) when no pinned contacts exist; auto-disabled + when the last pin is removed. + - Interactive channel assignment via checkboxes built from the + device channel list. Selection is persisted on Save. + + Reference components: + - Toggle styling: BOT checkbox in FilterPanel. + - Button styling: buttons in ActionsPanel. + + Args: + put_command: Callable to enqueue a command dict for the worker. + set_bot_enabled: Callable to update the bot enabled flag in SharedData. + bot_config_store: BotConfigStore instance for persistence. + pin_store: PinStore instance to check pinned contact count. + """ + + def __init__( + self, + put_command: Callable[[Dict], None], + set_bot_enabled: Callable[[bool], None], + bot_config_store: BotConfigStore, + pin_store: PinStore, + ) -> None: + self._put_command = put_command + self._set_bot_enabled = set_bot_enabled + self._store = bot_config_store + self._pin_store = pin_store + + # UI refs + self._enabled_checkbox = None + self._private_mode_checkbox = None + self._private_mode_warning = None + self._channel_container = None + self._channel_checkboxes: Dict[int, object] = {} # idx -> ui.checkbox + + # State + self._last_ch_fingerprint: tuple = () + self._suppress_enabled_event: bool = False + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the bot panel layout.""" + settings = self._store.get_settings() + + with ui.card().classes('w-full'): + ui.label('🤖 BOT').classes('font-bold text-gray-600') + + # -- Enabled toggle ---------------------------------------- + with ui.row().classes('w-full items-center gap-2'): + self._enabled_checkbox = ui.checkbox( + 'Bot enabled', + value=settings.enabled, + on_change=lambda e: self._on_enabled_toggle(e.value), + ) + self._enabled_checkbox.tooltip('Enabling BOT changes the device name') + ui.label('⚠️ BOT changes device name').classes( + 'text-xs text-amber-500' + ) + + ui.separator() + + # -- Private mode toggle ----------------------------------- + has_pins = len(self._pin_store.get_pinned()) > 0 + effective_private = settings.private_mode and has_pins + + with ui.column().classes('w-full gap-1'): + with ui.row().classes('w-full items-center gap-2'): + self._private_mode_checkbox = ui.checkbox( + 'Private mode — pinned contacts only', + value=effective_private, + on_change=lambda e: self._on_private_mode_toggle(e.value), + ) + self._private_mode_checkbox.tooltip( + 'When enabled the bot only responds to pinned contacts' + ) + if not has_pins: + self._private_mode_checkbox.disable() + + self._private_mode_warning = ui.label( + '⚠️ No pinned contacts — pin contacts first to enable private mode' + ).classes('text-xs text-amber-500') + self._private_mode_warning.set_visibility(not has_pins) + + ui.separator() + + # -- Channel assignment ------------------------------------ + with ui.row().classes('w-full items-center gap-2'): + ui.label('Channels:').classes('text-sm text-gray-600') + self._channel_container = ui.row().classes('gap-2 flex-wrap') + + with ui.row().classes('w-full justify-end'): + ui.button( + '💾 Save channels', + on_click=self._save_channels, + ).tooltip('Save channel selection for the bot') + + # ------------------------------------------------------------------ + # Update (called from dashboard timer) + # ------------------------------------------------------------------ + + def update(self, data: Dict) -> None: + """Update panel state from snapshot data. + + Rebuilds channel checkboxes when the channel list changes. + Updates the private-mode toggle disabled state based on + the current pinned-contact count. + + Args: + data: Snapshot dict from SharedData. + """ + self._sync_enabled(data) + self._sync_private_mode() + self._rebuild_channels_if_changed(data.get('channels', [])) + + # ------------------------------------------------------------------ + # Toggle handlers + # ------------------------------------------------------------------ + + def _on_enabled_toggle(self, value: bool) -> None: + """Handle bot enabled toggle: update SharedData and persist.""" + if self._suppress_enabled_event: + return + self._set_bot_enabled(value) + self._store.set_enabled(value) + self._put_command({ + 'action': 'set_device_name', + 'bot_enabled': value, + }) + + def _on_private_mode_toggle(self, value: bool) -> None: + """Handle private mode toggle: validate pins and persist.""" + has_pins = len(self._pin_store.get_pinned()) > 0 + if value and not has_pins: + # Guard: private mode cannot be enabled without pinned contacts. + if self._private_mode_checkbox is not None: + self._private_mode_checkbox.value = False + return + self._store.set_private_mode(value) + + # ------------------------------------------------------------------ + # Sync helpers + # ------------------------------------------------------------------ + + def _sync_enabled(self, data: Dict) -> None: + """Sync the enabled checkbox with the snapshot state.""" + if self._enabled_checkbox is None: + return + desired = data.get('bot_enabled', False) + if self._enabled_checkbox.value != desired: + self._suppress_enabled_event = True + self._enabled_checkbox.value = desired + self._suppress_enabled_event = False + + def _sync_private_mode(self) -> None: + """Update private-mode toggle enabled/disabled state. + + When pinned contacts are removed, auto-disable private mode + and grey out the checkbox. + """ + if self._private_mode_checkbox is None: + return + + has_pins = len(self._pin_store.get_pinned()) > 0 + + if has_pins: + self._private_mode_checkbox.enable() + if self._private_mode_warning is not None: + self._private_mode_warning.set_visibility(False) + else: + # Auto-disable private mode if all pins were removed. + if self._private_mode_checkbox.value: + self._private_mode_checkbox.value = False + self._store.set_private_mode(False) + self._private_mode_checkbox.disable() + if self._private_mode_warning is not None: + self._private_mode_warning.set_visibility(True) + + def _rebuild_channels_if_changed(self, channels: List[Dict]) -> None: + """Rebuild channel checkboxes when the channel list changes. + + Pre-selects channels that are in the stored configuration. + On first run (no stored channels), all channels are pre-selected. + + Args: + channels: List of channel dicts with 'idx' and 'name' keys. + """ + if not self._channel_container or not channels: + return + + fingerprint = tuple((ch['idx'], ch['name']) for ch in channels) + if fingerprint == self._last_ch_fingerprint: + return + + self._last_ch_fingerprint = fingerprint + saved_channels: Set[int] = self._store.get_settings().channels + + self._channel_container.clear() + self._channel_checkboxes = {} + + with self._channel_container: + for ch in channels: + idx = ch['idx'] + name = ch['name'] + # First run (no saved channels) -> all selected. + is_checked = (idx in saved_channels) if saved_channels else True + cb = ui.checkbox( + f"[{idx}] {name}", + value=is_checked, + ) + self._channel_checkboxes[idx] = cb + + # ------------------------------------------------------------------ + # Save + # ------------------------------------------------------------------ + + def _save_channels(self) -> None: + """Persist the current channel checkbox selection to BotConfigStore.""" + selected: Set[int] = { + idx + for idx, cb in self._channel_checkboxes.items() + if cb.value + } + self._store.set_channels(selected) + ui.notify( + f'Bot channels saved: {", ".join(f"[{i}]" for i in sorted(selected)) or "none"}', + type='positive', + position='top', + ) diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py index d31a2c2..eba2153 100644 --- a/meshcore_gui/services/bot.py +++ b/meshcore_gui/services/bot.py @@ -19,14 +19,25 @@ wired directly into :class:`~meshcore_gui.ble.events.EventHandler`. They never pass through ``MeshBot`` — the bot is a pure keyword/channel-message responder only. + +Private mode +~~~~~~~~~~~~ +When ``private_mode`` is enabled in :class:`BotConfigStore`, the bot +only responds to senders whose public-key prefix is found in the +:class:`~meshcore_gui.services.pin_store.PinStore`. Private mode can +only be activated when at least one contact is pinned; this constraint +is enforced in the UI layer. """ import time from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Optional from meshcore_gui.config import debug_print +if TYPE_CHECKING: + from meshcore_gui.services.bot_config_store import BotConfigStore + # ============================================================================== # Bot defaults (previously in config.py) @@ -41,7 +52,7 @@ BOT_NAME: str = "ZwolsBotje" # Minimum seconds between two bot replies (prevents reply-storms). BOT_COOLDOWN_SECONDS: float = 5.0 -# Keyword → reply template mapping. +# Keyword -> reply template mapping. # Available variables: {bot}, {sender}, {snr}, {path} # The bot checks whether the incoming message text *contains* the keyword # (case-insensitive). First match wins. @@ -54,13 +65,18 @@ BOT_KEYWORDS: Dict[str, str] = { @dataclass class BotConfig: - """Configuration for :class:`MeshBot`. + """Static configuration for :class:`MeshBot`. + + This dataclass holds default values. When a :class:`BotConfigStore` + is provided to :class:`MeshBot`, runtime channel selection and + private-mode state are read from the store instead. Attributes: - channels: Channel indices to listen on. + channels: Default channel indices to listen on (fallback + when BotConfigStore has no saved channel selection). name: Display name prepended to replies. cooldown_seconds: Minimum seconds between replies. - keywords: Keyword → reply template mapping. + keywords: Keyword -> reply template mapping. """ channels: frozenset = field(default_factory=lambda: frozenset(BOT_CHANNELS)) @@ -72,17 +88,26 @@ class BotConfig: class MeshBot: """Keyword-triggered auto-reply bot. - The bot checks incoming messages against a set of keyword → template + The bot checks incoming messages against a set of keyword -> template pairs. When a keyword is found (case-insensitive substring match, first match wins), the template is expanded and queued as a channel message via *command_sink*. Args: - config: Bot configuration. + config: Static bot configuration (defaults). command_sink: Callable that enqueues a command dict for the worker (typically ``shared.put_command``). enabled_check: Callable that returns ``True`` when the bot is enabled (typically ``shared.is_bot_enabled``). + config_store: Optional :class:`BotConfigStore` for persistent + channel selection and private-mode flag. When + provided, channel filtering and private-mode are + read from the store on every call to + :meth:`check_and_reply`. + pinned_check: Optional callable ``(pubkey: str) -> bool`` that + returns ``True`` when *pubkey* belongs to a pinned + contact. Required for private mode to function; + when absent, private mode blocks all unknown senders. """ def __init__( @@ -90,10 +115,14 @@ class MeshBot: config: BotConfig, command_sink: Callable[[Dict], None], enabled_check: Callable[[], bool], + config_store: Optional["BotConfigStore"] = None, + pinned_check: Optional[Callable[[str], bool]] = None, ) -> None: self._config = config self._sink = command_sink self._enabled = enabled_check + self._config_store = config_store + self._pinned_check = pinned_check self._last_reply: float = 0.0 def check_and_reply( @@ -104,27 +133,56 @@ class MeshBot: snr: Optional[float], path_len: int, path_hashes: Optional[List[str]] = None, + sender_pubkey: Optional[str] = None, ) -> None: """Evaluate an incoming channel message and queue a reply if appropriate. Guards (in order): - 1. Bot is enabled (checkbox in GUI). - 2. Message is on the configured channel. - 3. Sender is not the bot itself. - 4. Sender name does not end with ``'Bot'`` (prevent loops). - 5. Cooldown period has elapsed. - 6. Message text contains a recognised keyword. + 1. Bot is enabled (checkbox in GUI). + 1.5. Private mode: sender must be a pinned contact. + 2. Message is on the configured channel. + 3. Sender is not the bot itself. + 4. Sender name does not end with 'Bot' (prevent loops). + 5. Cooldown period has elapsed. + 6. Message text contains a recognised keyword. Note: BBS commands (``!bbs``, ``!p``, ``!r``) are NOT handled here. They arrive as DMs and are handled by ``BbsCommandHandler`` directly inside ``EventHandler.on_contact_msg``. + + Args: + sender: Display name of the message sender. + text: Message body. + channel_idx: Channel index the message arrived on. + snr: Signal-to-noise ratio (dB), or ``None``. + path_len: Number of hops in the path. + path_hashes: Optional list of 2-char hop hashes. + sender_pubkey: Optional public-key prefix of the sender. + Used for private-mode pin lookup. """ # Guard 1: enabled? if not self._enabled(): return + # Guard 1.5: private mode -- only respond to pinned contacts. + # Reads live from config_store so changes take effect immediately. + if self._config_store is not None: + settings = self._config_store.get_settings() + if settings.private_mode: + if not sender_pubkey: + debug_print( + f"BOT: private mode active, no pubkey for '{sender}' -- skip" + ) + return + if self._pinned_check is None or not self._pinned_check(sender_pubkey): + debug_print( + f"BOT: private mode active, '{sender}' not pinned -- skip" + ) + return + # Guard 2: correct channel? - if channel_idx not in self._config.channels: + active_channels = self._get_active_channels() + if channel_idx not in active_channels: return # Guard 3: own messages? @@ -190,12 +248,28 @@ class MeshBot: # Helpers # ------------------------------------------------------------------ + def _get_active_channels(self) -> frozenset: + """Return the effective channel set. + + When a :class:`BotConfigStore` is present, its channel selection + is always authoritative — including an empty set (bot silent on + all channels until the user saves a selection in the BOT panel). + The hardcoded :attr:`BotConfig.channels` fallback is only used + when no config store is wired (e.g. in unit tests). + + Returns: + Frozenset of active channel indices. + """ + if self._config_store is not None: + return frozenset(self._config_store.get_settings().channels) + return self._config.channels + @staticmethod def _format_path( path_len: int, path_hashes: Optional[List[str]], ) -> str: - """Format path info as ``path(N); ``path(0)``.""" + """Format path info as ``path(N)`` or ``path(0)``.""" if not path_len: return "path(0)" return f"path({path_len})" diff --git a/meshcore_gui/services/bot_config_store.py b/meshcore_gui/services/bot_config_store.py new file mode 100644 index 0000000..71dd35e --- /dev/null +++ b/meshcore_gui/services/bot_config_store.py @@ -0,0 +1,165 @@ +""" +Bot configuration store for MeshCore GUI. + +Persists bot settings to +``~/.meshcore-gui/bot/__bot.json``. + +The filename mirrors the PinStore convention so configuration is +always bound to a specific device. + +Example path for ``/dev/ttyUSB1``:: + + ~/.meshcore-gui/bot/_dev_ttyUSB1_bot.json + +Thread safety +~~~~~~~~~~~~~ +All public methods acquire an internal ``threading.Lock``. +""" + +import json +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Set + +from meshcore_gui.config import BOT_DIR, debug_print + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BotSettings: + """Persistent bot settings for a single device. + + Attributes: + enabled: Whether the bot is active. + private_mode: When True, bot only replies to pinned contacts. + channels: Set of channel indices the bot listens on. + Empty set means "use BotConfig defaults". + """ + + enabled: bool = False + private_mode: bool = False + channels: Set[int] = field(default_factory=set) + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + +class BotConfigStore: + """Persistent storage for bot settings per device. + + Args: + device_id: Device identifier string used to derive the filename. + May be empty for a device-agnostic default store. + """ + + def __init__(self, device_id: str = "") -> None: + self._lock = threading.Lock() + + safe_name = ( + device_id + .replace("literal:", "") + .replace(":", "_") + .replace("/", "_") + ) if device_id else "default" + + self._path: Path = BOT_DIR / f"_{safe_name}_bot.json" + self._settings: BotSettings = BotSettings() + self._load() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def get_settings(self) -> BotSettings: + """Return a shallow copy of the current bot settings. + + Returns: + Copy of the stored :class:`BotSettings`. + """ + with self._lock: + return BotSettings( + enabled=self._settings.enabled, + private_mode=self._settings.private_mode, + channels=set(self._settings.channels), + ) + + def set_enabled(self, value: bool) -> None: + """Set the bot enabled flag and persist to disk. + + Args: + value: New enabled state. + """ + with self._lock: + self._settings.enabled = value + self._save() + debug_print(f"BotConfigStore: enabled={value}") + + def set_private_mode(self, value: bool) -> None: + """Set private mode and persist to disk. + + Args: + value: New private mode state. + """ + with self._lock: + self._settings.private_mode = value + self._save() + debug_print(f"BotConfigStore: private_mode={value}") + + def set_channels(self, channels: Set[int]) -> None: + """Set the bot channel set and persist to disk. + + Args: + channels: Set of channel indices to respond on. + """ + with self._lock: + self._settings.channels = set(channels) + self._save() + debug_print(f"BotConfigStore: channels={sorted(channels)}") + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + def _load(self) -> None: + """Load settings from disk (called once at construction).""" + if not self._path.exists(): + debug_print(f"BotConfigStore: no file at {self._path.name}") + return + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + self._settings = BotSettings( + enabled=data.get("enabled", False), + private_mode=data.get("private_mode", False), + channels=set(data.get("channels", [])), + ) + debug_print( + f"BotConfigStore: loaded from {self._path.name} — " + f"enabled={self._settings.enabled}, " + f"private={self._settings.private_mode}, " + f"channels={sorted(self._settings.channels)}" + ) + except (json.JSONDecodeError, OSError) as exc: + debug_print(f"BotConfigStore: load error: {exc}") + self._settings = BotSettings() + + def _save(self) -> None: + """Write current settings to disk.""" + try: + BOT_DIR.mkdir(parents=True, exist_ok=True) + data = { + "enabled": self._settings.enabled, + "private_mode": self._settings.private_mode, + "channels": sorted(self._settings.channels), + } + self._path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + debug_print(f"BotConfigStore: saved to {self._path.name}") + except OSError as exc: + debug_print(f"BotConfigStore: save error: {exc}")