feat(bbs): DM-based BBS with short syntax and auto-abbreviations(#v1.14.0)

Adds an offline Bulletin Board System accessible via Direct Message to
the node's own key. All BBS commands (!p, !r, !bbs) are handled directly
in EventHandler.on_contact_msg, independent of MeshBot.

- One node = one board; settings reduced to a single channel selector
- Short syntax: !p <cat> <text> and !r [cat] alongside full !bbs syntax
- Category abbreviations computed automatically (shortest unique prefix)
- !r and !bbs help always include the abbreviation table in the reply
- DM reply routed back to sender via command_sink
- SQLite message store with WAL mode and configurable retention
This commit is contained in:
pe1hvh
2026-03-14 18:36:58 +01:00
parent 2d582b79b8
commit 374897448e
8 changed files with 625 additions and 343 deletions
+16 -18
View File
@@ -30,33 +30,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
> lower CPU usage during idle operation, and more stable map rendering.
---
## [1.14.0] - 2026-03-14 — Offline BBS (Bulletin Board System)
## [1.14.0] - 2026-03-14 — BBS (Bulletin Board System)
### Added
- 🆕 **`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.
- 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.
- 🆕 **BBS — Bulletin Board System** — offline berichtenbord voor mesh-netwerken.
- Één node beheert één board op één channel. Alle commando's via **Direct Message** aan de node; het channel blijft schoon.
- Korte syntax: `!p <cat> <tekst>` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). `!r` zonder args toont de afkortingstabel altijd mee.
- Volledige syntax behouden: `!bbs post`, `!bbs read`, `!bbs help`.
- Optioneel regio-filter (`!p Zwolle U hulp nodig`) en sender-whitelist.
- Settings-pagina (`/bbs-settings`): één channel-selector, categorieën, retentie (uur), en een ingeklapte Advanced-sectie voor regio's en allowed keys.
- Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode).
### Changed
- 🔄 **`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.
- 🔄 **`ble/events.py`** — DMs die beginnen met `!` worden direct verwerkt door `BbsCommandHandler`, volledig los van `MeshBot`.
- 🔄 **`services/bot.py`** — `MeshBot` is weer een pure keyword/channel responder; BBS-routing verwijderd.
- 🔄 **`services/bbs_config_store.py`** — `get_single_board()`, `set_single_board()`, `clear_single_board()` toegevoegd.
- 🔄 **`gui/dashboard.py`** — `BbsPanel` geregistreerd, `📋 BBS` drawer-item toegevoegd.
### Storage
```
~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2)
~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag
~/.meshcore-gui/bbs/bbs_config.json board configuratie
~/.meshcore-gui/bbs/bbs_messages.db SQLite berichtenopslag
```
### Not changed
- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels.
---
## [1.13.5] - 2026-03-14 — Route back-button and map popup flicker fixes
+95 -1
View File
@@ -1184,11 +1184,105 @@ meshcore-gui/
└── README.md
```
## 15. Roadmap
## 15. BBS — Bulletin Board System
MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging.
### Design
One node manages one board. Multiple boards require multiple nodes. All BBS commands are sent as a **Direct Message to the BBS node** — the channel stays clean and replies are private to the sender.
```
User ──DM──▶ BBS node (public key)
processes command
User ◀──DM── reply (only visible to sender)
```
Channel commands remain available as a fallback, but DM is the primary interface.
### Settings
Open the BBS settings via the gear icon (⚙) in the BBS panel, or navigate to `/bbs-settings`.
```
BBS Settings
─────────────────────────────────────────────
Channel: [2] NoodNet Zwolle ▼
Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL
Retain: 48 hours
[Save]
▶ Advanced
Regions (comma-separated)
Allowed keys (empty = everyone on the channel)
```
- **Channel** — select which device channel this node's board listens on.
- **Categories** — comma-separated list of valid category tags.
- **Retain** — message retention in hours (default 48).
- **Advanced → Regions** — optional region tags for geographic filtering.
- **Advanced → Allowed keys** — sender public key whitelist; empty = all senders allowed.
### Command syntax
#### Short syntax
| Command | Description |
|---|---|
| `!p <cat> <text>` | Post a message |
| `!p <region> <cat> <text>` | Post with region |
| `!r` | Read 5 most recent (all categories) |
| `!r <cat>` | Read filtered by category |
| `!r <region> <cat>` | Read filtered by region and category |
Category abbreviations are computed automatically as the shortest unique prefix within the configured category list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`:
```
U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
```
If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), the node calculates longer prefixes automatically: `ME` and `MI`. The `!r` and `!bbs help` replies always include the current abbreviation table.
#### Full syntax
| Command | Description |
|---|---|
| `!bbs help` | Show commands and abbreviation table |
| `!bbs post <category> <text>` | Post a message |
| `!bbs post <region> <category> <text>` | Post with region |
| `!bbs read` | Read 5 most recent |
| `!bbs read <category>` | Read filtered by category |
| `!bbs read <region> <category>` | Read filtered by region and category |
#### Example help reply
```
BBS [NoodNet Zwolle] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
```
### Error handling
| Situation | Reply |
|---|---|
| Unknown category | Lists valid categories and abbreviations |
| Ambiguous abbreviation | Lists all matching categories |
| Sender not on whitelist | Silent drop — no reply |
### Storage
```
~/.meshcore-gui/bbs/bbs_messages.db — SQLite message store (WAL mode)
~/.meshcore-gui/bbs/bbs_config.json — Board configuration (v2 format)
```
---
## 16. Roadmap
This project is under active development. The most common features from the official MeshCore Companion apps are being implemented gradually. Planned additions include:
- [x] **Cross-frequency bridge** — standalone daemon connecting two devices on different frequencies via configurable channel forwarding (see [11. Cross-Frequency Bridge](#11-cross-frequency-bridge))
- [x] **BBS — Bulletin Board System** — offline message board with DM-based commands, category/region filtering and automatic abbreviations (see [15. BBS](#15-bbs--bulletin-board-system))
- [ ] **Observer mode** — passively monitor mesh traffic without transmitting, useful for network analysis, coverage mapping and long-term logging
- [ ] **Room Server administration** — authenticate as admin to manage Room Server settings and users directly from the GUI
- [ ] **Repeater management** — connect to repeater nodes to view status and adjust configuration
+53 -3
View File
@@ -4,9 +4,16 @@ Device event callbacks for MeshCore GUI.
Handles ``CHANNEL_MSG_RECV``, ``CONTACT_MSG_RECV`` and ``RX_LOG_DATA``
events from the MeshCore library. Extracted from ``SerialWorker`` so the
worker only deals with connection lifecycle.
BBS routing
~~~~~~~~~~~
Direct Messages (``CONTACT_MSG_RECV``) whose text starts with ``!`` are
forwarded to :class:`~meshcore_gui.services.bbs_service.BbsCommandHandler`
**before** any other DM processing. This path is completely independent of
:class:`~meshcore_gui.services.bot.MeshBot`.
"""
from typing import Dict, Optional
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
from meshcore_gui.config import debug_print
from meshcore_gui.core.models import Message, RxLogEntry
@@ -15,6 +22,9 @@ from meshcore_gui.ble.packet_decoder import PacketDecoder, PayloadType
from meshcore_gui.services.bot import MeshBot
from meshcore_gui.services.dedup import DualDeduplicator
if TYPE_CHECKING:
from meshcore_gui.services.bbs_service import BbsCommandHandler
class EventHandler:
"""Processes device events and writes results to shared data.
@@ -35,11 +45,15 @@ class EventHandler:
decoder: PacketDecoder,
dedup: DualDeduplicator,
bot: MeshBot,
bbs_handler: Optional["BbsCommandHandler"] = None,
command_sink: Optional[Callable[[Dict], None]] = None,
) -> None:
self._shared = shared
self._decoder = decoder
self._dedup = dedup
self._bot = bot
self._bbs_handler = bbs_handler
self._command_sink = command_sink
# Cache: message_hash → path_hashes (from RX_LOG decode).
# Used by on_channel_msg fallback to recover hashes that the
@@ -409,9 +423,45 @@ class EventHandler:
or (pubkey[:8] if pubkey else '')
)
dm_text = payload.get('text', '')
# BBS routing: DMs starting with '!' go directly to BbsCommandHandler.
# This path is independent of the bot (MeshBot is for channel messages only).
if (
self._bbs_handler is not None
and self._command_sink is not None
and dm_text.strip().startswith("!")
):
bbs_reply = self._bbs_handler.handle_dm(
sender=sender,
sender_key=pubkey,
text=dm_text,
)
if bbs_reply is not None:
debug_print(f"BBS DM reply to {sender} ({pubkey[:8]}): {bbs_reply[:60]}")
self._command_sink({
"action": "send_dm",
"pubkey": pubkey,
"text": bbs_reply,
})
# Always store the incoming DM in the message archive too
self._shared.add_message(Message.incoming(
sender,
dm_text,
None,
snr=self._extract_snr(payload),
path_len=path_len,
sender_pubkey=pubkey,
path_hashes=path_hashes,
path_names=path_names,
message_hash=msg_hash,
))
debug_print(f"BBS DM stored from {sender}: {dm_text[:30]}")
return
self._shared.add_message(Message.incoming(
sender,
payload.get('text', ''),
dm_text,
None,
snr=self._extract_snr(payload),
path_len=path_len,
@@ -420,7 +470,7 @@ class EventHandler:
path_names=path_names,
message_hash=msg_hash,
))
debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}")
debug_print(f"DM received from {sender}: {dm_text[:30]}")
# ------------------------------------------------------------------
# Helpers
+10
View File
@@ -54,6 +54,8 @@ from meshcore_gui.ble.commands import CommandHandler
from meshcore_gui.ble.events import EventHandler
from meshcore_gui.ble.packet_decoder import PacketDecoder
from meshcore_gui.services.bot import BotConfig, MeshBot
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
from meshcore_gui.services.bbs_config_store import BbsConfigStore
from meshcore_gui.services.cache import DeviceCache
from meshcore_gui.services.dedup import DualDeduplicator
from meshcore_gui.services.device_identity import write_device_identity
@@ -124,6 +126,12 @@ class _BaseWorker(abc.ABC):
enabled_check=shared.is_bot_enabled,
)
# BBS handler — wired directly into EventHandler for DM routing.
# Independent of the bot; uses a shared config store and service.
_bbs_config = BbsConfigStore()
_bbs_service = BbsService()
self._bbs_handler = BbsCommandHandler(service=_bbs_service, config_store=_bbs_config)
# Channel indices that still need keys from device
self._pending_keys: Set[int] = set()
@@ -244,6 +252,8 @@ class _BaseWorker(abc.ABC):
decoder=self._decoder,
dedup=self._dedup,
bot=self._bot,
bbs_handler=self._bbs_handler,
command_sink=self.shared.put_command,
)
self._cmd_handler = CommandHandler(
mc=self.mc, shared=self.shared, cache=self._cache,
+105 -182
View File
@@ -368,8 +368,10 @@ class BbsPanel:
class BbsSettingsPage:
"""Standalone BBS settings page, registered at /bbs-settings.
Follows the same pattern as RoutePage: one instance, render() called
per page load.
One node = one board. The page shows a single channel selector
populated from the active device channels, plus a categories field,
a retention field, and a collapsible Advanced section for regions
and allowed keys. There is no board creation or deletion UI.
Args:
shared: SharedData instance (for device channel list).
@@ -384,7 +386,7 @@ class BbsSettingsPage:
self._shared = shared
self._config_store = config_store
self._device_channels: List[Dict] = []
self._boards_settings_container = None
self._container = None
def render(self) -> None:
"""Render the BBS settings page."""
@@ -411,204 +413,125 @@ class BbsSettingsPage:
ui.label('BBS Settings').classes('font-bold text-gray-600')
ui.separator()
self._boards_settings_container = ui.column().classes('w-full gap-3')
with self._boards_settings_container:
self._container = ui.column().classes('w-full gap-3')
with self._container:
if not self._device_channels:
ui.label('Connect device to see channels.').classes(
'text-xs text-gray-400 italic'
)
else:
self._render_all()
self._render_settings()
# ------------------------------------------------------------------
# Settings rendering
# ------------------------------------------------------------------
def _render_all(self) -> None:
"""Render all channel rows and the advanced section."""
for ch in self._device_channels:
self._render_channel_settings_row(ch)
def _render_settings(self) -> None:
"""Render the single-board settings block."""
board = self._config_store.get_single_board()
ui.separator()
# Build channel options: {idx: "[idx] name"}
ch_options = {
ch.get('idx', ch.get('index', 0)):
f"[{ch.get('idx', ch.get('index', 0))}] {ch.get('name', '?')}"
for ch in self._device_channels
}
with ui.expansion('Advanced', value=False).classes('w-full').props('dense'):
ui.label('Regions and key list per channel').classes(
'text-xs text-gray-500 pb-1'
current_idx = (
board.channels[0] if board and board.channels
else next(iter(ch_options), 0)
)
cats_value = (
', '.join(board.categories) if board
else ', '.join(DEFAULT_CATEGORIES)
)
retention_value = (
str(board.retention_hours) if board
else str(DEFAULT_RETENTION_HOURS)
)
adv_regions_value = ', '.join(board.regions) if board else ''
adv_keys_value = ', '.join(board.allowed_keys) if board else ''
# ── Main block ───────────────────────────────────────────────
with ui.column().classes('w-full gap-2'):
with ui.row().classes('w-full items-center gap-2'):
ui.label('Channel:').classes('text-xs text-gray-600 w-24 shrink-0')
ch_select = ui.select(
options=ch_options,
value=current_idx,
).classes('text-xs flex-grow')
with ui.row().classes('w-full items-center gap-2'):
ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0')
cats_input = ui.input(value=cats_value).classes('text-xs flex-grow')
with ui.row().classes('w-full items-center gap-2'):
ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0')
retention_input = ui.input(
value=retention_value,
).classes('text-xs').style('max-width: 80px')
ui.label('hours').classes('text-xs text-gray-600')
# ── Advanced (collapsed) ─────────────────────────────────────
with ui.expansion('Advanced', value=False).classes('w-full mt-2').props('dense'):
ui.label('Regions and allowed keys').classes('text-xs text-gray-500 pb-1')
regions_input = ui.input(
label='Regions (comma-separated)',
value=adv_regions_value,
).classes('w-full text-xs')
keys_input = ui.input(
label='Allowed keys (empty = everyone on the channel)',
value=adv_keys_value,
).classes('w-full text-xs')
# ── Save ─────────────────────────────────────────────────────
def _save(
cs=ch_select,
ci=cats_input,
ri=retention_input,
rgi=regions_input,
ki=keys_input,
) -> None:
idx = cs.value
ch_name = ch_options.get(idx, f'Ch {idx}')
categories = [
c.strip().upper()
for c in (ci.value or '').split(',') if c.strip()
] or list(DEFAULT_CATEGORIES)
try:
ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS)
except ValueError:
ret_hours = DEFAULT_RETENTION_HOURS
regions = [r.strip() for r in (rgi.value or '').split(',') if r.strip()]
allowed_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()]
self._config_store.set_single_board(
channel_idx=idx,
channel_name=ch_name,
categories=categories,
retention_hours=ret_hours,
regions=regions,
allowed_keys=allowed_keys,
)
advanced_any = False
for ch in self._device_channels:
idx = ch.get('idx', ch.get('index', 0))
board = self._config_store.get_board(f'ch{idx}')
if board is not None:
self._render_channel_advanced_row(ch, board)
advanced_any = True
if not advanced_any:
ui.label(
'Enable at least one channel to see advanced options.'
).classes('text-xs text-gray-400 italic')
debug_print(f'BBS settings: saved ch{idx} {ch_name}')
ui.notify(f'BBS saved — {ch_name}.', type='positive')
self._rebuild()
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2')
def _rebuild(self) -> None:
"""Clear and re-render the settings container in-place."""
if not self._boards_settings_container:
if not self._container:
return
self._boards_settings_container.clear()
with self._boards_settings_container:
data = self._shared.get_snapshot()
self._device_channels = data.get('channels', [])
self._container.clear()
with self._container:
if not self._device_channels:
ui.label('Connect device to see channels.').classes(
'text-xs text-gray-400 italic'
)
else:
self._render_all()
def _render_channel_settings_row(self, ch: Dict) -> None:
"""Render the standard settings row for a single device channel.
Args:
ch: Device channel dict with 'idx'/'index' and 'name' keys.
"""
idx = ch.get('idx', ch.get('index', 0))
ch_name = ch.get('name', f'Ch {idx}')
board_id = f'ch{idx}'
board = self._config_store.get_board(board_id)
is_active = board is not None
cats_value = ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES)
retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS)
with ui.card().classes('w-full p-2'):
with ui.row().classes('w-full items-center justify-between'):
ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium')
active_toggle = ui.toggle(
{True: '● Active', False: '○ Off'},
value=is_active,
).classes('text-xs')
with ui.row().classes('w-full items-center gap-2 mt-1'):
ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0')
cats_input = ui.input(value=cats_value).classes('text-xs flex-grow')
with ui.row().classes('w-full items-center gap-2 mt-1'):
ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0')
retention_input = ui.input(value=retention_value).classes('text-xs').style(
'max-width: 80px'
)
ui.label('hrs').classes('text-xs text-gray-600')
def _save(
bid=board_id,
bname=ch_name,
bidx=idx,
tog=active_toggle,
ci=cats_input,
ri=retention_input,
) -> None:
if tog.value:
existing = self._config_store.get_board(bid)
categories = [
c.strip().upper()
for c in (ci.value or '').split(',') if c.strip()
] or list(DEFAULT_CATEGORIES)
try:
ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS)
except ValueError:
ret_hours = DEFAULT_RETENTION_HOURS
extra_channels = (
[c for c in existing.channels if c != bidx]
if existing else []
)
updated = BbsBoard(
id=bid,
name=bname,
channels=[bidx] + extra_channels,
categories=categories,
regions=existing.regions if existing else [],
retention_hours=ret_hours,
allowed_keys=existing.allowed_keys if existing else [],
)
self._config_store.set_board(updated)
debug_print(f'BBS settings: channel {bid} saved')
ui.notify(f'{bname} saved.', type='positive')
else:
self._config_store.delete_board(bid)
debug_print(f'BBS settings: channel {bid} disabled')
ui.notify(f'{bname} disabled.', type='warning')
self._rebuild()
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1')
def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None:
"""Render the advanced settings block for a single active channel.
Args:
ch: Device channel dict.
board: Existing BbsBoard for this channel.
"""
idx = ch.get('idx', ch.get('index', 0))
ch_name = ch.get('name', f'Ch {idx}')
board_id = f'ch{idx}'
with ui.column().classes('w-full gap-1 py-2'):
ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium')
regions_input = ui.input(
label='Regions (comma-separated)',
value=', '.join(board.regions),
).classes('w-full text-xs')
wl_input = ui.input(
label='Allowed keys (empty = everyone on the channel)',
value=', '.join(board.allowed_keys),
).classes('w-full text-xs')
other_channels = [
c for c in self._device_channels
if c.get('idx', c.get('index', 0)) != idx
]
ch_checks: Dict[int, object] = {}
if other_channels:
ui.label('Combine with channels:').classes('text-xs text-gray-600 mt-1')
with ui.row().classes('flex-wrap gap-2'):
for other_ch in other_channels:
other_idx = other_ch.get('idx', other_ch.get('index', 0))
other_name = other_ch.get('name', f'Ch {other_idx}')
cb = ui.checkbox(
f'[{other_idx}] {other_name}',
value=other_idx in board.channels,
).classes('text-xs')
ch_checks[other_idx] = cb
def _save_adv(
bid=board_id,
bidx=idx,
bname=ch_name,
ri=regions_input,
wli=wl_input,
cc=ch_checks,
) -> None:
existing = self._config_store.get_board(bid)
if existing is None:
ui.notify('Enable this channel first.', type='warning')
return
regions = [
r.strip() for r in (ri.value or '').split(',') if r.strip()
]
allowed_keys = [
k.strip() for k in (wli.value or '').split(',') if k.strip()
]
combined = [bidx] + [oidx for oidx, cb in cc.items() if cb.value]
updated = BbsBoard(
id=bid,
name=bname,
channels=combined,
categories=existing.categories,
regions=regions,
retention_hours=existing.retention_hours,
allowed_keys=allowed_keys,
)
self._config_store.set_board(updated)
debug_print(f'BBS settings (advanced): {bid} saved')
ui.notify(f'{bname} saved.', type='positive')
self._rebuild()
ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1')
ui.separator()
self._render_settings()
+75 -5
View File
@@ -4,12 +4,15 @@ BBS board configuration store for MeshCore GUI.
Persists BBS board configuration to
``~/.meshcore-gui/bbs/bbs_config.json``.
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:
Design (v1.14.0 redesign)
~~~~~~~~~~~~~~~~~~~~~~~~~
One node = one board. The settings UI exposes a single channel selector;
the board id is always ``ch{channel_idx}`` and the name is taken from the
device channel. There is no Create/Delete UI the board is saved or
cleared through :meth:`set_single_board` / :meth:`clear_single_board`.
- One board per channel (classic per-channel BBS)
- One board spanning multiple channels (shared bulletin board)
Multiple-board storage is retained internally so that the storage layer
(``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged.
Config version history
~~~~~~~~~~~~~~~~~~~~~~
@@ -300,3 +303,70 @@ class BbsConfigStore:
"""
with self._lock:
return any(b.id == board_id for b in self._boards)
# ------------------------------------------------------------------
# Single-board convenience API (v1.14.0 redesign)
# ------------------------------------------------------------------
def get_single_board(self) -> Optional[BbsBoard]:
"""Return the one configured board, or ``None`` if none exists.
This is the primary accessor for the simplified single-board UI.
Returns:
The first ``BbsBoard`` in the store, or ``None``.
"""
with self._lock:
if self._boards:
return BbsBoard.from_dict(self._boards[0].to_dict())
return None
def set_single_board(
self,
channel_idx: int,
channel_name: str,
categories: List[str],
retention_hours: int = DEFAULT_RETENTION_HOURS,
regions: Optional[List[str]] = None,
allowed_keys: Optional[List[str]] = None,
) -> None:
"""Replace the single board with a fresh config derived from one channel.
The board id is always ``ch{channel_idx}`` and the board name is
taken from *channel_name*. Any previously stored boards are
discarded so the store always holds at most one board.
Args:
channel_idx: MeshCore channel index to assign to this board.
channel_name: Human-readable name of the channel (display only).
categories: Category tag list.
retention_hours: Message retention period in hours.
regions: Optional region tags (``None`` empty list).
allowed_keys: Sender public key whitelist (``None`` all allowed).
"""
board = BbsBoard(
id=f"ch{channel_idx}",
name=channel_name,
channels=[channel_idx],
categories=list(categories),
regions=list(regions) if regions else [],
retention_hours=retention_hours,
allowed_keys=list(allowed_keys) if allowed_keys else [],
)
with self._lock:
self._boards = [board]
self._save_unlocked()
debug_print(
f"BBS config: single board set → ch{channel_idx} '{channel_name}'"
)
def clear_single_board(self) -> None:
"""Remove the configured board (disable BBS on this node).
After this call :meth:`get_single_board` returns ``None`` and
the BBS command handler will not respond to any channel.
"""
with self._lock:
self._boards = []
self._save_unlocked()
debug_print("BBS config: single board cleared")
+257 -100
View File
@@ -281,11 +281,34 @@ class BbsService:
# ---------------------------------------------------------------------------
class BbsCommandHandler:
"""Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`.
"""Parses BBS commands arriving as DMs and delegates to :class:`BbsService`.
Looks up the board for the incoming channel via ``BbsConfigStore``
so that a single board spanning multiple channels handles commands
from all of them.
Entry point
~~~~~~~~~~~
All BBS commands arrive as **Direct Messages** addressed to the node's
own public key. :meth:`handle_dm` is the sole public entry point and is
called directly from
:class:`~meshcore_gui.ble.events.EventHandler.on_contact_msg`.
It is completely independent of :class:`~meshcore_gui.services.bot.MeshBot`.
Command syntax
~~~~~~~~~~~~~~
Both styles are accepted:
Short syntax::
!p [region] <abbrev> <text> post a message
!r [region] [abbrev] read (5 most recent)
Full syntax::
!bbs post [region] <category> <text>
!bbs read [region] [category]
!bbs help
Category abbreviations are computed automatically as the shortest unique
prefix per category within the configured list. ``!r`` and ``!bbs help``
always include the abbreviation table in the reply.
Args:
service: Shared ``BbsService`` instance.
@@ -299,99 +322,179 @@ class BbsCommandHandler:
self._config_store = config_store
# ------------------------------------------------------------------
# Public entry point
# Public entry point — called from EventHandler.on_contact_msg
# ------------------------------------------------------------------
def handle(
def handle_dm(
self,
channel_idx: int,
sender: str,
sender_key: str,
text: str,
) -> Optional[str]:
"""Parse an incoming message and return a reply string (or ``None``).
"""Parse a DM addressed to this node and return a reply (or ``None``).
This is the **only** entry point for BBS commands. It is called
directly by ``EventHandler.on_contact_msg`` when a DM arrives whose
text starts with ``!``. The bot is never involved.
The board is looked up from the single configured board via
``BbsConfigStore.get_single_board()``.
Args:
channel_idx: MeshCore channel index the message arrived on.
sender: Display name of the sender.
sender_key: Public key of the sender (hex string).
text: Raw message text.
sender: Display name of the DM sender.
sender_key: Public key of the sender (hex string).
text: Raw DM text.
Returns:
Reply string, or ``None`` if no reply should be sent.
Reply string to send back as DM, or ``None`` for silent drop.
"""
text = (text or "").strip()
if not text.lower().startswith("!bbs"):
first = text.split()[0].lower() if text else ""
if not first.startswith("!"):
return None
board = self._config_store.get_board_for_channel(channel_idx)
board = self._config_store.get_single_board()
if board is None:
debug_print("BBS: no board configured, ignoring DM")
return None
# Whitelist check
if board.allowed_keys and sender_key not in board.allowed_keys:
debug_print(
f"BBS: silently dropping msg from {sender} "
f"BBS: silently dropping DM from {sender} "
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(board, channel_idx, sender, sender_key, args)
# Channel for storing posted messages
channel_idx = board.channels[0] if board.channels else 0
# Route by command prefix
if first in ("!p",):
rest = text[len(first):].strip()
return self._handle_post_short(board, channel_idx, sender, sender_key, rest)
if first in ("!r",):
rest = text[len(first):].strip()
return self._handle_read_short(board, rest)
if first == "!bbs":
parts = text.split(None, 2)
sub = parts[1].lower() if len(parts) > 1 else ""
rest = parts[2] if len(parts) > 2 else ""
if sub == "post":
return self._handle_post(board, channel_idx, sender, sender_key, rest)
if sub == "read":
return self._handle_read(board, rest)
if sub == "help" or not sub:
return self._handle_help(board)
return f"Unknown subcommand '{sub}'. " + self._handle_help(board)
# Unknown !-command starting with something else
return None
# ------------------------------------------------------------------
# Dispatch
# Abbreviation helpers
# ------------------------------------------------------------------
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(board, channel_idx, sender, sender_key, rest)
if sub == "read":
return self._handle_read(board, rest)
if sub == "help" or not sub:
return self._handle_help(board)
return f"Unknown command '{sub}'. {self._handle_help(board)}"
@staticmethod
def compute_abbreviations(categories: List[str]) -> Dict[str, str]:
"""Compute shortest unique prefix for each category.
Returns a dict mapping ``abbrev.upper()`` ``category``.
Examples::
["URGENT", "MEDICAL", "LOGISTICS", "STATUS", "GENERAL"]
{"U": "URGENT", "M": "MEDICAL", "L": "LOGISTICS",
"S": "STATUS", "G": "GENERAL"}
["MEDICAL", "MISSING"]
{"ME": "MEDICAL", "MI": "MISSING"}
"""
abbrevs: Dict[str, str] = {}
cats_upper = [c.upper() for c in categories]
for cat in cats_upper:
for length in range(1, len(cat) + 1):
prefix = cat[:length]
# Unique if no other category starts with this prefix
if sum(1 for c in cats_upper if c.startswith(prefix)) == 1:
abbrevs[prefix] = cat
break
return abbrevs
def _abbrev_table(self, categories: List[str]) -> str:
"""Return a compact abbreviation table string, e.g. ``U=URGENT M=MEDICAL``."""
abbrevs = self.compute_abbreviations(categories)
# abbrevs maps prefix → full name; invert for display
inv = {v: k for k, v in abbrevs.items()}
return " ".join(f"{inv[c]}={c}" for c in [cu.upper() for cu in categories] if cu.upper() in inv)
def _resolve_category(self, token: str, categories: List[str]) -> Optional[str]:
"""Resolve *token* to a category via exact match or abbreviation.
Returns the matching category string (original case from board
config), or ``None`` if unresolvable.
"""
token_up = token.upper()
cats_upper = [c.upper() for c in categories]
# Exact match first
if token_up in cats_upper:
return categories[cats_upper.index(token_up)]
# Abbreviation match
abbrevs = self.compute_abbreviations(categories)
if token_up in abbrevs:
matched = abbrevs[token_up]
return categories[cats_upper.index(matched)]
return None
def _resolve_region(self, token: str, regions: List[str]) -> Optional[str]:
"""Resolve *token* to a region via exact (case-insensitive) match."""
token_up = token.upper()
regs_upper = [r.upper() for r in regions]
if token_up in regs_upper:
return regions[regs_upper.index(token_up)]
return None
# ------------------------------------------------------------------
# post
# Short syntax — !p and !r
# ------------------------------------------------------------------
def _handle_post(self, board, channel_idx, sender, sender_key, args):
def _handle_post_short(self, board, channel_idx, sender, sender_key, args):
"""Handle ``!p [region] <abbrev> <text>``."""
regions = board.regions
categories = board.categories
tokens = args.split(None, 2) if args else []
if regions:
if len(tokens) < 3:
return (
f"Usage: !bbs post [region] [category] [text] | "
f"Regions: {', '.join(regions)} | "
f"Categories: {', '.join(categories)}"
)
region, category, text = tokens[0], tokens[1], tokens[2]
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_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_c.index(category.upper())]
else:
if len(tokens) < 2:
return (
f"Usage: !bbs post [category] [text] | "
f"Categories: {', '.join(categories)}"
)
region = ""
category, text = tokens[0], tokens[1]
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_c.index(category.upper())]
region = ""
if regions and tokens:
resolved_r = self._resolve_region(tokens[0], regions)
if resolved_r:
region = resolved_r
tokens = tokens[1:] # consume region token
# Now tokens should be [abbrev, text]
if len(tokens) < 2:
abbr = self._abbrev_table(categories)
return (
f"Usage: !p [region] <cat> <text> | {abbr}"
)
cat_token, text = tokens[0], tokens[1] if len(tokens) >= 2 else ""
# Rebuild text in case split(None,2) on a shorter string
if len(args.split(None, 2 if not region else 3)) > (2 if not region else 3):
# re-split with region consumed
pass
category = self._resolve_category(cat_token, categories)
if category is None:
abbr = self._abbrev_table(categories)
return (
f"Unknown category '{cat_token}'. Valid: {abbr}"
)
msg = BbsMessage(
channel=channel_idx,
@@ -402,44 +505,107 @@ class BbsCommandHandler:
region_label = f" [{region}]" if region else ""
return f"Posted [{category}]{region_label}: {text[:60]}"
# ------------------------------------------------------------------
# read
# ------------------------------------------------------------------
def _handle_read_short(self, board, args):
"""Handle ``!r [region] [abbrev]``.
def _handle_read(self, board, args):
With no arguments returns 5 most recent messages across all
categories and always includes the abbreviation table.
"""
regions = board.regions
categories = board.categories
tokens = args.split() if args else []
region = None
category = None
if regions:
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:
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_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)}"
if tokens and regions:
resolved_r = self._resolve_region(tokens[0], regions)
if resolved_r:
region = resolved_r
tokens = tokens[1:]
if tokens:
category = self._resolve_category(tokens[0], categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{tokens[0]}'. Valid: {abbr}"
return self._format_messages(board, region, category, include_abbrevs=not args)
# ------------------------------------------------------------------
# Full syntax — !bbs post / read
# ------------------------------------------------------------------
def _handle_post(self, board, channel_idx, sender, sender_key, args):
"""Handle ``!bbs post [region] <category> <text>``."""
regions = board.regions
categories = board.categories
tokens = args.split(None, 2) if args else []
region = ""
if regions and tokens:
resolved_r = self._resolve_region(tokens[0], regions)
if resolved_r:
region = resolved_r
tokens = tokens[1:]
if len(tokens) < 2:
abbr = self._abbrev_table(categories)
region_hint = f" [region]" if regions else ""
return f"Usage: !bbs post{region_hint} <cat> <text> | {abbr}"
cat_token, text = tokens[0], tokens[1]
category = self._resolve_category(cat_token, categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{cat_token}'. Valid: {abbr}"
msg = BbsMessage(
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]}"
def _handle_read(self, board, args):
"""Handle ``!bbs read [region] [category]``."""
regions = board.regions
categories = board.categories
tokens = args.split() if args else []
region = None
category = None
if tokens and regions:
resolved_r = self._resolve_region(tokens[0], regions)
if resolved_r:
region = resolved_r
tokens = tokens[1:]
if tokens:
category = self._resolve_category(tokens[0], categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{tokens[0]}'. Valid: {abbr}"
return self._format_messages(board, region, category, include_abbrevs=False)
# ------------------------------------------------------------------
# Shared message formatter
# ------------------------------------------------------------------
def _format_messages(self, board, region, category, include_abbrevs: bool) -> str:
messages = self._service.get_messages(
board.channels, region=region, category=category, limit=self.READ_LIMIT,
)
if not messages:
return "BBS: no messages found."
lines = []
if include_abbrevs:
lines.append(self._handle_help(board))
if not messages:
lines.append("BBS: no messages found.")
return "\n".join(lines)
for m in messages:
ts = m.timestamp[:16].replace("T", " ")
region_label = f"[{m.region}] " if m.region else ""
@@ -447,22 +613,13 @@ class BbsCommandHandler:
return "\n".join(lines)
# ------------------------------------------------------------------
# help
# Help
# ------------------------------------------------------------------
def _handle_help(self, board) -> str:
cats = ", ".join(board.categories)
abbr = self._abbrev_table(board.categories)
header = f"BBS [{board.name}] | !p [cat] [text] | !r [cat]"
if board.regions:
regs = ", ".join(board.regions)
return (
f"BBS [{board.name}] | "
f"!bbs post [region] [cat] [text] | "
f"!bbs read [region] [cat] | "
f"Regions: {regs} | Categories: {cats}"
)
return (
f"BBS [{board.name}] | "
f"!bbs post [cat] [text] | "
f"!bbs read [cat] | "
f"Categories: {cats}"
)
return f"{header} | Regions: {regs} | {abbr}"
return f"{header} | {abbr}"
+14 -34
View File
@@ -11,20 +11,19 @@ New keywords are added via ``BotConfig.keywords`` (data) without
modifying the ``MeshBot`` class (code). Custom matching strategies
can be implemented by subclassing and overriding ``_match_keyword``.
BBS integration
~~~~~~~~~~~~~~~
``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a
:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one
is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is
``None`` (default), BBS routing is simply skipped.
BBS separation
~~~~~~~~~~~~~~
BBS commands (``!bbs``, ``!p``, ``!r``) are handled by
:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` which is
wired directly into
:class:`~meshcore_gui.ble.events.EventHandler`. They never pass
through ``MeshBot`` the bot is a pure keyword/channel-message
responder only.
"""
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
if TYPE_CHECKING:
from meshcore_gui.services.bbs_service import BbsCommandHandler
from typing import Callable, Dict, List, Optional
from meshcore_gui.config import debug_print
@@ -91,13 +90,11 @@ class MeshBot:
config: BotConfig,
command_sink: Callable[[Dict], None],
enabled_check: Callable[[], bool],
bbs_handler: Optional["BbsCommandHandler"] = None,
) -> None:
self._config = config
self._sink = command_sink
self._enabled = enabled_check
self._last_reply: float = 0.0
self._bbs_handler = bbs_handler
def check_and_reply(
self,
@@ -108,7 +105,7 @@ class MeshBot:
path_len: int,
path_hashes: Optional[List[str]] = None,
) -> None:
"""Evaluate an incoming message and queue a reply if appropriate.
"""Evaluate an incoming channel message and queue a reply if appropriate.
Guards (in order):
1. Bot is enabled (checkbox in GUI).
@@ -117,6 +114,10 @@ class MeshBot:
4. Sender name does not end with ``'Bot'`` (prevent loops).
5. Cooldown period has elapsed.
6. Message text contains a recognised keyword.
Note: BBS commands (``!bbs``, ``!p``, ``!r``) are NOT handled here.
They arrive as DMs and are handled by ``BbsCommandHandler`` directly
inside ``EventHandler.on_contact_msg``.
"""
# Guard 1: enabled?
if not self._enabled():
@@ -141,27 +142,6 @@ class MeshBot:
debug_print("BOT: cooldown active, skipping")
return
# BBS routing: delegate !bbs commands to BbsCommandHandler
if self._bbs_handler is not None:
text_stripped = (text or "").strip()
if text_stripped.lower().startswith("!bbs"):
bbs_reply = self._bbs_handler.handle(
channel_idx=channel_idx,
sender=sender,
sender_key="", # sender_key not available at this call-site
text=text_stripped,
)
if bbs_reply is not None:
self._last_reply = now
self._sink({
"action": "send_message",
"channel": channel_idx,
"text": bbs_reply,
"_bot": True,
})
debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}")
return # Do not fall through to keyword matching
# Guard 6: keyword match
template = self._match_keyword(text)
if template is None: