diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df97ad..13142ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` (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 diff --git a/README.md b/README.md index 798de13..2361b3d 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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 | + +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 ` | 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 | + +#### 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 diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 5700d40..35da67e 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -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 diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index 484ff0a..4b45090 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -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, diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 692acf1..2e7ee6b 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -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() diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index c05727b..c8c925d 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -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") diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 52b7968..0432eef 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -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] β€” post a message + !r [region] [abbrev] β€” read (5 most recent) + + Full syntax:: + + !bbs post [region] + !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] ``.""" 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] | {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] ``.""" + 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} | {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}" diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py index 9279a72..d31a2c2 100644 --- a/meshcore_gui/services/bot.py +++ b/meshcore_gui/services/bot.py @@ -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: