mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-03-28 17:42:38 +01:00
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:
50
CHANGELOG.md
50
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/_<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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
19
README.md
19
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.
|
||||
|
||||
<!-- 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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
248
meshcore_gui/gui/panels/bot_panel.py
Normal file
248
meshcore_gui/gui/panels/bot_panel.py
Normal 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',
|
||||
)
|
||||
@@ -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})"
|
||||
|
||||
165
meshcore_gui/services/bot_config_store.py
Normal file
165
meshcore_gui/services/bot_config_store.py
Normal 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}")
|
||||
Reference in New Issue
Block a user