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:
pe1hvh
2026-03-14 08:43:50 +01:00
parent 430628c945
commit 21e266ceb5
4 changed files with 610 additions and 544 deletions

View File

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

View File

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

View File

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

View File

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