feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0)

Implements a fully offline Bulletin Board System for use on MeshCore
mesh networks, designed for emergency communication organisations
(NoodNet Zwolle, NoodNet OV, Dalfsen).

New files:
- services/bbs_service.py: SQLite-backed persistence layer with
  BbsMessage dataclass, BbsService (post/read/purge) and
  BbsCommandHandler (!bbs post/read/help mesh command parser).
  Whitelist enforcement via sender public key (silent drop on
  unknown sender). Per-channel configurable regions, categories
  and retention period.
- gui/panels/bbs_panel.py: Dashboard panel with channel selector,
  region/category filters, scrollable message list and post form.
  Region filter is conditionally visible based on channel config.

Modified files:
- config.py: BBS_CHANNELS configuration block added (ch 2/3/4).
  Version bumped to 1.14.0.
- services/bot.py: MeshBot accepts optional bbs_handler parameter.
  Incoming !bbs commands are routed to BbsCommandHandler before
  keyword matching; no changes to existing bot behaviour.
- gui/dashboard.py: BbsPanel registered as standalone panel with
  📋 BBS drawer menu item.
- gui/panels/__init__.py: BbsPanel re-exported.

Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only).
No new external dependencies.
This commit is contained in:
pe1hvh
2026-03-14 08:05:30 +01:00
parent 64b6c62125
commit e3bd422dfd
7 changed files with 1043 additions and 2 deletions
+21
View File
@@ -16,6 +16,7 @@ from meshcore_gui import config
from meshcore_gui.core.protocols import SharedDataReader
from meshcore_gui.gui.panels import (
ActionsPanel,
BbsPanel,
ContactsPanel,
DevicePanel,
MapPanel,
@@ -24,6 +25,7 @@ from meshcore_gui.gui.panels import (
RxLogPanel,
)
from meshcore_gui.gui.archive_page import ArchivePage
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
from meshcore_gui.services.pin_store import PinStore
from meshcore_gui.services.room_password_store import RoomPasswordStore
@@ -264,6 +266,7 @@ _STANDALONE_ITEMS = [
('\U0001f4e1', 'DEVICE', 'device'),
('\u26a1', 'ACTIONS', 'actions'),
('\U0001f4ca', 'RX LOG', 'rxlog'),
('\U0001f4cb', 'BBS', 'bbs'),
]
_EXT_LINKS = config.EXT_LINKS
@@ -295,6 +298,13 @@ class DashboardPage:
self._pin_store = pin_store
self._room_password_store = room_password_store
# BBS service (singleton, shared with bot routing)
from meshcore_gui import config as _cfg
self._bbs_service = BbsService()
self._bbs_handler = BbsCommandHandler(
self._bbs_service, _cfg.BBS_CHANNELS
)
# Panels (created fresh on each render)
self._device: DevicePanel | None = None
self._contacts: ContactsPanel | None = None
@@ -303,6 +313,7 @@ class DashboardPage:
self._actions: ActionsPanel | None = None
self._rxlog: RxLogPanel | None = None
self._room_server: RoomServerPanel | None = None
self._bbs: BbsPanel | None = None
# Header status label
self._status_label = None
@@ -349,6 +360,8 @@ class DashboardPage:
self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled)
self._rxlog = RxLogPanel()
self._room_server = RoomServerPanel(put_cmd, self._room_password_store)
from meshcore_gui import config as _cfg
self._bbs = BbsPanel(put_cmd, self._bbs_service, _cfg.BBS_CHANNELS)
# Inject DOMCA theme (fonts + CSS variables)
ui.add_head_html(_DOMCA_HEAD)
@@ -509,6 +522,7 @@ class DashboardPage:
('actions', self._actions),
('rxlog', self._rxlog),
('rooms', self._room_server),
('bbs', self._bbs),
]
for panel_id, panel_obj in panel_defs:
@@ -735,6 +749,9 @@ class DashboardPage:
self._room_server.update(data)
elif self._active_panel == 'rxlog':
self._rxlog.update(data)
elif self._active_panel == 'bbs':
if self._bbs:
self._bbs.update(data)
# ------------------------------------------------------------------
# Room Server callback (from ContactsPanel)
@@ -817,6 +834,10 @@ class DashboardPage:
if data['rxlog_updated'] or is_first:
self._rxlog.update(data)
elif self._active_panel == 'bbs':
if self._bbs:
self._bbs.update(data)
# Signal worker that GUI is ready for data
if is_first and data['channels'] and data['contacts']:
self._shared.mark_gui_initialized()
+1
View File
@@ -15,3 +15,4 @@ from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401
from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401
from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401
from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401
from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401
+310
View File
@@ -0,0 +1,310 @@
"""BBS panel — offline Bulletin Board System viewer and post form."""
from typing import Callable, Dict, List, Optional
from nicegui import ui
from meshcore_gui.config import debug_print
from meshcore_gui.services.bbs_service import BbsMessage, BbsService
class BbsPanel:
"""BBS panel: channel selector, region/category filters, message list and post form.
All data access goes through :class:`~meshcore_gui.services.bbs_service.BbsService`.
No direct SQLite access in this class (SOLID: SRP / DIP).
Args:
put_command: Callable to enqueue a command dict for the worker.
bbs_service: Shared ``BbsService`` instance.
channels_config: ``BBS_CHANNELS`` list from ``config.py``.
"""
def __init__(
self,
put_command: Callable[[Dict], None],
bbs_service: BbsService,
channels_config: List[Dict],
) -> None:
self._put_command = put_command
self._service = bbs_service
self._channels_config = channels_config
# Indexed for fast lookup
self._channels_by_idx: Dict[int, Dict] = {
cfg["channel"]: cfg for cfg in channels_config
}
# UI state
self._active_channel_idx: int = (
channels_config[0]["channel"] if channels_config else 0
)
self._active_region: Optional[str] = None
self._active_category: Optional[str] = None
# UI element references
self._msg_list_container = None
self._region_select = None
self._region_row = None
self._category_select = None
self._text_input = None
self._post_region_select = None
self._post_region_row = None
self._post_category_select = None
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self) -> None:
"""Build the complete BBS panel layout."""
with ui.card().classes('w-full'):
ui.label('📋 BBS — Bulletin Board System').classes('font-bold text-gray-600')
# ── Channel selector ──────────────────────────────────────
with ui.row().classes('w-full items-center gap-4'):
ui.label('Channel:').classes('text-sm text-gray-600')
for cfg in self._channels_config:
idx = cfg["channel"]
name = cfg["name"]
ui.button(
name,
on_click=lambda i=idx: self._select_channel(i),
).props('flat no-caps').classes('text-xs')
ui.separator()
# ── Filter row ────────────────────────────────────────────
with ui.row().classes('w-full items-center gap-4'):
ui.label('Filter:').classes('text-sm text-gray-600')
# Region filter (hidden when channel has no regions)
self._region_row = ui.row().classes('items-center gap-2')
with self._region_row:
ui.label('Region:').classes('text-xs text-gray-600')
self._region_select = ui.select(
options=[],
value=None,
on_change=lambda e: self._on_region_filter(e.value),
).classes('text-xs').style('min-width: 120px')
# Category filter
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,
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).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')
# Post region select (hidden when channel has no regions)
self._post_region_row = ui.row().classes('items-center gap-1')
with self._post_region_row:
self._post_region_select = ui.select(
options=[],
label='Region',
).classes('text-xs').style('min-width: 110px')
# Post category select
self._post_category_select = ui.select(
options=[],
label='Category',
).classes('text-xs').style('min-width: 110px')
self._text_input = ui.input(
placeholder='Message text…',
).classes('flex-grow text-sm')
ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs')
# Initial render for the default channel
self._select_channel(self._active_channel_idx)
# ------------------------------------------------------------------
# Channel selection
# ------------------------------------------------------------------
def _select_channel(self, channel_idx: int) -> None:
"""Switch the active channel and rebuild filter options.
Args:
channel_idx: MeshCore channel index to activate.
"""
self._active_channel_idx = channel_idx
self._active_region = None
self._active_category = None
cfg = self._channels_by_idx.get(channel_idx, {})
regions: List[str] = cfg.get("regions", [])
categories: List[str] = cfg.get("categories", [])
# Region filter visibility
has_regions = bool(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)
# Populate region selects
region_opts = ["(all)"] + 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
# Populate category selects
cat_opts = ["(all)"] + 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._refresh_messages()
# ------------------------------------------------------------------
# Filter callbacks
# ------------------------------------------------------------------
def _on_region_filter(self, value: Optional[str]) -> None:
"""Handle region filter change.
Args:
value: Selected region string, or ``'(all)'``.
"""
self._active_region = None if (not value or value == "(all)") else value
self._refresh_messages()
def _on_category_filter(self, value: Optional[str]) -> None:
"""Handle category filter change.
Args:
value: Selected category string, or ``'(all)'``.
"""
self._active_category = None if (not value or value == "(all)") else value
self._refresh_messages()
# ------------------------------------------------------------------
# Message list refresh
# ------------------------------------------------------------------
def _refresh_messages(self) -> None:
"""Query the BBS service and rebuild the message list UI."""
if not self._msg_list_container:
return
messages = self._service.get_all_messages(
channel=self._active_channel_idx,
region=self._active_region,
category=self._active_category,
)
self._msg_list_container.clear()
with self._msg_list_container:
if not messages:
ui.label('No messages.').classes('text-xs text-gray-400 italic')
for msg in messages:
self._render_message_row(msg)
def _render_message_row(self, msg: BbsMessage) -> None:
"""Render a single message row in the message list.
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}"
with ui.column().classes('w-full gap-0 py-1 border-b border-gray-200'):
ui.label(header).classes('text-xs text-gray-500')
ui.label(msg.text).classes('text-sm')
# ------------------------------------------------------------------
# Post
# ------------------------------------------------------------------
def _on_post(self) -> None:
"""Handle the Send button: validate inputs and post a BBS message."""
cfg = self._channels_by_idx.get(self._active_channel_idx, {})
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:
ui.notify("Message text cannot be empty.", type="warning")
return
category = (
self._post_category_select.value
if self._post_category_select
else (categories[0] if categories else "")
)
if not category:
ui.notify("Please select a category.", type="warning")
return
region = ""
if regions and self._post_region_select:
region = self._post_region_select.value or ""
# Build and persist the message (GUI post — sender is the local device)
msg = BbsMessage(
channel=self._active_channel_idx,
region=region,
category=category,
sender="Me",
sender_key="",
text=text,
)
self._service.post_message(msg)
# Optionally also broadcast via the mesh (put_command enqueues for worker)
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,
"text": mesh_text,
})
debug_print(f"BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}")
if self._text_input:
self._text_input.value = ""
self._refresh_messages()
ui.notify("Message posted.", type="positive")
# ------------------------------------------------------------------
# External update hook (called from dashboard timer)
# ------------------------------------------------------------------
def update(self, data: Dict) -> None:
"""Called by the dashboard timer. Refreshes if new data arrived.
Currently a lightweight no-op: the BBS panel refreshes on user
interaction. Override for real-time auto-refresh if desired.
Args:
data: SharedData snapshot (unused; kept for interface consistency).
"""
# No-op: BBS data is local SQLite, not pushed via SharedData.
# Active refresh only happens on user action or channel switch.