feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax(#v1.14.0)

Adds an offline BBS accessible via Direct Message to the node's own key.
Access is channel-based: anyone seen on a configured BBS channel is
automatically whitelisted for DM access. Channels stay clean.

- Multi-channel configuration: any combination of device channels can be
  selected; senders on any of them are auto-whitelisted
- Short syntax: !p <cat> <text> and !r [cat] alongside full !bbs syntax
- Category abbreviations computed automatically (shortest unique prefix)
- handle_channel_msg: bootstrap reply on channel + auto-whitelist sender
- handle_dm: DM entry point, checks whitelist, routes to post/read/help
- 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 20:01:07 +01:00
parent 374897448e
commit d9ad4c83b8
6 changed files with 247 additions and 117 deletions

View File

@@ -35,18 +35,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
### Added
- 🆕 **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.
- **Toegangsmodel:** de beheerder selecteert één of meer channels in de settings. Iedereen die op een van die channels een bericht stuurt, wordt automatisch gewhitelist en kan daarna commando's sturen via **Direct Message** aan de node. Het channel blijft schoon; alleen de eerste interactie verloopt via het channel.
- Korte syntax: `!p <cat> <tekst>` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`).
- 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.
- Optioneel regio-filter en handmatige allowed-keys override in Advanced.
- Settings-pagina (`/bbs-settings`): checkboxes per channel, categorieën, retentie, Advanced voor regio's en handmatige keys.
- Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode).
### Changed
- 🔄 **`ble/events.py`** — DMs die beginnen met `!` worden direct verwerkt door `BbsCommandHandler`, volledig los van `MeshBot`.
- 🔄 **`ble/events.py`** — `on_channel_msg` roept `BbsCommandHandler.handle_channel_msg()` aan op geconfigureerde BBS-channels: auto-whitelist + bootstrap reply. `on_contact_msg` stuurt `!`-DMs direct naar `handle_dm()`. Beide paden 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.
- 🔄 **`services/bbs_config_store.py`** — `configure_board()` (multi-channel), `add_allowed_key()` (auto-whitelist), `clear_board()`.
- 🔄 **`gui/dashboard.py`** — `BbsPanel` geregistreerd, `📋 BBS` drawer-item toegevoegd.
### Storage

View File

@@ -1188,91 +1188,94 @@ meshcore-gui/
MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging.
### Design
### Toegangsmodel
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.
De beheerder koppelt één of meer channels aan het BBS. Iedereen die op zo'n channel een bericht stuurt wordt automatisch gewhitelist. Daarna kunnen zij commando's sturen via **Direct Message** aan de BBS-node — het channel zelf blijft schoon.
```
User ──DM──▶ BBS node (public key)
processes command
User ◀──DM── reply (only visible to sender)
Eerste contact: !bbs help op het channel
→ node ziet de public key → whitelist
Daarna: !p U hulp nodig als DM naar de node
→ verwerkt, reply via DM terug
```
Channel commands remain available as a fallback, but DM is the primary interface.
Wie nooit iets heeft gestuurd op een geconfigureerd channel staat niet op de whitelist en wordt silently genegeerd.
### Settings
Open the BBS settings via the gear icon (⚙) in the BBS panel, or navigate to `/bbs-settings`.
Open via het tandwiel (⚙) in het BBS-panel, of navigeer naar `/bbs-settings`.
```
BBS Settings
─────────────────────────────────────────────
Channel: [2] NoodNet Zwolle
──────────────────────────────────────────
Channels: [1] NoodNet Zwolle
☑ [2] NoodNet Dalfsen
☐ [3] NoodNet OV
Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL
Retain: 48 hours
[Save]
▶ Advanced
Regions (comma-separated)
Allowed keys (empty = everyone on the channel)
Allowed keys (leeg = auto-geleerd via channel-activiteit)
```
- **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.
- **Channels** — vink alle channels aan waarvan deelnemers toegang krijgen tot het BBS.
- **Categories** — komma-gescheiden lijst van geldige categorie-tags.
- **Retain** — berichtretentie in uren (standaard 48).
- **Advanced → Regions** — optionele regio-tags voor geografische filtering.
- **Advanced → Allowed keys** — handmatige whitelist-override; leeg laten om alleen automatisch te leren.
### Command syntax
### Commando-syntax
#### Short syntax
#### Korte syntax
| Command | Description |
| Commando | Beschrijving |
|---|---|
| `!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 |
| `!p <cat> <tekst>` | Bericht posten |
| `!p <regio> <cat> <tekst>` | Posten met regio |
| `!r` | Laatste 5 berichten lezen (alle categorieën) |
| `!r <cat>` | Lezen gefilterd op categorie |
| `!r <regio> <cat>` | Lezen gefilterd op regio en categorie |
Category abbreviations are computed automatically as the shortest unique prefix within the configured category list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`:
Categorie-afkortingen worden automatisch berekend als de kortste unieke prefix. Voorbeeld met `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.
`!r` zonder argumenten en `!bbs help` geven altijd de afkortingstabel mee.
#### Full syntax
#### Volledige syntax
| Command | Description |
| Commando | Beschrijving |
|---|---|
| `!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 |
| `!bbs help` | Toon commando's en afkortingstabel |
| `!bbs post <category> <tekst>` | Bericht posten |
| `!bbs post <regio> <category> <tekst>` | Posten met regio |
| `!bbs read` | Laatste 5 berichten |
| `!bbs read <category>` | Gefilterd op categorie |
| `!bbs read <regio> <category>` | Gefilterd op regio en categorie |
#### Example help reply
#### Voorbeeld help-reply
```
BBS [NoodNet Zwolle] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
```
### Error handling
### Foutafhandeling
| Situation | Reply |
| Situatie | Reply |
|---|---|
| Unknown category | Lists valid categories and abbreviations |
| Ambiguous abbreviation | Lists all matching categories |
| Sender not on whitelist | Silent drop — no reply |
| Onbekende categorie | Lijst met geldige categorieën en afkortingen |
| Ambigue afkorting | Lijst met overeenkomende categorieën |
| Sender niet op whitelist | Silent drop — geen reply |
### Storage
```
~/.meshcore-gui/bbs/bbs_messages.db — SQLite message store (WAL mode)
~/.meshcore-gui/bbs/bbs_config.json — Board configuration (v2 format)
~/.meshcore-gui/bbs/bbs_messages.db — SQLite berichtenopslag (WAL-mode)
~/.meshcore-gui/bbs/bbs_config.json — Board-configuratie
```
---

View File

@@ -301,6 +301,23 @@ class EventHandler:
message_hash=msg_hash,
))
# BBS channel hook: auto-whitelist sender + bootstrap reply for !-commands.
# Runs on every message on a configured BBS channel, independent of the bot.
if self._bbs_handler is not None and self._command_sink is not None:
bbs_reply = self._bbs_handler.handle_channel_msg(
channel_idx=ch_idx,
sender=sender,
sender_key=sender_pubkey,
text=msg_text,
)
if bbs_reply is not None:
debug_print(f"BBS channel reply on ch{ch_idx} to {sender!r}: {bbs_reply[:60]}")
self._command_sink({
"action": "send_message",
"channel": ch_idx,
"text": bbs_reply,
})
self._bot.check_and_reply(
sender=sender,
text=msg_text,

View File

@@ -427,20 +427,9 @@ class BbsSettingsPage:
# ------------------------------------------------------------------
def _render_settings(self) -> None:
"""Render the single-board settings block."""
"""Render the board settings block."""
board = self._config_store.get_single_board()
# 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
}
current_idx = (
board.channels[0] if board and board.channels
else next(iter(ch_options), 0)
)
active_channels = set(board.channels) if board else set()
cats_value = (
', '.join(board.categories) if board
else ', '.join(DEFAULT_CATEGORIES)
@@ -452,25 +441,31 @@ class BbsSettingsPage:
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')
# ── Channel checkboxes ───────────────────────────────────────
ch_checks: Dict[int, object] = {}
with ui.column().classes('w-full gap-1'):
ui.label('Channels:').classes('text-xs text-gray-600')
with ui.column().classes('w-full gap-1 pl-2'):
for ch in self._device_channels:
idx = ch.get('idx', ch.get('index', 0))
name = ch.get('name', f'Ch {idx}')
cb = ui.checkbox(
f'[{idx}] {name}',
value=idx in active_channels,
).classes('text-xs')
ch_checks[idx] = cb
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')
# ── Categories + retention ───────────────────────────────────
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'):
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')
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'):
@@ -482,20 +477,27 @@ class BbsSettingsPage:
).classes('w-full text-xs')
keys_input = ui.input(
label='Allowed keys (empty = everyone on the channel)',
label='Allowed keys (empty = auto-learned from channel activity)',
value=adv_keys_value,
).classes('w-full text-xs')
# ── Save ─────────────────────────────────────────────────────
def _save(
cs=ch_select,
cc=ch_checks,
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}')
selected = [idx for idx, cb in cc.items() if cb.value]
if not selected:
ui.notify('Select at least one channel.', type='warning')
return
ch_names = {
ch.get('idx', ch.get('index', 0)): ch.get('name', '?')
for ch in self._device_channels
}
categories = [
c.strip().upper()
for c in (ci.value or '').split(',') if c.strip()
@@ -505,18 +507,22 @@ class BbsSettingsPage:
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()]
# Only pass allowed_keys if the field was explicitly filled;
# empty field means "keep auto-learned keys"
raw_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()]
allowed_keys = raw_keys if raw_keys else None
self._config_store.set_single_board(
channel_idx=idx,
channel_name=ch_name,
self._config_store.configure_board(
channel_indices=selected,
channel_names=ch_names,
categories=categories,
retention_hours=ret_hours,
regions=regions,
allowed_keys=allowed_keys,
)
debug_print(f'BBS settings: saved ch{idx} {ch_name}')
ui.notify(f'BBS saved — {ch_name}.', type='positive')
ch_labels = ', '.join(f"[{i}] {ch_names.get(i, '?')}" for i in sorted(selected))
debug_print(f'BBS settings: configured channels {ch_labels}')
ui.notify(f'BBS saved — {ch_labels}.', type='positive')
self._rebuild()
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2')

View File

@@ -9,7 +9,7 @@ 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`.
cleared through :meth:`configure_board` / :meth:`clear_board`.
Multiple-board storage is retained internally so that the storage layer
(``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged.
@@ -305,13 +305,11 @@ class BbsConfigStore:
return any(b.id == board_id for b in self._boards)
# ------------------------------------------------------------------
# Single-board convenience API (v1.14.0 redesign)
# Board 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.
"""Return the configured board, or ``None`` if none exists.
Returns:
The first ``BbsBoard`` in the store, or ``None``.
@@ -321,52 +319,89 @@ class BbsConfigStore:
return BbsBoard.from_dict(self._boards[0].to_dict())
return None
def set_single_board(
def configure_board(
self,
channel_idx: int,
channel_name: str,
channel_indices: List[int],
channel_names: Dict[int, 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.
"""Save the board configuration.
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.
Multiple channels can be assigned. Every sender seen on any of
these channels is automatically eligible for DM access (the
worker calls :meth:`add_allowed_key` when it sees them).
The board id is always ``'bbs_board'``. The board name is built
from the channel names in *channel_names*.
Args:
channel_idx: MeshCore channel index to assign to this board.
channel_name: Human-readable name of the channel (display only).
channel_indices: MeshCore channel indices to assign.
channel_names: Mapping ``idx → display name`` for labelling.
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).
regions: Optional region tags.
allowed_keys: Manual sender key whitelist seed (auto-learned
keys are added via :meth:`add_allowed_key`).
"""
name = ", ".join(
channel_names.get(i, f"Ch {i}") for i in sorted(channel_indices)
) or "BBS"
# Preserve existing auto-learned keys unless caller supplies a new list
existing = self.get_single_board()
merged_keys = list(allowed_keys) if allowed_keys is not None else (
existing.allowed_keys if existing else []
)
board = BbsBoard(
id=f"ch{channel_idx}",
name=channel_name,
channels=[channel_idx],
id="bbs_board",
name=name,
channels=sorted(channel_indices),
categories=list(categories),
regions=list(regions) if regions else [],
retention_hours=retention_hours,
allowed_keys=list(allowed_keys) if allowed_keys else [],
allowed_keys=merged_keys,
)
with self._lock:
self._boards = [board]
self._save_unlocked()
debug_print(
f"BBS config: single board set → ch{channel_idx} '{channel_name}'"
f"BBS config: board configured → channels={sorted(channel_indices)} "
f"name='{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.
"""
def clear_board(self) -> None:
"""Remove the configured board (disable BBS on this node)."""
with self._lock:
self._boards = []
self._save_unlocked()
debug_print("BBS config: single board cleared")
debug_print("BBS config: board cleared")
def add_allowed_key(self, sender_key: str) -> bool:
"""Add *sender_key* to the board's allowed_keys whitelist.
Called automatically by the worker whenever a sender is seen on
a configured BBS channel. No-op if the key is already present
or if no board is configured.
Args:
sender_key: Public key hex string of the sender.
Returns:
``True`` if the key was newly added, ``False`` otherwise.
"""
if not sender_key:
return False
with self._lock:
if not self._boards:
return False
board = self._boards[0]
if sender_key in board.allowed_keys:
return False
board.allowed_keys.append(sender_key)
self._save_unlocked()
debug_print(f"BBS config: auto-whitelisted key {sender_key[:12]}")
return True

View File

@@ -322,7 +322,76 @@ class BbsCommandHandler:
self._config_store = config_store
# ------------------------------------------------------------------
# Public entry point — called from EventHandler.on_contact_msg
# Public entry points
# ------------------------------------------------------------------
def handle_channel_msg(
self,
channel_idx: int,
sender: str,
sender_key: str,
text: str,
) -> Optional[str]:
"""Handle a channel message on a configured BBS channel.
Called from ``EventHandler.on_channel_msg`` **after** the message
has been stored. Two responsibilities:
1. **Auto-whitelist**: every sender seen on a BBS channel gets their
key added to ``allowed_keys`` so they can use DMs afterwards.
2. **Bootstrap reply**: if the message starts with ``!``, reply on
the channel so the sender knows the BBS is active and receives
the abbreviation table.
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.
Returns:
Reply string to post on the channel, or ``None``.
"""
board = self._config_store.get_single_board()
if board is None:
return None
if channel_idx not in board.channels:
return None
# Auto-whitelist: register this sender so they can use DMs
if sender_key:
self._config_store.add_allowed_key(sender_key)
# Bootstrap reply only for !-commands
text = (text or "").strip()
if not text.startswith("!"):
return None
first = text.split()[0].lower()
channel_for_post = channel_idx
if first == "!p":
rest = text[len(first):].strip()
return self._handle_post_short(board, channel_for_post, sender, sender_key, rest)
if first == "!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_for_post, 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)
return None
# ------------------------------------------------------------------
def handle_dm(