From d9ad4c83b8b1a7a2cfa7bbbf7813e9f29ee4ba27 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 20:01:07 +0100 Subject: [PATCH] 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 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 --- CHANGELOG.md | 12 +-- README.md | 87 +++++++++++----------- meshcore_gui/ble/events.py | 17 +++++ meshcore_gui/gui/panels/bbs_panel.py | 86 +++++++++++---------- meshcore_gui/services/bbs_config_store.py | 91 ++++++++++++++++------- meshcore_gui/services/bbs_service.py | 71 +++++++++++++++++- 6 files changed, 247 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13142ee..68b92a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` (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 ` (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 diff --git a/README.md b/README.md index 2361b3d..1b056d0 100644 --- a/README.md +++ b/README.md @@ -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 ` | Post a message | -| `!p ` | Post with region | -| `!r` | Read 5 most recent (all categories) | -| `!r ` | Read filtered by category | -| `!r ` | Read filtered by region and category | +| `!p ` | Bericht posten | +| `!p ` | Posten met regio | +| `!r` | Laatste 5 berichten lezen (alle categorieΓ«n) | +| `!r ` | Lezen gefilterd op categorie | +| `!r ` | 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 ` | Post a message | -| `!bbs post ` | Post with region | -| `!bbs read` | Read 5 most recent | -| `!bbs read ` | Read filtered by category | -| `!bbs read ` | Read filtered by region and category | +| `!bbs help` | Toon commando's en afkortingstabel | +| `!bbs post ` | Bericht posten | +| `!bbs post ` | Posten met regio | +| `!bbs read` | Laatste 5 berichten | +| `!bbs read ` | Gefilterd op categorie | +| `!bbs read ` | 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 ``` --- diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 35da67e..361836c 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -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, diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 2e7ee6b..5abb6b9 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -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') diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index c8c925d..2eb5a35 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -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 diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 0432eef..e73ff18 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -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(