mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-03-28 17:42:38 +01:00
feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0)
Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI — no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json — channel configuration ~/.meshcore-gui/bbs/bbs_messages.db — SQLite message store No new external dependencies (SQLite is stdlib).
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -33,29 +33,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
|
||||
## [1.14.0] - 2026-03-14 — Offline BBS (Bulletin Board System)
|
||||
|
||||
### Added
|
||||
- 🆕 **`meshcore_gui/services/bbs_config_store.py`** — `BbsConfigStore`: beheert `~/.meshcore-gui/bbs/bbs_config.json`. Thread-safe, atomische schrijfoperaties. Wordt aangemaakt bij eerste start. Methoden: `get_channels()`, `get_enabled_channels()`, `get_channel()`, `set_channel()`, `enable_channel()`, `disable_channel()`, `update_channel_field()`.
|
||||
- 🆕 **`meshcore_gui/services/bbs_service.py`** — SQLite-backed BBS persistence layer.
|
||||
- `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp.
|
||||
- `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen (bijv. 800 MHz + 433 MHz gelijktijdig). Database op `~/.meshcore-gui/bbs/bbs_messages.db`.
|
||||
- `BbsCommandHandler`: parst `!bbs post`, `!bbs read`, `!bbs help` mesh commando's. Leest channel-config live uit `BbsConfigStore`. Whitelist-controle (stille drop bij onbekende sender key). Per-channel regio/categorie-validatie met foutmelding.
|
||||
- 🆕 **`meshcore_gui/services/bbs_config_store.py`** — `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`.
|
||||
- 🆕 **`meshcore_gui/services/bbs_service.py`** — SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`.
|
||||
- 🆕 **`meshcore_gui/gui/panels/bbs_panel.py`** — BBS panel voor het dashboard.
|
||||
- Channel-selector (alleen enabled BBS channels).
|
||||
- Regio-filter (alleen zichtbaar als channel regio's heeft).
|
||||
- Categorie-filter.
|
||||
- Scrollbare berichtenlijst met timestamp, afzender, categorie en optioneel regio-label.
|
||||
- Post-formulier: regio-select (conditioneel), categorie-select, tekstinvoer, Send-knop.
|
||||
- Send verstuurt ook `!bbs post …` op het mesh-kanaal zodat andere nodes het ontvangen.
|
||||
- **Settings-sectie**: per device channel een inklapbaar blok met enable-toggle, categorie-invoer, regio-invoer, retentie-invoer en whitelist-invoer. Opslaan via `BbsConfigStore`; channel-selector wordt direct bijgewerkt.
|
||||
- Board-selector (knoppen per geconfigureerd board).
|
||||
- Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft).
|
||||
- Scrollbare berichtenlijst over alle channels van het actieve board.
|
||||
- Post-formulier: post op het eerste channel van het board.
|
||||
- **Settings-sectie**: boards aanmaken (naam → Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieën, regio's, retentie, whitelist, Save en Delete.
|
||||
|
||||
### Changed
|
||||
- 🔄 **`meshcore_gui/services/bot.py`** — `MeshBot` accepteert optionele `bbs_handler` parameter. Inkomende `!bbs` berichten worden doorgesluisd naar `BbsCommandHandler` vóór keyword-matching; antwoorden worden teruggestuurd op hetzelfde kanaal.
|
||||
- 🔄 **`meshcore_gui/config.py`** — `BBS_CHANNELS` verwijderd (config leeft nu in `bbs_config.json`). Versie naar `1.14.0`.
|
||||
- 🔄 **`meshcore_gui/gui/dashboard.py`** — `BbsConfigStore` en `BbsService` instanties toegevoegd; `BbsPanel` geregistreerd als standalone panel `'bbs'`; menu-item `📋 BBS` toegevoegd aan de drawer.
|
||||
- 🔄 **`meshcore_gui/services/bot.py`** — `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`.
|
||||
- 🔄 **`meshcore_gui/config.py`** — `BBS_CHANNELS` verwijderd; versie `1.14.0`.
|
||||
- 🔄 **`meshcore_gui/gui/dashboard.py`** — `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `📋 BBS` drawer-item.
|
||||
- 🔄 **`meshcore_gui/gui/panels/__init__.py`** — `BbsPanel` re-exported.
|
||||
|
||||
### Storage
|
||||
```
|
||||
~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2)
|
||||
~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag
|
||||
```
|
||||
|
||||
### Not changed
|
||||
- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services.
|
||||
- Bestaande bot keyword-logica, room server flow, archive page, contacts, map, device, actions, rxlog panels.
|
||||
- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""BBS panel -- offline Bulletin Board System viewer, post form and settings."""
|
||||
"""BBS panel -- board-based Bulletin Board System viewer and configuration."""
|
||||
|
||||
import re
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
from meshcore_gui.services.bbs_config_store import (
|
||||
BbsBoard,
|
||||
BbsConfigStore,
|
||||
DEFAULT_CATEGORIES,
|
||||
DEFAULT_RETENTION_HOURS,
|
||||
@@ -13,16 +15,24 @@ from meshcore_gui.services.bbs_config_store import (
|
||||
from meshcore_gui.services.bbs_service import BbsMessage, BbsService
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
"""Convert a board name to a safe id slug.
|
||||
|
||||
Args:
|
||||
name: Human-readable board name.
|
||||
|
||||
Returns:
|
||||
Lowercase alphanumeric + underscore string.
|
||||
"""
|
||||
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board"
|
||||
|
||||
|
||||
class BbsPanel:
|
||||
"""BBS panel: channel selector, filters, message list, post form and settings.
|
||||
"""BBS panel: board selector, filters, message list, post form and settings.
|
||||
|
||||
The settings section lists all active device channels (from SharedData)
|
||||
and lets the user enable/disable BBS per channel and configure
|
||||
categories, regions and retention. Configuration is persisted via
|
||||
BbsConfigStore to ~/.meshcore-gui/bbs/bbs_config.json.
|
||||
|
||||
All data access goes through BbsService and BbsConfigStore.
|
||||
No direct SQLite access in this class (SOLID: SRP / DIP).
|
||||
The settings section lets users create, configure and delete boards.
|
||||
Each board can span one or more device channels (from SharedData).
|
||||
Configuration is persisted via BbsConfigStore.
|
||||
|
||||
Args:
|
||||
put_command: Callable to enqueue a command dict for the worker.
|
||||
@@ -41,12 +51,12 @@ class BbsPanel:
|
||||
self._config_store = config_store
|
||||
|
||||
# Active view state
|
||||
self._active_channel_idx: Optional[int] = None
|
||||
self._active_board: Optional[BbsBoard] = None
|
||||
self._active_region: Optional[str] = None
|
||||
self._active_category: Optional[str] = None
|
||||
|
||||
# UI element references -- message view
|
||||
self._msg_list_container = None
|
||||
# UI refs -- message view
|
||||
self._board_btn_row = None
|
||||
self._region_row = None
|
||||
self._region_select = None
|
||||
self._category_select = None
|
||||
@@ -54,11 +64,16 @@ class BbsPanel:
|
||||
self._post_region_row = None
|
||||
self._post_region_select = None
|
||||
self._post_category_select = None
|
||||
self._channel_btn_row = None
|
||||
self._msg_list_container = None
|
||||
|
||||
# UI element references -- settings
|
||||
self._settings_container = None
|
||||
self._last_device_channels: List[Dict] = []
|
||||
# UI refs -- settings
|
||||
self._boards_settings_container = None
|
||||
self._new_board_name_input = None
|
||||
self._new_board_channel_checks: Dict[int, object] = {}
|
||||
|
||||
# Cached device channels (updated by update())
|
||||
self._device_channels: List[Dict] = []
|
||||
self._last_ch_fingerprint: tuple = ()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
@@ -66,18 +81,16 @@ class BbsPanel:
|
||||
|
||||
def render(self) -> None:
|
||||
"""Build the complete BBS panel layout."""
|
||||
# ---- Message view card --------------------------------------
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600')
|
||||
|
||||
# ---- Channel selector -----------------------------------
|
||||
self._channel_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap')
|
||||
with self._channel_btn_row:
|
||||
ui.label('Channel:').classes('text-sm text-gray-600')
|
||||
# Populated by _rebuild_channel_buttons()
|
||||
self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap')
|
||||
with self._board_btn_row:
|
||||
ui.label('Board:').classes('text-sm text-gray-600')
|
||||
|
||||
ui.separator()
|
||||
|
||||
# ---- Filter row ----------------------------------------
|
||||
with ui.row().classes('w-full items-center gap-4 flex-wrap'):
|
||||
ui.label('Filter:').classes('text-sm text-gray-600')
|
||||
|
||||
@@ -85,33 +98,29 @@ class BbsPanel:
|
||||
with self._region_row:
|
||||
ui.label('Region:').classes('text-xs text-gray-600')
|
||||
self._region_select = ui.select(
|
||||
options=[],
|
||||
value=None,
|
||||
options=[], value=None,
|
||||
on_change=lambda e: self._on_region_filter(e.value),
|
||||
).classes('text-xs').style('min-width: 120px')
|
||||
|
||||
with ui.row().classes('items-center gap-2'):
|
||||
ui.label('Category:').classes('text-xs text-gray-600')
|
||||
self._category_select = ui.select(
|
||||
options=[],
|
||||
value=None,
|
||||
options=[], value=None,
|
||||
on_change=lambda e: self._on_category_filter(e.value),
|
||||
).classes('text-xs').style('min-width: 120px')
|
||||
|
||||
ui.button(
|
||||
'Refresh', on_click=self._refresh_messages
|
||||
'Refresh', on_click=self._refresh_messages,
|
||||
).props('flat no-caps').classes('text-xs')
|
||||
|
||||
ui.separator()
|
||||
|
||||
# ---- Message list --------------------------------------
|
||||
self._msg_list_container = ui.column().classes(
|
||||
'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2'
|
||||
)
|
||||
|
||||
ui.separator()
|
||||
|
||||
# ---- Post form -----------------------------------------
|
||||
with ui.row().classes('w-full items-center gap-2 flex-wrap'):
|
||||
ui.label('Post:').classes('text-sm text-gray-600')
|
||||
|
||||
@@ -131,91 +140,100 @@ class BbsPanel:
|
||||
|
||||
ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs')
|
||||
|
||||
# ---- Settings card -----------------------------------------
|
||||
# ---- Settings card ------------------------------------------
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('BBS Settings').classes('font-bold text-gray-600')
|
||||
ui.label(
|
||||
'Enable BBS on a channel to allow !bbs commands and store messages.'
|
||||
'Create boards and assign device channels. '
|
||||
'One board can cover multiple channels.'
|
||||
).classes('text-xs text-gray-500')
|
||||
ui.separator()
|
||||
self._settings_container = ui.column().classes('w-full gap-2')
|
||||
with self._settings_container:
|
||||
ui.label('Waiting for device channels...').classes(
|
||||
|
||||
# New board form
|
||||
with ui.row().classes('w-full items-center gap-2 flex-wrap'):
|
||||
ui.label('New board:').classes('text-sm text-gray-600')
|
||||
self._new_board_name_input = ui.input(
|
||||
placeholder='Board name...',
|
||||
).classes('text-xs').style('min-width: 160px')
|
||||
ui.button(
|
||||
'Create', on_click=self._on_create_board,
|
||||
).props('no-caps').classes('text-xs')
|
||||
|
||||
ui.separator()
|
||||
self._boards_settings_container = ui.column().classes('w-full gap-3')
|
||||
with self._boards_settings_container:
|
||||
ui.label('No boards configured yet.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
|
||||
# Initial render
|
||||
self._rebuild_board_buttons()
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Channel selector (message view)
|
||||
# Board selector (message view)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rebuild_channel_buttons(self, enabled_channels: List[Dict]) -> None:
|
||||
"""Rebuild the channel selector buttons for enabled BBS channels.
|
||||
|
||||
Args:
|
||||
enabled_channels: List of enabled channel config dicts.
|
||||
"""
|
||||
if not self._channel_btn_row:
|
||||
def _rebuild_board_buttons(self) -> None:
|
||||
"""Rebuild board selector buttons from current config."""
|
||||
if not self._board_btn_row:
|
||||
return
|
||||
self._channel_btn_row.clear()
|
||||
with self._channel_btn_row:
|
||||
ui.label('Channel:').classes('text-sm text-gray-600')
|
||||
if not enabled_channels:
|
||||
ui.label('No BBS channels configured.').classes(
|
||||
self._board_btn_row.clear()
|
||||
boards = self._config_store.get_boards()
|
||||
with self._board_btn_row:
|
||||
ui.label('Board:').classes('text-sm text-gray-600')
|
||||
if not boards:
|
||||
ui.label('No boards configured.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
return
|
||||
for cfg in enabled_channels:
|
||||
idx = cfg['channel']
|
||||
name = cfg['name']
|
||||
for board in boards:
|
||||
ui.button(
|
||||
name,
|
||||
on_click=lambda i=idx: self._select_channel(i),
|
||||
board.name,
|
||||
on_click=lambda b=board: self._select_board(b),
|
||||
).props('flat no-caps').classes('text-xs')
|
||||
|
||||
# Auto-select first channel when none is active yet
|
||||
if self._active_channel_idx is None and enabled_channels:
|
||||
self._select_channel(enabled_channels[0]['channel'])
|
||||
# Auto-select first board if none active or active was deleted
|
||||
ids = [b.id for b in boards]
|
||||
if boards and (self._active_board is None or self._active_board.id not in ids):
|
||||
self._select_board(boards[0])
|
||||
|
||||
def _select_channel(self, channel_idx: int) -> None:
|
||||
"""Switch the active channel and rebuild filter options.
|
||||
def _select_board(self, board: BbsBoard) -> None:
|
||||
"""Activate a board and rebuild filter selects.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index to activate.
|
||||
board: Board to activate.
|
||||
"""
|
||||
self._active_channel_idx = channel_idx
|
||||
self._active_board = board
|
||||
self._active_region = None
|
||||
self._active_category = None
|
||||
|
||||
cfg = self._config_store.get_channel(channel_idx) or {}
|
||||
regions: List[str] = cfg.get('regions', [])
|
||||
categories: List[str] = cfg.get('categories', [])
|
||||
|
||||
has_regions = bool(regions)
|
||||
has_regions = bool(board.regions)
|
||||
if self._region_row:
|
||||
self._region_row.set_visibility(has_regions)
|
||||
if self._post_region_row:
|
||||
self._post_region_row.set_visibility(has_regions)
|
||||
|
||||
region_opts = ['(all)'] + regions
|
||||
region_opts = ['(all)'] + board.regions
|
||||
if self._region_select:
|
||||
self._region_select.options = region_opts
|
||||
self._region_select.value = '(all)'
|
||||
if self._post_region_select:
|
||||
self._post_region_select.options = regions
|
||||
self._post_region_select.value = regions[0] if regions else None
|
||||
self._post_region_select.options = board.regions
|
||||
self._post_region_select.value = board.regions[0] if board.regions else None
|
||||
|
||||
cat_opts = ['(all)'] + categories
|
||||
cat_opts = ['(all)'] + board.categories
|
||||
if self._category_select:
|
||||
self._category_select.options = cat_opts
|
||||
self._category_select.value = '(all)'
|
||||
if self._post_category_select:
|
||||
self._post_category_select.options = categories
|
||||
self._post_category_select.value = categories[0] if categories else None
|
||||
self._post_category_select.options = board.categories
|
||||
self._post_category_select.value = board.categories[0] if board.categories else None
|
||||
|
||||
self._refresh_messages()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Filter callbacks
|
||||
# Filters
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_region_filter(self, value: Optional[str]) -> None:
|
||||
@@ -227,20 +245,24 @@ class BbsPanel:
|
||||
self._refresh_messages()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Message list refresh
|
||||
# Message list
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_messages(self) -> None:
|
||||
"""Query the BBS service and rebuild the message list UI."""
|
||||
if not self._msg_list_container:
|
||||
return
|
||||
self._msg_list_container.clear()
|
||||
with self._msg_list_container:
|
||||
if self._active_channel_idx is None:
|
||||
ui.label('Select a channel above.').classes('text-xs text-gray-400 italic')
|
||||
if self._active_board is None:
|
||||
ui.label('Select a board above.').classes('text-xs text-gray-400 italic')
|
||||
return
|
||||
if not self._active_board.channels:
|
||||
ui.label('No channels assigned to this board.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
return
|
||||
messages = self._service.get_all_messages(
|
||||
channel=self._active_channel_idx,
|
||||
channels=self._active_board.channels,
|
||||
region=self._active_region,
|
||||
category=self._active_category,
|
||||
)
|
||||
@@ -251,11 +273,6 @@ class BbsPanel:
|
||||
self._render_message_row(msg)
|
||||
|
||||
def _render_message_row(self, msg: BbsMessage) -> None:
|
||||
"""Render a single message row.
|
||||
|
||||
Args:
|
||||
msg: BbsMessage to display.
|
||||
"""
|
||||
ts = msg.timestamp[:16].replace('T', ' ')
|
||||
region_label = f' [{msg.region}]' if msg.region else ''
|
||||
header = f'{ts} {msg.sender} [{msg.category}]{region_label}'
|
||||
@@ -268,14 +285,12 @@ class BbsPanel:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_post(self) -> None:
|
||||
"""Handle the Send button: validate inputs and post a BBS message."""
|
||||
if self._active_channel_idx is None:
|
||||
ui.notify('Select a channel first.', type='warning')
|
||||
if self._active_board is None:
|
||||
ui.notify('Select a board first.', type='warning')
|
||||
return
|
||||
if not self._active_board.channels:
|
||||
ui.notify('No channels assigned to this board.', type='warning')
|
||||
return
|
||||
|
||||
cfg = self._config_store.get_channel(self._active_channel_idx) or {}
|
||||
regions: List[str] = cfg.get('regions', [])
|
||||
categories: List[str] = cfg.get('categories', [])
|
||||
|
||||
text = (self._text_input.value or '').strip() if self._text_input else ''
|
||||
if not text:
|
||||
@@ -283,36 +298,38 @@ class BbsPanel:
|
||||
return
|
||||
|
||||
category = (
|
||||
self._post_category_select.value
|
||||
if self._post_category_select else (categories[0] if categories else '')
|
||||
self._post_category_select.value if self._post_category_select
|
||||
else (self._active_board.categories[0] if self._active_board.categories else '')
|
||||
)
|
||||
if not category:
|
||||
ui.notify('Please select a category.', type='warning')
|
||||
return
|
||||
|
||||
region = ''
|
||||
if regions and self._post_region_select:
|
||||
if self._active_board.regions and self._post_region_select:
|
||||
region = self._post_region_select.value or ''
|
||||
|
||||
# Post on first assigned channel (primary channel for outgoing)
|
||||
target_channel = self._active_board.channels[0]
|
||||
|
||||
msg = BbsMessage(
|
||||
channel=self._active_channel_idx,
|
||||
region=region,
|
||||
category=category,
|
||||
sender='Me',
|
||||
sender_key='',
|
||||
text=text,
|
||||
channel=target_channel,
|
||||
region=region, category=category,
|
||||
sender='Me', sender_key='', text=text,
|
||||
)
|
||||
self._service.post_message(msg)
|
||||
|
||||
# Broadcast on the mesh channel
|
||||
region_part = f'{region} ' if region else ''
|
||||
mesh_text = f'!bbs post {region_part}{category} {text}'
|
||||
self._put_command({
|
||||
'action': 'send_message',
|
||||
'channel': self._active_channel_idx,
|
||||
'channel': target_channel,
|
||||
'text': mesh_text,
|
||||
})
|
||||
debug_print(f'BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}')
|
||||
debug_print(
|
||||
f'BBS panel: posted to board={self._active_board.id} '
|
||||
f'ch={target_channel} {mesh_text[:60]}'
|
||||
)
|
||||
|
||||
if self._text_input:
|
||||
self._text_input.value = ''
|
||||
@@ -320,170 +337,199 @@ class BbsPanel:
|
||||
ui.notify('Message posted.', type='positive')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings panel
|
||||
# Settings -- board list
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rebuild_settings(self, device_channels: List[Dict]) -> None:
|
||||
"""Rebuild the settings rows for all device channels.
|
||||
|
||||
Called from update() when the device channel list changes.
|
||||
|
||||
Args:
|
||||
device_channels: Channel list from SharedData snapshot.
|
||||
"""
|
||||
if not self._settings_container:
|
||||
def _rebuild_boards_settings(self) -> None:
|
||||
"""Rebuild the settings section for all configured boards."""
|
||||
if not self._boards_settings_container:
|
||||
return
|
||||
self._settings_container.clear()
|
||||
with self._settings_container:
|
||||
if not device_channels:
|
||||
ui.label('No channels received from device yet.').classes(
|
||||
self._boards_settings_container.clear()
|
||||
boards = self._config_store.get_boards()
|
||||
with self._boards_settings_container:
|
||||
if not boards:
|
||||
ui.label('No boards configured yet.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
return
|
||||
for ch in device_channels:
|
||||
self._render_settings_row(ch)
|
||||
for board in boards:
|
||||
self._render_board_settings_row(board)
|
||||
|
||||
def _render_settings_row(self, device_ch: Dict) -> None:
|
||||
"""Render one settings row for a single device channel.
|
||||
def _render_board_settings_row(self, board: BbsBoard) -> None:
|
||||
"""Render one settings expansion for a single board.
|
||||
|
||||
Args:
|
||||
device_ch: Channel dict from SharedData (keys: idx, name).
|
||||
board: Board to render.
|
||||
"""
|
||||
ch_idx = device_ch.get('idx', device_ch.get('index', 0))
|
||||
ch_name = device_ch.get('name', f'Ch {ch_idx}')
|
||||
bbs_cfg = self._config_store.get_channel(ch_idx)
|
||||
is_enabled = bbs_cfg.get('enabled', False) if bbs_cfg else False
|
||||
|
||||
with ui.expansion(
|
||||
f'[{ch_idx}] {ch_name}',
|
||||
value=False,
|
||||
board.name, value=False,
|
||||
).classes('w-full').props('dense'):
|
||||
|
||||
with ui.column().classes('w-full gap-2 p-2'):
|
||||
|
||||
# Enable / disable toggle
|
||||
enable_cb = ui.checkbox(
|
||||
'Enable BBS on this channel',
|
||||
value=is_enabled,
|
||||
)
|
||||
|
||||
# Categories input
|
||||
cats_val = ', '.join(bbs_cfg.get('categories', DEFAULT_CATEGORIES)) if bbs_cfg else ', '.join(DEFAULT_CATEGORIES)
|
||||
cats_input = ui.input(
|
||||
label='Categories (comma-separated)',
|
||||
value=cats_val,
|
||||
# Name
|
||||
name_input = ui.input(
|
||||
label='Board name', value=board.name,
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# Regions input
|
||||
regions_val = ', '.join(bbs_cfg.get('regions', [])) if bbs_cfg else ''
|
||||
# Channel assignment
|
||||
ui.label('Channels (select which device channels belong to this board):').classes(
|
||||
'text-xs text-gray-600'
|
||||
)
|
||||
ch_checks: Dict[int, object] = {}
|
||||
with ui.row().classes('flex-wrap gap-2'):
|
||||
if not self._device_channels:
|
||||
ui.label('No device channels known yet.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
for ch in self._device_channels:
|
||||
idx = ch.get('idx', ch.get('index', 0))
|
||||
ch_name = ch.get('name', f'Ch {idx}')
|
||||
cb = ui.checkbox(
|
||||
f'[{idx}] {ch_name}',
|
||||
value=idx in board.channels,
|
||||
).classes('text-xs')
|
||||
ch_checks[idx] = cb
|
||||
|
||||
# Categories
|
||||
cats_input = ui.input(
|
||||
label='Categories (comma-separated)',
|
||||
value=', '.join(board.categories),
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# Regions
|
||||
regions_input = ui.input(
|
||||
label='Regions (comma-separated, leave empty for none)',
|
||||
value=regions_val,
|
||||
value=', '.join(board.regions),
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# Retention
|
||||
ret_val = str(bbs_cfg.get('retention_hours', DEFAULT_RETENTION_HOURS)) if bbs_cfg else str(DEFAULT_RETENTION_HOURS)
|
||||
retention_input = ui.input(
|
||||
label='Retention (hours)',
|
||||
value=ret_val,
|
||||
).classes('w-full text-xs').style('max-width: 160px')
|
||||
value=str(board.retention_hours),
|
||||
).classes('text-xs').style('max-width: 160px')
|
||||
|
||||
# Whitelist
|
||||
wl_val = ', '.join(bbs_cfg.get('allowed_keys', [])) if bbs_cfg else ''
|
||||
whitelist_input = ui.input(
|
||||
label='Allowed keys (comma-separated hex, leave empty for all)',
|
||||
value=wl_val,
|
||||
wl_input = ui.input(
|
||||
label='Allowed keys (comma-separated hex, empty = all)',
|
||||
value=', '.join(board.allowed_keys),
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# Save button
|
||||
def _save(
|
||||
idx=ch_idx,
|
||||
name=ch_name,
|
||||
cb=enable_cb,
|
||||
cats=cats_input,
|
||||
regs=regions_input,
|
||||
ret=retention_input,
|
||||
wl=whitelist_input,
|
||||
) -> None:
|
||||
categories = [
|
||||
c.strip().upper()
|
||||
for c in (cats.value or '').split(',')
|
||||
if c.strip()
|
||||
]
|
||||
regions = [
|
||||
r.strip()
|
||||
for r in (regs.value or '').split(',')
|
||||
if r.strip()
|
||||
]
|
||||
try:
|
||||
retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS)
|
||||
except ValueError:
|
||||
retention_hours = DEFAULT_RETENTION_HOURS
|
||||
allowed_keys = [
|
||||
k.strip()
|
||||
for k in (wl.value or '').split(',')
|
||||
if k.strip()
|
||||
]
|
||||
cfg_entry = {
|
||||
'channel': idx,
|
||||
'name': name,
|
||||
'enabled': cb.value,
|
||||
'categories': categories if categories else list(DEFAULT_CATEGORIES),
|
||||
'regions': regions,
|
||||
'retention_hours': retention_hours,
|
||||
'allowed_keys': allowed_keys,
|
||||
}
|
||||
self._config_store.set_channel(cfg_entry)
|
||||
debug_print(
|
||||
f'BBS settings: saved ch={idx} enabled={cb.value} '
|
||||
f'cats={categories} regions={regions}'
|
||||
)
|
||||
ui.notify(f'BBS settings saved for [{idx}] {name}', type='positive')
|
||||
# Refresh channel buttons and message view
|
||||
self._refresh_after_settings_save()
|
||||
|
||||
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs')
|
||||
|
||||
def _refresh_after_settings_save(self) -> None:
|
||||
"""Rebuild the channel selector buttons after a settings save."""
|
||||
enabled = self._config_store.get_enabled_channels()
|
||||
self._rebuild_channel_buttons(enabled)
|
||||
# Reset active channel if it was disabled
|
||||
if self._active_channel_idx is not None:
|
||||
cfg = self._config_store.get_channel(self._active_channel_idx)
|
||||
if not cfg or not cfg.get('enabled', False):
|
||||
self._active_channel_idx = None
|
||||
if self._msg_list_container:
|
||||
self._msg_list_container.clear()
|
||||
with self._msg_list_container:
|
||||
ui.label('Select a channel above.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
with ui.row().classes('gap-2'):
|
||||
def _save(
|
||||
bid=board.id,
|
||||
ni=name_input,
|
||||
cc=ch_checks,
|
||||
ci=cats_input,
|
||||
ri=regions_input,
|
||||
ret=retention_input,
|
||||
wli=wl_input,
|
||||
) -> None:
|
||||
new_name = (ni.value or '').strip() or bid
|
||||
selected_channels = [
|
||||
idx for idx, cb in cc.items() if cb.value
|
||||
]
|
||||
categories = [
|
||||
c.strip().upper()
|
||||
for c in (ci.value or '').split(',') if c.strip()
|
||||
] or list(DEFAULT_CATEGORIES)
|
||||
regions = [
|
||||
r.strip()
|
||||
for r in (ri.value or '').split(',') if r.strip()
|
||||
]
|
||||
try:
|
||||
retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS)
|
||||
except ValueError:
|
||||
retention_hours = DEFAULT_RETENTION_HOURS
|
||||
allowed_keys = [
|
||||
k.strip()
|
||||
for k in (wli.value or '').split(',') if k.strip()
|
||||
]
|
||||
updated = BbsBoard(
|
||||
id=bid,
|
||||
name=new_name,
|
||||
channels=selected_channels,
|
||||
categories=categories,
|
||||
regions=regions,
|
||||
retention_hours=retention_hours,
|
||||
allowed_keys=allowed_keys,
|
||||
)
|
||||
self._config_store.set_board(updated)
|
||||
debug_print(
|
||||
f'BBS settings: saved board {bid} '
|
||||
f'channels={selected_channels}'
|
||||
)
|
||||
ui.notify(f'Board "{new_name}" saved.', type='positive')
|
||||
self._rebuild_board_buttons()
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
def _delete(bid=board.id, bname=board.name) -> None:
|
||||
self._config_store.delete_board(bid)
|
||||
if self._active_board and self._active_board.id == bid:
|
||||
self._active_board = None
|
||||
debug_print(f'BBS settings: deleted board {bid}')
|
||||
ui.notify(f'Board "{bname}" deleted.', type='warning')
|
||||
self._rebuild_board_buttons()
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs')
|
||||
ui.button(
|
||||
'Delete', on_click=_delete,
|
||||
).props('no-caps flat color=negative').classes('text-xs')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External update hook (called from dashboard timer)
|
||||
# Settings -- create new board
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_create_board(self) -> None:
|
||||
"""Handle the Create button for a new board."""
|
||||
name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else ''
|
||||
if not name:
|
||||
ui.notify('Enter a board name first.', type='warning')
|
||||
return
|
||||
|
||||
board_id = _slug(name)
|
||||
# Make id unique if needed
|
||||
base_id = board_id
|
||||
counter = 2
|
||||
while self._config_store.board_id_exists(board_id):
|
||||
board_id = f'{base_id}_{counter}'
|
||||
counter += 1
|
||||
|
||||
board = BbsBoard(
|
||||
id=board_id,
|
||||
name=name,
|
||||
channels=[],
|
||||
categories=list(DEFAULT_CATEGORIES),
|
||||
regions=[],
|
||||
retention_hours=DEFAULT_RETENTION_HOURS,
|
||||
allowed_keys=[],
|
||||
)
|
||||
self._config_store.set_board(board)
|
||||
debug_print(f'BBS settings: created board {board_id}')
|
||||
if self._new_board_name_input:
|
||||
self._new_board_name_input.value = ''
|
||||
ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive')
|
||||
self._rebuild_board_buttons()
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External update hook
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update(self, data: Dict) -> None:
|
||||
"""Called by the dashboard timer with the SharedData snapshot.
|
||||
|
||||
Rebuilds the settings panel when the device channel list changes.
|
||||
Rebuilds the settings channel checkboxes when the device channel
|
||||
list changes.
|
||||
|
||||
Args:
|
||||
data: SharedData snapshot dict.
|
||||
"""
|
||||
device_channels = data.get('channels', [])
|
||||
|
||||
# Rebuild settings only when the channel list changes
|
||||
ch_fingerprint = tuple(
|
||||
fingerprint = tuple(
|
||||
(ch.get('idx', 0), ch.get('name', '')) for ch in device_channels
|
||||
)
|
||||
last_fingerprint = tuple(
|
||||
(ch.get('idx', 0), ch.get('name', '')) for ch in self._last_device_channels
|
||||
)
|
||||
if ch_fingerprint != last_fingerprint:
|
||||
self._last_device_channels = device_channels
|
||||
self._rebuild_settings(device_channels)
|
||||
# Also rebuild channel buttons (config may have changed)
|
||||
enabled = self._config_store.get_enabled_channels()
|
||||
self._rebuild_channel_buttons(enabled)
|
||||
if fingerprint != self._last_ch_fingerprint:
|
||||
self._last_ch_fingerprint = fingerprint
|
||||
self._device_channels = device_channels
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
@@ -1,36 +1,46 @@
|
||||
"""
|
||||
BBS channel configuration store for MeshCore GUI.
|
||||
BBS board configuration store for MeshCore GUI.
|
||||
|
||||
Persists BBS channel configuration to
|
||||
``~/.meshcore-gui/bbs/bbs_config.json`` so that settings survive
|
||||
restarts and are managed outside of ``config.py``.
|
||||
Persists BBS board configuration to
|
||||
``~/.meshcore-gui/bbs/bbs_config.json``.
|
||||
|
||||
On first use the file is created with an empty channel list.
|
||||
The GUI populates it when the user enables BBS on a device channel.
|
||||
A **board** groups one or more MeshCore channel indices into a single
|
||||
bulletin board. Messages posted on any of the board's channels are
|
||||
visible in the board view. This supports two usage patterns:
|
||||
|
||||
- One board per channel (classic per-channel BBS)
|
||||
- One board spanning multiple channels (shared bulletin board)
|
||||
|
||||
Config version history
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
v1 — per-channel config (list of channels with enabled flag).
|
||||
v2 — board-based config (list of boards, each with a channels list).
|
||||
Automatic migration from v1 on first load.
|
||||
|
||||
Thread safety
|
||||
~~~~~~~~~~~~~
|
||||
All methods acquire an internal ``threading.Lock``.
|
||||
All public methods acquire an internal ``threading.Lock``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage location
|
||||
# Storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs"
|
||||
BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json"
|
||||
|
||||
CONFIG_VERSION: int = 1
|
||||
CONFIG_VERSION: int = 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default values applied when a channel is first enabled
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"]
|
||||
@@ -38,8 +48,64 @@ DEFAULT_REGIONS: List[str] = []
|
||||
DEFAULT_RETENTION_HOURS: int = 48
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class BbsBoard:
|
||||
"""A BBS board grouping one or more MeshCore channels.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``).
|
||||
name: Human-readable board name.
|
||||
channels: List of MeshCore channel indices assigned to this board.
|
||||
categories: Valid category tags for this board.
|
||||
regions: Optional region tags; empty = no region filtering.
|
||||
retention_hours: Message retention period in hours.
|
||||
allowed_keys: Sender public key whitelist (empty = all allowed).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
channels: List[int] = field(default_factory=list)
|
||||
categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES))
|
||||
regions: List[str] = field(default_factory=list)
|
||||
retention_hours: int = DEFAULT_RETENTION_HOURS
|
||||
allowed_keys: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Serialise to a JSON-compatible dict."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"channels": list(self.channels),
|
||||
"categories": list(self.categories),
|
||||
"regions": list(self.regions),
|
||||
"retention_hours": self.retention_hours,
|
||||
"allowed_keys": list(self.allowed_keys),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: Dict) -> "BbsBoard":
|
||||
"""Deserialise from a config dict."""
|
||||
return BbsBoard(
|
||||
id=d.get("id", ""),
|
||||
name=d.get("name", ""),
|
||||
channels=list(d.get("channels", [])),
|
||||
categories=list(d.get("categories", DEFAULT_CATEGORIES)),
|
||||
regions=list(d.get("regions", [])),
|
||||
retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)),
|
||||
allowed_keys=list(d.get("allowed_keys", [])),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BbsConfigStore:
|
||||
"""Persistent store for BBS channel configuration.
|
||||
"""Persistent store for BBS board configuration.
|
||||
|
||||
Args:
|
||||
config_path: Path to the JSON config file.
|
||||
@@ -49,7 +115,7 @@ class BbsConfigStore:
|
||||
def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None:
|
||||
self._path = config_path
|
||||
self._lock = threading.Lock()
|
||||
self._data: Dict = {"version": CONFIG_VERSION, "channels": []}
|
||||
self._boards: List[BbsBoard] = []
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -57,36 +123,79 @@ class BbsConfigStore:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load config from disk; create defaults if file is absent."""
|
||||
"""Load config from disk; migrate v1 → v2 if needed."""
|
||||
BBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not self._path.exists():
|
||||
self._save_unlocked()
|
||||
debug_print("BBS config: created new config file")
|
||||
debug_print("BBS config: created new config file (v2)")
|
||||
return
|
||||
|
||||
try:
|
||||
raw = self._path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
if data.get("version") == CONFIG_VERSION:
|
||||
self._data = data
|
||||
version = data.get("version", 1)
|
||||
|
||||
if version == CONFIG_VERSION:
|
||||
self._boards = [
|
||||
BbsBoard.from_dict(b) for b in data.get("boards", [])
|
||||
]
|
||||
debug_print(f"BBS config: loaded {len(self._boards)} boards")
|
||||
|
||||
elif version == 1:
|
||||
# Migrate: each v1 channel → one board
|
||||
self._boards = self._migrate_v1(data.get("channels", []))
|
||||
self._save_unlocked()
|
||||
debug_print(
|
||||
f"BBS config: loaded {len(self._data.get('channels', []))} channels"
|
||||
f"BBS config: migrated v1 → v2 ({len(self._boards)} boards)"
|
||||
)
|
||||
else:
|
||||
debug_print(
|
||||
f"BBS config: version mismatch "
|
||||
f"(got {data.get('version')}, expected {CONFIG_VERSION}) — using defaults"
|
||||
f"BBS config: unknown version {version}, using empty config"
|
||||
)
|
||||
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
debug_print(f"BBS config: load error ({exc}) — using defaults")
|
||||
debug_print(f"BBS config: load error ({exc}), using empty config")
|
||||
|
||||
@staticmethod
|
||||
def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]:
|
||||
"""Convert v1 per-channel entries to v2 boards.
|
||||
|
||||
Only enabled channels are migrated.
|
||||
|
||||
Args:
|
||||
v1_channels: List of v1 channel config dicts.
|
||||
|
||||
Returns:
|
||||
List of ``BbsBoard`` instances.
|
||||
"""
|
||||
boards = []
|
||||
for ch in v1_channels:
|
||||
if not ch.get("enabled", False):
|
||||
continue
|
||||
idx = ch.get("channel", 0)
|
||||
board_id = f"ch{idx}"
|
||||
boards.append(BbsBoard(
|
||||
id=board_id,
|
||||
name=ch.get("name", f"Channel {idx}"),
|
||||
channels=[idx],
|
||||
categories=list(ch.get("categories", DEFAULT_CATEGORIES)),
|
||||
regions=list(ch.get("regions", [])),
|
||||
retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)),
|
||||
allowed_keys=list(ch.get("allowed_keys", [])),
|
||||
))
|
||||
return boards
|
||||
|
||||
def _save_unlocked(self) -> None:
|
||||
"""Write config to disk. MUST be called with self._lock held."""
|
||||
BBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"version": CONFIG_VERSION,
|
||||
"boards": [b.to_dict() for b in self._boards],
|
||||
}
|
||||
tmp = self._path.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(self._data, indent=2, ensure_ascii=False),
|
||||
json.dumps(data, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(self._path)
|
||||
@@ -97,136 +206,97 @@ class BbsConfigStore:
|
||||
self._save_unlocked()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Channel management
|
||||
# Board queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_channels(self) -> List[Dict]:
|
||||
"""Return a copy of all configured channels (enabled and disabled).
|
||||
def get_boards(self) -> List[BbsBoard]:
|
||||
"""Return a copy of all configured boards.
|
||||
|
||||
Returns:
|
||||
List of channel config dicts.
|
||||
List of ``BbsBoard`` instances.
|
||||
"""
|
||||
with self._lock:
|
||||
return [ch.copy() for ch in self._data.get("channels", [])]
|
||||
return list(self._boards)
|
||||
|
||||
def get_enabled_channels(self) -> List[Dict]:
|
||||
"""Return only channels with ``enabled: true``.
|
||||
|
||||
Returns:
|
||||
List of enabled channel config dicts.
|
||||
"""
|
||||
with self._lock:
|
||||
return [
|
||||
ch.copy()
|
||||
for ch in self._data.get("channels", [])
|
||||
if ch.get("enabled", False)
|
||||
]
|
||||
|
||||
def get_channel(self, channel_idx: int) -> Optional[Dict]:
|
||||
"""Return config for a single channel index, or ``None``.
|
||||
def get_board(self, board_id: str) -> Optional[BbsBoard]:
|
||||
"""Return a board by its id, or ``None``.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index.
|
||||
board_id: Board identifier string.
|
||||
|
||||
Returns:
|
||||
Channel config dict copy, or ``None`` if not found.
|
||||
``BbsBoard`` instance or ``None``.
|
||||
"""
|
||||
with self._lock:
|
||||
for ch in self._data.get("channels", []):
|
||||
if ch.get("channel") == channel_idx:
|
||||
return ch.copy()
|
||||
for b in self._boards:
|
||||
if b.id == board_id:
|
||||
return BbsBoard.from_dict(b.to_dict())
|
||||
return None
|
||||
|
||||
def set_channel(self, channel_cfg: Dict) -> None:
|
||||
"""Insert or update a channel configuration entry.
|
||||
def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]:
|
||||
"""Return the first board that includes *channel_idx*, or ``None``.
|
||||
|
||||
The channel is identified by the ``channel`` key in *channel_cfg*.
|
||||
If an entry with the same index exists it is replaced; otherwise
|
||||
a new entry is appended.
|
||||
|
||||
Args:
|
||||
channel_cfg: Channel config dict (must contain ``'channel'``).
|
||||
"""
|
||||
idx = channel_cfg["channel"]
|
||||
with self._lock:
|
||||
channels = self._data.setdefault("channels", [])
|
||||
for i, ch in enumerate(channels):
|
||||
if ch.get("channel") == idx:
|
||||
channels[i] = channel_cfg.copy()
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: updated ch={idx}")
|
||||
return
|
||||
channels.append(channel_cfg.copy())
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: added ch={idx}")
|
||||
|
||||
def enable_channel(
|
||||
self,
|
||||
channel_idx: int,
|
||||
name: str,
|
||||
*,
|
||||
categories: Optional[List[str]] = None,
|
||||
regions: Optional[List[str]] = None,
|
||||
retention_hours: int = DEFAULT_RETENTION_HOURS,
|
||||
allowed_keys: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Enable BBS on a device channel, creating a default config if needed.
|
||||
|
||||
If the channel already exists its ``enabled`` flag is set to
|
||||
``True`` and other fields are left as-is. Pass explicit keyword
|
||||
arguments to override any field on a new channel.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index.
|
||||
name: Human-readable channel name.
|
||||
categories: Category list (defaults to ``DEFAULT_CATEGORIES``).
|
||||
regions: Region list (defaults to empty — no regions).
|
||||
retention_hours: Retention in hours (default 48).
|
||||
allowed_keys: Sender key whitelist (default empty = all allowed).
|
||||
"""
|
||||
existing = self.get_channel(channel_idx)
|
||||
if existing:
|
||||
existing["enabled"] = True
|
||||
self.set_channel(existing)
|
||||
else:
|
||||
self.set_channel({
|
||||
"channel": channel_idx,
|
||||
"name": name,
|
||||
"enabled": True,
|
||||
"categories": categories if categories is not None else list(DEFAULT_CATEGORIES),
|
||||
"regions": regions if regions is not None else list(DEFAULT_REGIONS),
|
||||
"retention_hours": retention_hours,
|
||||
"allowed_keys": allowed_keys if allowed_keys is not None else [],
|
||||
})
|
||||
|
||||
def disable_channel(self, channel_idx: int) -> None:
|
||||
"""Set ``enabled: false`` for a channel without removing its config.
|
||||
Used by ``BbsCommandHandler`` to route incoming mesh commands.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index.
|
||||
"""
|
||||
existing = self.get_channel(channel_idx)
|
||||
if existing:
|
||||
existing["enabled"] = False
|
||||
self.set_channel(existing)
|
||||
debug_print(f"BBS config: disabled ch={channel_idx}")
|
||||
|
||||
def update_channel_field(
|
||||
self, channel_idx: int, field: str, value
|
||||
) -> bool:
|
||||
"""Update a single field on an existing channel entry.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index.
|
||||
field: Field name to update.
|
||||
value: New value.
|
||||
|
||||
Returns:
|
||||
``True`` if the channel was found and updated, ``False`` otherwise.
|
||||
``BbsBoard`` instance or ``None``.
|
||||
"""
|
||||
existing = self.get_channel(channel_idx)
|
||||
if not existing:
|
||||
return False
|
||||
existing[field] = value
|
||||
self.set_channel(existing)
|
||||
return True
|
||||
with self._lock:
|
||||
for b in self._boards:
|
||||
if channel_idx in b.channels:
|
||||
return BbsBoard.from_dict(b.to_dict())
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Board management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_board(self, board: BbsBoard) -> None:
|
||||
"""Insert or replace a board (matched by ``board.id``).
|
||||
|
||||
Args:
|
||||
board: ``BbsBoard`` to persist.
|
||||
"""
|
||||
with self._lock:
|
||||
for i, b in enumerate(self._boards):
|
||||
if b.id == board.id:
|
||||
self._boards[i] = BbsBoard.from_dict(board.to_dict())
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: updated board '{board.id}'")
|
||||
return
|
||||
self._boards.append(BbsBoard.from_dict(board.to_dict()))
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: added board '{board.id}'")
|
||||
|
||||
def delete_board(self, board_id: str) -> bool:
|
||||
"""Remove a board by id.
|
||||
|
||||
Args:
|
||||
board_id: Board identifier to remove.
|
||||
|
||||
Returns:
|
||||
``True`` if removed, ``False`` if not found.
|
||||
"""
|
||||
with self._lock:
|
||||
before = len(self._boards)
|
||||
self._boards = [b for b in self._boards if b.id != board_id]
|
||||
if len(self._boards) < before:
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: deleted board '{board_id}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
def board_id_exists(self, board_id: str) -> bool:
|
||||
"""Check whether a board id is already in use.
|
||||
|
||||
Args:
|
||||
board_id: Board identifier to check.
|
||||
|
||||
Returns:
|
||||
``True`` if a board with this id exists.
|
||||
"""
|
||||
with self._lock:
|
||||
return any(b.id == board_id for b in self._boards)
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
"""
|
||||
Offline Bulletin Board System (BBS) service for MeshCore GUI.
|
||||
|
||||
Stores BBS messages in a local SQLite database, one table per channel.
|
||||
Channel configuration is managed by
|
||||
:class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` and
|
||||
persisted to ``~/.meshcore-gui/bbs/bbs_config.json``.
|
||||
Stores BBS messages in a local SQLite database. Messages are keyed by
|
||||
their originating MeshCore channel index. A **board** (see
|
||||
:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or
|
||||
more channel indices to a single bulletin board, so queries are always
|
||||
issued as ``WHERE channel IN (...)``.
|
||||
|
||||
Architecture
|
||||
~~~~~~~~~~~~
|
||||
- ``BbsService`` — persistence layer (SQLite, retention, queries).
|
||||
- ``BbsCommandHandler`` — parses incoming ``!bbs`` text commands and
|
||||
delegates to ``BbsService``. Returns reply text.
|
||||
- ``BbsService`` -- persistence layer (SQLite, retention, queries).
|
||||
- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and
|
||||
delegates to ``BbsService``. Returns reply text.
|
||||
|
||||
Thread safety
|
||||
~~~~~~~~~~~~~
|
||||
SQLite connections are created in the calling thread. The service uses
|
||||
``check_same_thread=False`` combined with an internal ``threading.Lock``
|
||||
so it is safe to call from both the GUI thread and the worker thread.
|
||||
SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by
|
||||
multiple application instances (e.g. 800 MHz + 433 MHz on one Pi).
|
||||
|
||||
Storage location
|
||||
~~~~~~~~~~~~~~~~
|
||||
``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib).
|
||||
``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore).
|
||||
Storage
|
||||
~~~~~~~
|
||||
``~/.meshcore-gui/bbs/bbs_messages.db``
|
||||
``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
@@ -33,10 +33,6 @@ from typing import Dict, List, Optional
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BBS_DIR = Path.home() / ".meshcore-gui" / "bbs"
|
||||
BBS_DB_PATH = BBS_DIR / "bbs_messages.db"
|
||||
|
||||
@@ -51,9 +47,9 @@ class BbsMessage:
|
||||
|
||||
Attributes:
|
||||
id: Database row id (``None`` before insert).
|
||||
channel: MeshCore channel index.
|
||||
region: Region tag (empty string when channel has no regions).
|
||||
category: Category tag (e.g. ``'MEDISCH'``).
|
||||
channel: MeshCore channel index the message arrived on.
|
||||
region: Region tag (empty string when board has no regions).
|
||||
category: Category tag.
|
||||
sender: Display name of the sender.
|
||||
sender_key: Public key of the sender (hex string).
|
||||
text: Message body.
|
||||
@@ -81,7 +77,6 @@ class BbsService:
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file.
|
||||
Defaults to ``~/.meshcore-gui/bbs/bbs_messages.db``.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = BBS_DB_PATH) -> None:
|
||||
@@ -89,10 +84,6 @@ class BbsService:
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Initialisation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Create the database directory and schema if not present."""
|
||||
BBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -121,10 +112,7 @@ class BbsService:
|
||||
debug_print(f"BBS: database ready at {self._db_path}")
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
"""Return a new SQLite connection (check_same_thread=False)."""
|
||||
return sqlite3.connect(
|
||||
str(self._db_path), check_same_thread=False
|
||||
)
|
||||
return sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Write
|
||||
@@ -142,163 +130,150 @@ class BbsService:
|
||||
with self._lock:
|
||||
with self._connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO bbs_messages
|
||||
(channel, region, category, sender, sender_key, text, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
msg.channel,
|
||||
msg.region,
|
||||
msg.category,
|
||||
msg.sender,
|
||||
msg.sender_key,
|
||||
msg.text,
|
||||
msg.timestamp,
|
||||
),
|
||||
"""INSERT INTO bbs_messages
|
||||
(channel, region, category, sender, sender_key, text, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(msg.channel, msg.region, msg.category,
|
||||
msg.sender, msg.sender_key, msg.text, msg.timestamp),
|
||||
)
|
||||
conn.commit()
|
||||
msg.id = cur.lastrowid
|
||||
debug_print(
|
||||
f"BBS: posted msg id={msg.id} ch={msg.channel} "
|
||||
f"BBS: posted id={msg.id} ch={msg.channel} "
|
||||
f"cat={msg.category} sender={msg.sender}"
|
||||
)
|
||||
return msg.id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read
|
||||
# Read (channels is a list to support multi-channel boards)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
channel: int,
|
||||
channels: List[int],
|
||||
region: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> List[BbsMessage]:
|
||||
"""Return the *limit* most recent messages for a channel.
|
||||
"""Return the *limit* most recent messages for a set of channels.
|
||||
|
||||
Args:
|
||||
channel: MeshCore channel index.
|
||||
region: Optional region filter (exact match; ``None`` = all).
|
||||
category: Optional category filter (exact match; ``None`` = all).
|
||||
channels: MeshCore channel indices to query (board's channel list).
|
||||
region: Optional region filter.
|
||||
category: Optional category filter.
|
||||
limit: Maximum number of messages to return.
|
||||
|
||||
Returns:
|
||||
List of ``BbsMessage`` objects, newest first.
|
||||
"""
|
||||
if not channels:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(channels))
|
||||
query = (
|
||||
"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
|
||||
"FROM bbs_messages WHERE channel = ?"
|
||||
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
|
||||
f"FROM bbs_messages WHERE channel IN ({placeholders})"
|
||||
)
|
||||
params: list = [channel]
|
||||
|
||||
params: list = list(channels)
|
||||
if region:
|
||||
query += " AND region = ?"
|
||||
params.append(region)
|
||||
if category:
|
||||
query += " AND category = ?"
|
||||
params.append(category)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
return [self._row_to_msg(row) for row in rows]
|
||||
return [self._row_to_msg(r) for r in rows]
|
||||
|
||||
def get_all_messages(
|
||||
self,
|
||||
channel: int,
|
||||
channels: List[int],
|
||||
region: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> List[BbsMessage]:
|
||||
"""Return all messages for a channel (oldest first) for the GUI panel.
|
||||
"""Return all messages for a set of channels (oldest first).
|
||||
|
||||
Args:
|
||||
channel: MeshCore channel index.
|
||||
channels: MeshCore channel indices to query.
|
||||
region: Optional region filter.
|
||||
category: Optional category filter.
|
||||
|
||||
Returns:
|
||||
List of ``BbsMessage`` objects, oldest first.
|
||||
"""
|
||||
if not channels:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(channels))
|
||||
query = (
|
||||
"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
|
||||
"FROM bbs_messages WHERE channel = ?"
|
||||
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
|
||||
f"FROM bbs_messages WHERE channel IN ({placeholders})"
|
||||
)
|
||||
params: list = [channel]
|
||||
|
||||
params: list = list(channels)
|
||||
if region:
|
||||
query += " AND region = ?"
|
||||
params.append(region)
|
||||
if category:
|
||||
query += " AND category = ?"
|
||||
params.append(category)
|
||||
|
||||
query += " ORDER BY timestamp ASC"
|
||||
|
||||
with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
return [self._row_to_msg(row) for row in rows]
|
||||
return [self._row_to_msg(r) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def _row_to_msg(row: tuple) -> BbsMessage:
|
||||
return BbsMessage(
|
||||
id=row[0],
|
||||
channel=row[1],
|
||||
region=row[2],
|
||||
category=row[3],
|
||||
sender=row[4],
|
||||
sender_key=row[5],
|
||||
text=row[6],
|
||||
timestamp=row[7],
|
||||
id=row[0], channel=row[1], region=row[2], category=row[3],
|
||||
sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7],
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Retention
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def purge_expired(self, channel: int, retention_hours: int) -> int:
|
||||
"""Delete messages older than *retention_hours* for a channel.
|
||||
def purge_expired(self, channels: List[int], retention_hours: int) -> int:
|
||||
"""Delete messages older than *retention_hours* for a set of channels.
|
||||
|
||||
Args:
|
||||
channel: MeshCore channel index.
|
||||
channels: MeshCore channel indices to purge.
|
||||
retention_hours: Messages older than this are deleted.
|
||||
|
||||
Returns:
|
||||
Number of rows deleted.
|
||||
"""
|
||||
if not channels:
|
||||
return 0
|
||||
cutoff = (
|
||||
datetime.now(timezone.utc) - timedelta(hours=retention_hours)
|
||||
).isoformat()
|
||||
|
||||
placeholders = ",".join("?" * len(channels))
|
||||
with self._lock:
|
||||
with self._connect() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM bbs_messages WHERE channel = ? AND timestamp < ?",
|
||||
(channel, cutoff),
|
||||
f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?",
|
||||
list(channels) + [cutoff],
|
||||
)
|
||||
conn.commit()
|
||||
deleted = cur.rowcount
|
||||
if deleted:
|
||||
debug_print(
|
||||
f"BBS: purged {deleted} expired messages from ch={channel}"
|
||||
f"BBS: purged {deleted} expired messages from ch={channels}"
|
||||
)
|
||||
return deleted
|
||||
|
||||
def purge_all_expired(self, channels_config: List[Dict]) -> None:
|
||||
"""Run retention cleanup for all configured channels.
|
||||
def purge_all_expired(self, boards) -> None:
|
||||
"""Run retention cleanup for all boards.
|
||||
|
||||
Args:
|
||||
channels_config: List of channel config dicts.
|
||||
boards: Iterable of ``BbsBoard`` instances.
|
||||
"""
|
||||
for cfg in channels_config:
|
||||
self.purge_expired(cfg["channel"], cfg["retention_hours"])
|
||||
for board in boards:
|
||||
self.purge_expired(board.channels, board.retention_hours)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -308,14 +283,13 @@ class BbsService:
|
||||
class BbsCommandHandler:
|
||||
"""Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`.
|
||||
|
||||
Channel configuration is read live from the supplied
|
||||
:class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore`
|
||||
so that changes made in the GUI take effect immediately without
|
||||
restarting the application.
|
||||
Looks up the board for the incoming channel via ``BbsConfigStore``
|
||||
so that a single board spanning multiple channels handles commands
|
||||
from all of them.
|
||||
|
||||
Args:
|
||||
service: Shared ``BbsService`` instance.
|
||||
config_store: ``BbsConfigStore`` instance for live channel config.
|
||||
config_store: ``BbsConfigStore`` instance for live board config.
|
||||
"""
|
||||
|
||||
READ_LIMIT: int = 5
|
||||
@@ -324,13 +298,6 @@ class BbsCommandHandler:
|
||||
self._service = service
|
||||
self._config_store = config_store
|
||||
|
||||
def _get_cfg(self, channel_idx: int) -> Optional[Dict]:
|
||||
"""Return enabled channel config, or ``None``."""
|
||||
cfg = self._config_store.get_channel(channel_idx)
|
||||
if cfg and cfg.get("enabled", False):
|
||||
return cfg
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
@@ -357,46 +324,44 @@ class BbsCommandHandler:
|
||||
if not text.lower().startswith("!bbs"):
|
||||
return None
|
||||
|
||||
cfg = self._get_cfg(channel_idx)
|
||||
if cfg is None:
|
||||
board = self._config_store.get_board_for_channel(channel_idx)
|
||||
if board is None:
|
||||
return None
|
||||
|
||||
# Whitelist check
|
||||
allowed = cfg.get("allowed_keys", [])
|
||||
if allowed and sender_key not in allowed:
|
||||
if board.allowed_keys and sender_key not in board.allowed_keys:
|
||||
debug_print(
|
||||
f"BBS: silently dropping msg from {sender} "
|
||||
f"(key not in whitelist for ch={channel_idx})"
|
||||
f"(key not in whitelist for board '{board.id}')"
|
||||
)
|
||||
return None
|
||||
|
||||
parts = text.split(None, 1)
|
||||
args = parts[1].strip() if len(parts) > 1 else ""
|
||||
return self._dispatch(cfg, sender, sender_key, args)
|
||||
return self._dispatch(board, channel_idx, sender, sender_key, args)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _dispatch(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str:
|
||||
def _dispatch(self, board, channel_idx, sender, sender_key, args):
|
||||
sub = args.split(None, 1)[0].lower() if args else ""
|
||||
rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else ""
|
||||
|
||||
if sub == "post":
|
||||
return self._handle_post(cfg, sender, sender_key, rest)
|
||||
return self._handle_post(board, channel_idx, sender, sender_key, rest)
|
||||
if sub == "read":
|
||||
return self._handle_read(cfg, rest)
|
||||
return self._handle_read(board, rest)
|
||||
if sub == "help" or not sub:
|
||||
return self._handle_help(cfg)
|
||||
return f"Unknown command '{sub}'. {self._handle_help(cfg)}"
|
||||
return self._handle_help(board)
|
||||
return f"Unknown command '{sub}'. {self._handle_help(board)}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sub-command: post
|
||||
# post
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_post(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str:
|
||||
regions: List[str] = cfg.get("regions", [])
|
||||
categories: List[str] = cfg["categories"]
|
||||
def _handle_post(self, board, channel_idx, sender, sender_key, args):
|
||||
regions = board.regions
|
||||
categories = board.categories
|
||||
tokens = args.split(None, 2) if args else []
|
||||
|
||||
if regions:
|
||||
@@ -407,16 +372,14 @@ class BbsCommandHandler:
|
||||
f"Categories: {', '.join(categories)}"
|
||||
)
|
||||
region, category, text = tokens[0], tokens[1], tokens[2]
|
||||
region_upper = region.upper()
|
||||
valid_regions = [r.upper() for r in regions]
|
||||
if region_upper not in valid_regions:
|
||||
valid_r = [r.upper() for r in regions]
|
||||
if region.upper() not in valid_r:
|
||||
return f"Invalid region '{region}'. Valid: {', '.join(regions)}"
|
||||
region = regions[valid_regions.index(region_upper)]
|
||||
category_upper = category.upper()
|
||||
valid_cats = [c.upper() for c in categories]
|
||||
if category_upper not in valid_cats:
|
||||
region = regions[valid_r.index(region.upper())]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if category.upper() not in valid_c:
|
||||
return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
|
||||
category = categories[valid_cats.index(category_upper)]
|
||||
category = categories[valid_c.index(category.upper())]
|
||||
else:
|
||||
if len(tokens) < 2:
|
||||
return (
|
||||
@@ -425,67 +388,57 @@ class BbsCommandHandler:
|
||||
)
|
||||
region = ""
|
||||
category, text = tokens[0], tokens[1]
|
||||
category_upper = category.upper()
|
||||
valid_cats = [c.upper() for c in categories]
|
||||
if category_upper not in valid_cats:
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if category.upper() not in valid_c:
|
||||
return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
|
||||
category = categories[valid_cats.index(category_upper)]
|
||||
category = categories[valid_c.index(category.upper())]
|
||||
|
||||
msg = BbsMessage(
|
||||
channel=cfg["channel"],
|
||||
region=region,
|
||||
category=category,
|
||||
sender=sender,
|
||||
sender_key=sender_key,
|
||||
text=text,
|
||||
channel=channel_idx,
|
||||
region=region, category=category,
|
||||
sender=sender, sender_key=sender_key, text=text,
|
||||
)
|
||||
self._service.post_message(msg)
|
||||
region_label = f" [{region}]" if region else ""
|
||||
return f"Posted [{category}]{region_label}: {text[:60]}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sub-command: read
|
||||
# read
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_read(self, cfg: Dict, args: str) -> str:
|
||||
regions: List[str] = cfg.get("regions", [])
|
||||
categories: List[str] = cfg["categories"]
|
||||
def _handle_read(self, board, args):
|
||||
regions = board.regions
|
||||
categories = board.categories
|
||||
tokens = args.split() if args else []
|
||||
|
||||
region: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
region = None
|
||||
category = None
|
||||
|
||||
if regions:
|
||||
valid_regions_upper = [r.upper() for r in regions]
|
||||
valid_cats_upper = [c.upper() for c in categories]
|
||||
if len(tokens) >= 1:
|
||||
tok0 = tokens[0].upper()
|
||||
if tok0 in valid_regions_upper:
|
||||
region = regions[valid_regions_upper.index(tok0)]
|
||||
valid_r = [r.upper() for r in regions]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if tokens:
|
||||
if tokens[0].upper() in valid_r:
|
||||
region = regions[valid_r.index(tokens[0].upper())]
|
||||
if len(tokens) >= 2:
|
||||
tok1 = tokens[1].upper()
|
||||
if tok1 in valid_cats_upper:
|
||||
category = categories[valid_cats_upper.index(tok1)]
|
||||
if tokens[1].upper() in valid_c:
|
||||
category = categories[valid_c.index(tokens[1].upper())]
|
||||
else:
|
||||
return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}"
|
||||
else:
|
||||
return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}"
|
||||
else:
|
||||
valid_cats_upper = [c.upper() for c in categories]
|
||||
if len(tokens) >= 1:
|
||||
tok0 = tokens[0].upper()
|
||||
if tok0 in valid_cats_upper:
|
||||
category = categories[valid_cats_upper.index(tok0)]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if tokens:
|
||||
if tokens[0].upper() in valid_c:
|
||||
category = categories[valid_c.index(tokens[0].upper())]
|
||||
else:
|
||||
return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}"
|
||||
|
||||
messages = self._service.get_messages(
|
||||
cfg["channel"], region=region, category=category, limit=self.READ_LIMIT,
|
||||
board.channels, region=region, category=category, limit=self.READ_LIMIT,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
return "BBS: no messages found."
|
||||
|
||||
lines = []
|
||||
for m in messages:
|
||||
ts = m.timestamp[:16].replace("T", " ")
|
||||
@@ -494,25 +447,22 @@ class BbsCommandHandler:
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sub-command: help
|
||||
# help
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_help(self, cfg: Dict) -> str:
|
||||
regions: List[str] = cfg.get("regions", [])
|
||||
categories: List[str] = cfg["categories"]
|
||||
name = cfg.get("name", f"ch{cfg['channel']}")
|
||||
if regions:
|
||||
def _handle_help(self, board) -> str:
|
||||
cats = ", ".join(board.categories)
|
||||
if board.regions:
|
||||
regs = ", ".join(board.regions)
|
||||
return (
|
||||
f"BBS [{name}] | "
|
||||
f"BBS [{board.name}] | "
|
||||
f"!bbs post [region] [cat] [text] | "
|
||||
f"!bbs read [region] [cat] | "
|
||||
f"Regions: {', '.join(regions)} | "
|
||||
f"Categories: {', '.join(categories)}"
|
||||
f"Regions: {regs} | Categories: {cats}"
|
||||
)
|
||||
return (
|
||||
f"BBS [{name}] | "
|
||||
f"BBS [{board.name}] | "
|
||||
f"!bbs post [cat] [text] | "
|
||||
f"!bbs read [cat] | "
|
||||
f"Categories: {', '.join(categories)}"
|
||||
f"Categories: {cats}"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user