Merge pull request #30 from pe1hvh/feature/private_bot

feat: extract bot to dedicated panel with channel assignment and private mode(#v1.15.0)
This commit is contained in:
pe1hvh
2026-03-16 16:49:37 +01:00
committed by GitHub
12 changed files with 635 additions and 69 deletions

View File

@@ -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/_<dev_id>_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.
---

View File

@@ -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.
<!-- CHANGED: Bot device name switching feature added in v5.5.0 -->
**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.
<!-- CHANGED: v1.15.0 — bot moved from Actions panel to dedicated BOT panel -->
**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/_<dev_id>_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:**
<!-- CHANGED: Removed "Zwolle Bot:" prefix from example replies — bot replies no longer include a name prefix (v5.5.0) -->
| Keyword | Reply |
|---------|-------|
| `test` | `<sender>, rcvd \| SNR <snr> \| path(<hops>); <repeaters>` |
| `test` | `<sender>, rcvd \| SNR <snr> \| path(<hops>)` |
| `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

View File

@@ -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()

View File

@@ -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,
)
# ------------------------------------------------------------------

View File

@@ -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

View File

@@ -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: _<safe_dev_id>_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"

View File

@@ -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()

View File

@@ -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

View File

@@ -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:

View File

@@ -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',
)

View File

@@ -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})"

View File

@@ -0,0 +1,165 @@
"""
Bot configuration store for MeshCore GUI.
Persists bot settings to
``~/.meshcore-gui/bot/_<safe_dev_id>_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}")