diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d542cb..6df97ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,29 +33,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) ### Added -- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsConfigStore`: beheert `~/.meshcore-gui/bbs/bbs_config.json`. Thread-safe, atomische schrijfoperaties. Wordt aangemaakt bij eerste start. Methoden: `get_channels()`, `get_enabled_channels()`, `get_channel()`, `set_channel()`, `enable_channel()`, `disable_channel()`, `update_channel_field()`. -- πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. - - `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp. - - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen (bijv. 800 MHz + 433 MHz gelijktijdig). Database op `~/.meshcore-gui/bbs/bbs_messages.db`. - - `BbsCommandHandler`: parst `!bbs post`, `!bbs read`, `!bbs help` mesh commando's. Leest channel-config live uit `BbsConfigStore`. Whitelist-controle (stille drop bij onbekende sender key). Per-channel regio/categorie-validatie met foutmelding. +- πŸ†• **`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. - - Channel-selector (alleen enabled BBS channels). - - Regio-filter (alleen zichtbaar als channel regio's heeft). - - Categorie-filter. - - Scrollbare berichtenlijst met timestamp, afzender, categorie en optioneel regio-label. - - Post-formulier: regio-select (conditioneel), categorie-select, tekstinvoer, Send-knop. - - Send verstuurt ook `!bbs post …` op het mesh-kanaal zodat andere nodes het ontvangen. - - **Settings-sectie**: per device channel een inklapbaar blok met enable-toggle, categorie-invoer, regio-invoer, retentie-invoer en whitelist-invoer. Opslaan via `BbsConfigStore`; channel-selector wordt direct bijgewerkt. + - 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. ### Changed -- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepteert optionele `bbs_handler` parameter. Inkomende `!bbs` berichten worden doorgesluisd naar `BbsCommandHandler` vΓ³Γ³r keyword-matching; antwoorden worden teruggestuurd op hetzelfde kanaal. -- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd (config leeft nu in `bbs_config.json`). Versie naar `1.14.0`. -- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties toegevoegd; `BbsPanel` geregistreerd als standalone panel `'bbs'`; menu-item `πŸ“‹ BBS` toegevoegd aan de drawer. +- πŸ”„ **`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. +### Storage +``` +~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2) +~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag +``` + ### Not changed -- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services. -- Bestaande bot keyword-logica, room server flow, archive page, contacts, map, device, actions, rxlog panels. +- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels. --- diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index c642ad8..0282a59 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -1,11 +1,13 @@ -"""BBS panel -- offline Bulletin Board System viewer, post form and settings.""" +"""BBS panel -- board-based Bulletin Board System viewer and configuration.""" +import re from typing import Callable, Dict, List, Optional from nicegui import ui from meshcore_gui.config import debug_print from meshcore_gui.services.bbs_config_store import ( + BbsBoard, BbsConfigStore, DEFAULT_CATEGORIES, DEFAULT_RETENTION_HOURS, @@ -13,16 +15,24 @@ from meshcore_gui.services.bbs_config_store import ( from meshcore_gui.services.bbs_service import BbsMessage, BbsService +def _slug(name: str) -> str: + """Convert a board name to a safe id slug. + + Args: + name: Human-readable board name. + + Returns: + Lowercase alphanumeric + underscore string. + """ + return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board" + + class BbsPanel: - """BBS panel: channel selector, filters, message list, post form and settings. + """BBS panel: board selector, filters, message list, post form and settings. - The settings section lists all active device channels (from SharedData) - and lets the user enable/disable BBS per channel and configure - categories, regions and retention. Configuration is persisted via - BbsConfigStore to ~/.meshcore-gui/bbs/bbs_config.json. - - All data access goes through BbsService and BbsConfigStore. - No direct SQLite access in this class (SOLID: SRP / DIP). + The settings section lets users create, configure and delete boards. + Each board can span one or more device channels (from SharedData). + Configuration is persisted via BbsConfigStore. Args: put_command: Callable to enqueue a command dict for the worker. @@ -41,12 +51,12 @@ class BbsPanel: self._config_store = config_store # Active view state - self._active_channel_idx: Optional[int] = None + self._active_board: Optional[BbsBoard] = None self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI element references -- message view - self._msg_list_container = None + # UI refs -- message view + self._board_btn_row = None self._region_row = None self._region_select = None self._category_select = None @@ -54,11 +64,16 @@ class BbsPanel: self._post_region_row = None self._post_region_select = None self._post_category_select = None - self._channel_btn_row = None + self._msg_list_container = None - # UI element references -- settings - self._settings_container = None - self._last_device_channels: List[Dict] = [] + # UI refs -- settings + self._boards_settings_container = None + self._new_board_name_input = None + self._new_board_channel_checks: Dict[int, object] = {} + + # Cached device channels (updated by update()) + self._device_channels: List[Dict] = [] + self._last_ch_fingerprint: tuple = () # ------------------------------------------------------------------ # Render @@ -66,18 +81,16 @@ class BbsPanel: def render(self) -> None: """Build the complete BBS panel layout.""" + # ---- Message view card -------------------------------------- with ui.card().classes('w-full'): ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') - # ---- Channel selector ----------------------------------- - self._channel_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') - with self._channel_btn_row: - ui.label('Channel:').classes('text-sm text-gray-600') - # Populated by _rebuild_channel_buttons() + self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') ui.separator() - # ---- Filter row ---------------------------------------- with ui.row().classes('w-full items-center gap-4 flex-wrap'): ui.label('Filter:').classes('text-sm text-gray-600') @@ -85,33 +98,29 @@ class BbsPanel: with self._region_row: ui.label('Region:').classes('text-xs text-gray-600') self._region_select = ui.select( - options=[], - value=None, + options=[], value=None, on_change=lambda e: self._on_region_filter(e.value), ).classes('text-xs').style('min-width: 120px') with ui.row().classes('items-center gap-2'): ui.label('Category:').classes('text-xs text-gray-600') self._category_select = ui.select( - options=[], - value=None, + options=[], value=None, on_change=lambda e: self._on_category_filter(e.value), ).classes('text-xs').style('min-width: 120px') ui.button( - 'Refresh', on_click=self._refresh_messages + 'Refresh', on_click=self._refresh_messages, ).props('flat no-caps').classes('text-xs') ui.separator() - # ---- Message list -------------------------------------- self._msg_list_container = ui.column().classes( 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' ) ui.separator() - # ---- Post form ----------------------------------------- with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.label('Post:').classes('text-sm text-gray-600') @@ -131,91 +140,100 @@ class BbsPanel: ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # ---- Settings card ----------------------------------------- + # ---- Settings card ------------------------------------------ with ui.card().classes('w-full'): ui.label('BBS Settings').classes('font-bold text-gray-600') ui.label( - 'Enable BBS on a channel to allow !bbs commands and store messages.' + 'Create boards and assign device channels. ' + 'One board can cover multiple channels.' ).classes('text-xs text-gray-500') ui.separator() - self._settings_container = ui.column().classes('w-full gap-2') - with self._settings_container: - ui.label('Waiting for device channels...').classes( + + # New board form + with ui.row().classes('w-full items-center gap-2 flex-wrap'): + ui.label('New board:').classes('text-sm text-gray-600') + self._new_board_name_input = ui.input( + placeholder='Board name...', + ).classes('text-xs').style('min-width: 160px') + ui.button( + 'Create', on_click=self._on_create_board, + ).props('no-caps').classes('text-xs') + + ui.separator() + self._boards_settings_container = ui.column().classes('w-full gap-3') + with self._boards_settings_container: + ui.label('No boards configured yet.').classes( 'text-xs text-gray-400 italic' ) + # Initial render + self._rebuild_board_buttons() + self._rebuild_boards_settings() + # ------------------------------------------------------------------ - # Channel selector (message view) + # Board selector (message view) # ------------------------------------------------------------------ - def _rebuild_channel_buttons(self, enabled_channels: List[Dict]) -> None: - """Rebuild the channel selector buttons for enabled BBS channels. - - Args: - enabled_channels: List of enabled channel config dicts. - """ - if not self._channel_btn_row: + def _rebuild_board_buttons(self) -> None: + """Rebuild board selector buttons from current config.""" + if not self._board_btn_row: return - self._channel_btn_row.clear() - with self._channel_btn_row: - ui.label('Channel:').classes('text-sm text-gray-600') - if not enabled_channels: - ui.label('No BBS channels configured.').classes( + self._board_btn_row.clear() + boards = self._config_store.get_boards() + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') + if not boards: + ui.label('No boards configured.').classes( 'text-xs text-gray-400 italic' ) return - for cfg in enabled_channels: - idx = cfg['channel'] - name = cfg['name'] + for board in boards: ui.button( - name, - on_click=lambda i=idx: self._select_channel(i), + board.name, + on_click=lambda b=board: self._select_board(b), ).props('flat no-caps').classes('text-xs') - # Auto-select first channel when none is active yet - if self._active_channel_idx is None and enabled_channels: - self._select_channel(enabled_channels[0]['channel']) + # Auto-select first board if none active or active was deleted + ids = [b.id for b in boards] + if boards and (self._active_board is None or self._active_board.id not in ids): + self._select_board(boards[0]) - def _select_channel(self, channel_idx: int) -> None: - """Switch the active channel and rebuild filter options. + def _select_board(self, board: BbsBoard) -> None: + """Activate a board and rebuild filter selects. Args: - channel_idx: MeshCore channel index to activate. + board: Board to activate. """ - self._active_channel_idx = channel_idx + self._active_board = board self._active_region = None self._active_category = None - cfg = self._config_store.get_channel(channel_idx) or {} - regions: List[str] = cfg.get('regions', []) - categories: List[str] = cfg.get('categories', []) - - has_regions = bool(regions) + has_regions = bool(board.regions) if self._region_row: self._region_row.set_visibility(has_regions) if self._post_region_row: self._post_region_row.set_visibility(has_regions) - region_opts = ['(all)'] + regions + region_opts = ['(all)'] + board.regions if self._region_select: self._region_select.options = region_opts self._region_select.value = '(all)' if self._post_region_select: - self._post_region_select.options = regions - self._post_region_select.value = regions[0] if regions else None + self._post_region_select.options = board.regions + self._post_region_select.value = board.regions[0] if board.regions else None - cat_opts = ['(all)'] + categories + cat_opts = ['(all)'] + board.categories if self._category_select: self._category_select.options = cat_opts self._category_select.value = '(all)' if self._post_category_select: - self._post_category_select.options = categories - self._post_category_select.value = categories[0] if categories else None + self._post_category_select.options = board.categories + self._post_category_select.value = board.categories[0] if board.categories else None self._refresh_messages() # ------------------------------------------------------------------ - # Filter callbacks + # Filters # ------------------------------------------------------------------ def _on_region_filter(self, value: Optional[str]) -> None: @@ -227,20 +245,24 @@ class BbsPanel: self._refresh_messages() # ------------------------------------------------------------------ - # Message list refresh + # Message list # ------------------------------------------------------------------ def _refresh_messages(self) -> None: - """Query the BBS service and rebuild the message list UI.""" if not self._msg_list_container: return self._msg_list_container.clear() with self._msg_list_container: - if self._active_channel_idx is None: - ui.label('Select a channel above.').classes('text-xs text-gray-400 italic') + if self._active_board is None: + ui.label('Select a board above.').classes('text-xs text-gray-400 italic') + return + if not self._active_board.channels: + ui.label('No channels assigned to this board.').classes( + 'text-xs text-gray-400 italic' + ) return messages = self._service.get_all_messages( - channel=self._active_channel_idx, + channels=self._active_board.channels, region=self._active_region, category=self._active_category, ) @@ -251,11 +273,6 @@ class BbsPanel: self._render_message_row(msg) def _render_message_row(self, msg: BbsMessage) -> None: - """Render a single message row. - - Args: - msg: BbsMessage to display. - """ ts = msg.timestamp[:16].replace('T', ' ') region_label = f' [{msg.region}]' if msg.region else '' header = f'{ts} {msg.sender} [{msg.category}]{region_label}' @@ -268,14 +285,12 @@ class BbsPanel: # ------------------------------------------------------------------ def _on_post(self) -> None: - """Handle the Send button: validate inputs and post a BBS message.""" - if self._active_channel_idx is None: - ui.notify('Select a channel first.', type='warning') + if self._active_board is None: + ui.notify('Select a board first.', type='warning') + return + if not self._active_board.channels: + ui.notify('No channels assigned to this board.', type='warning') return - - cfg = self._config_store.get_channel(self._active_channel_idx) or {} - regions: List[str] = cfg.get('regions', []) - categories: List[str] = cfg.get('categories', []) text = (self._text_input.value or '').strip() if self._text_input else '' if not text: @@ -283,36 +298,38 @@ class BbsPanel: return category = ( - self._post_category_select.value - if self._post_category_select else (categories[0] if categories else '') + self._post_category_select.value if self._post_category_select + else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: ui.notify('Please select a category.', type='warning') return region = '' - if regions and self._post_region_select: + if self._active_board.regions and self._post_region_select: region = self._post_region_select.value or '' + # Post on first assigned channel (primary channel for outgoing) + target_channel = self._active_board.channels[0] + msg = BbsMessage( - channel=self._active_channel_idx, - region=region, - category=category, - sender='Me', - sender_key='', - text=text, + channel=target_channel, + region=region, category=category, + sender='Me', sender_key='', text=text, ) self._service.post_message(msg) - # Broadcast on the mesh channel region_part = f'{region} ' if region else '' mesh_text = f'!bbs post {region_part}{category} {text}' self._put_command({ 'action': 'send_message', - 'channel': self._active_channel_idx, + 'channel': target_channel, 'text': mesh_text, }) - debug_print(f'BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}') + debug_print( + f'BBS panel: posted to board={self._active_board.id} ' + f'ch={target_channel} {mesh_text[:60]}' + ) if self._text_input: self._text_input.value = '' @@ -320,170 +337,199 @@ class BbsPanel: ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ - # Settings panel + # Settings -- board list # ------------------------------------------------------------------ - def _rebuild_settings(self, device_channels: List[Dict]) -> None: - """Rebuild the settings rows for all device channels. - - Called from update() when the device channel list changes. - - Args: - device_channels: Channel list from SharedData snapshot. - """ - if not self._settings_container: + def _rebuild_boards_settings(self) -> None: + """Rebuild the settings section for all configured boards.""" + if not self._boards_settings_container: return - self._settings_container.clear() - with self._settings_container: - if not device_channels: - ui.label('No channels received from device yet.').classes( + self._boards_settings_container.clear() + boards = self._config_store.get_boards() + with self._boards_settings_container: + if not boards: + ui.label('No boards configured yet.').classes( 'text-xs text-gray-400 italic' ) return - for ch in device_channels: - self._render_settings_row(ch) + for board in boards: + self._render_board_settings_row(board) - def _render_settings_row(self, device_ch: Dict) -> None: - """Render one settings row for a single device channel. + def _render_board_settings_row(self, board: BbsBoard) -> None: + """Render one settings expansion for a single board. Args: - device_ch: Channel dict from SharedData (keys: idx, name). + board: Board to render. """ - ch_idx = device_ch.get('idx', device_ch.get('index', 0)) - ch_name = device_ch.get('name', f'Ch {ch_idx}') - bbs_cfg = self._config_store.get_channel(ch_idx) - is_enabled = bbs_cfg.get('enabled', False) if bbs_cfg else False - with ui.expansion( - f'[{ch_idx}] {ch_name}', - value=False, + board.name, value=False, ).classes('w-full').props('dense'): - with ui.column().classes('w-full gap-2 p-2'): - # Enable / disable toggle - enable_cb = ui.checkbox( - 'Enable BBS on this channel', - value=is_enabled, - ) - - # Categories input - cats_val = ', '.join(bbs_cfg.get('categories', DEFAULT_CATEGORIES)) if bbs_cfg else ', '.join(DEFAULT_CATEGORIES) - cats_input = ui.input( - label='Categories (comma-separated)', - value=cats_val, + # Name + name_input = ui.input( + label='Board name', value=board.name, ).classes('w-full text-xs') - # Regions input - regions_val = ', '.join(bbs_cfg.get('regions', [])) if bbs_cfg else '' + # Channel assignment + ui.label('Channels (select which device channels belong to this board):').classes( + 'text-xs text-gray-600' + ) + ch_checks: Dict[int, object] = {} + with ui.row().classes('flex-wrap gap-2'): + if not self._device_channels: + ui.label('No device channels known yet.').classes( + 'text-xs text-gray-400 italic' + ) + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + cb = ui.checkbox( + f'[{idx}] {ch_name}', + value=idx in board.channels, + ).classes('text-xs') + ch_checks[idx] = cb + + # Categories + cats_input = ui.input( + label='Categories (comma-separated)', + value=', '.join(board.categories), + ).classes('w-full text-xs') + + # Regions regions_input = ui.input( label='Regions (comma-separated, leave empty for none)', - value=regions_val, + value=', '.join(board.regions), ).classes('w-full text-xs') # Retention - ret_val = str(bbs_cfg.get('retention_hours', DEFAULT_RETENTION_HOURS)) if bbs_cfg else str(DEFAULT_RETENTION_HOURS) retention_input = ui.input( label='Retention (hours)', - value=ret_val, - ).classes('w-full text-xs').style('max-width: 160px') + value=str(board.retention_hours), + ).classes('text-xs').style('max-width: 160px') # Whitelist - wl_val = ', '.join(bbs_cfg.get('allowed_keys', [])) if bbs_cfg else '' - whitelist_input = ui.input( - label='Allowed keys (comma-separated hex, leave empty for all)', - value=wl_val, + wl_input = ui.input( + label='Allowed keys (comma-separated hex, empty = all)', + value=', '.join(board.allowed_keys), ).classes('w-full text-xs') - # Save button - def _save( - idx=ch_idx, - name=ch_name, - cb=enable_cb, - cats=cats_input, - regs=regions_input, - ret=retention_input, - wl=whitelist_input, - ) -> None: - categories = [ - c.strip().upper() - for c in (cats.value or '').split(',') - if c.strip() - ] - regions = [ - r.strip() - for r in (regs.value or '').split(',') - if r.strip() - ] - try: - retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) - except ValueError: - retention_hours = DEFAULT_RETENTION_HOURS - allowed_keys = [ - k.strip() - for k in (wl.value or '').split(',') - if k.strip() - ] - cfg_entry = { - 'channel': idx, - 'name': name, - 'enabled': cb.value, - 'categories': categories if categories else list(DEFAULT_CATEGORIES), - 'regions': regions, - 'retention_hours': retention_hours, - 'allowed_keys': allowed_keys, - } - self._config_store.set_channel(cfg_entry) - debug_print( - f'BBS settings: saved ch={idx} enabled={cb.value} ' - f'cats={categories} regions={regions}' - ) - ui.notify(f'BBS settings saved for [{idx}] {name}', type='positive') - # Refresh channel buttons and message view - self._refresh_after_settings_save() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') - - def _refresh_after_settings_save(self) -> None: - """Rebuild the channel selector buttons after a settings save.""" - enabled = self._config_store.get_enabled_channels() - self._rebuild_channel_buttons(enabled) - # Reset active channel if it was disabled - if self._active_channel_idx is not None: - cfg = self._config_store.get_channel(self._active_channel_idx) - if not cfg or not cfg.get('enabled', False): - self._active_channel_idx = None - if self._msg_list_container: - self._msg_list_container.clear() - with self._msg_list_container: - ui.label('Select a channel above.').classes( - 'text-xs text-gray-400 italic' + with ui.row().classes('gap-2'): + def _save( + bid=board.id, + ni=name_input, + cc=ch_checks, + ci=cats_input, + ri=regions_input, + ret=retention_input, + wli=wl_input, + ) -> None: + new_name = (ni.value or '').strip() or bid + selected_channels = [ + idx for idx, cb in cc.items() if cb.value + ] + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + regions = [ + r.strip() + for r in (ri.value or '').split(',') if r.strip() + ] + try: + retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) + except ValueError: + retention_hours = DEFAULT_RETENTION_HOURS + allowed_keys = [ + k.strip() + for k in (wli.value or '').split(',') if k.strip() + ] + updated = BbsBoard( + id=bid, + name=new_name, + channels=selected_channels, + categories=categories, + regions=regions, + retention_hours=retention_hours, + allowed_keys=allowed_keys, ) + self._config_store.set_board(updated) + debug_print( + f'BBS settings: saved board {bid} ' + f'channels={selected_channels}' + ) + ui.notify(f'Board "{new_name}" saved.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + def _delete(bid=board.id, bname=board.name) -> None: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: deleted board {bid}') + ui.notify(f'Board "{bname}" deleted.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') + ui.button( + 'Delete', on_click=_delete, + ).props('no-caps flat color=negative').classes('text-xs') # ------------------------------------------------------------------ - # External update hook (called from dashboard timer) + # Settings -- create new board + # ------------------------------------------------------------------ + + def _on_create_board(self) -> None: + """Handle the Create button for a new board.""" + name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' + if not name: + ui.notify('Enter a board name first.', type='warning') + return + + board_id = _slug(name) + # Make id unique if needed + base_id = board_id + counter = 2 + while self._config_store.board_id_exists(board_id): + board_id = f'{base_id}_{counter}' + counter += 1 + + board = BbsBoard( + id=board_id, + name=name, + channels=[], + categories=list(DEFAULT_CATEGORIES), + regions=[], + retention_hours=DEFAULT_RETENTION_HOURS, + allowed_keys=[], + ) + self._config_store.set_board(board) + debug_print(f'BBS settings: created board {board_id}') + if self._new_board_name_input: + self._new_board_name_input.value = '' + ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + # ------------------------------------------------------------------ + # External update hook # ------------------------------------------------------------------ def update(self, data: Dict) -> None: """Called by the dashboard timer with the SharedData snapshot. - Rebuilds the settings panel when the device channel list changes. + Rebuilds the settings channel checkboxes when the device channel + list changes. Args: data: SharedData snapshot dict. """ device_channels = data.get('channels', []) - - # Rebuild settings only when the channel list changes - ch_fingerprint = tuple( + fingerprint = tuple( (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels ) - last_fingerprint = tuple( - (ch.get('idx', 0), ch.get('name', '')) for ch in self._last_device_channels - ) - if ch_fingerprint != last_fingerprint: - self._last_device_channels = device_channels - self._rebuild_settings(device_channels) - # Also rebuild channel buttons (config may have changed) - enabled = self._config_store.get_enabled_channels() - self._rebuild_channel_buttons(enabled) + if fingerprint != self._last_ch_fingerprint: + self._last_ch_fingerprint = fingerprint + self._device_channels = device_channels + self._rebuild_boards_settings() diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index 1e43842..c05727b 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -1,36 +1,46 @@ """ -BBS channel configuration store for MeshCore GUI. +BBS board configuration store for MeshCore GUI. -Persists BBS channel configuration to -``~/.meshcore-gui/bbs/bbs_config.json`` so that settings survive -restarts and are managed outside of ``config.py``. +Persists BBS board configuration to +``~/.meshcore-gui/bbs/bbs_config.json``. -On first use the file is created with an empty channel list. -The GUI populates it when the user enables BBS on a device channel. +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: + +- One board per channel (classic per-channel BBS) +- One board spanning multiple channels (shared bulletin board) + +Config version history +~~~~~~~~~~~~~~~~~~~~~~ +v1 β€” per-channel config (list of channels with enabled flag). +v2 β€” board-based config (list of boards, each with a channels list). + Automatic migration from v1 on first load. Thread safety ~~~~~~~~~~~~~ -All methods acquire an internal ``threading.Lock``. +All public methods acquire an internal ``threading.Lock``. """ import json import threading +from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional from meshcore_gui.config import debug_print # --------------------------------------------------------------------------- -# Storage location +# Storage # --------------------------------------------------------------------------- BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs" BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json" -CONFIG_VERSION: int = 1 +CONFIG_VERSION: int = 2 # --------------------------------------------------------------------------- -# Default values applied when a channel is first enabled +# Defaults # --------------------------------------------------------------------------- DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"] @@ -38,8 +48,64 @@ DEFAULT_REGIONS: List[str] = [] DEFAULT_RETENTION_HOURS: int = 48 +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BbsBoard: + """A BBS board grouping one or more MeshCore channels. + + Attributes: + id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``). + name: Human-readable board name. + channels: List of MeshCore channel indices assigned to this board. + categories: Valid category tags for this board. + regions: Optional region tags; empty = no region filtering. + retention_hours: Message retention period in hours. + allowed_keys: Sender public key whitelist (empty = all allowed). + """ + + id: str + name: str + channels: List[int] = field(default_factory=list) + categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES)) + regions: List[str] = field(default_factory=list) + retention_hours: int = DEFAULT_RETENTION_HOURS + allowed_keys: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict: + """Serialise to a JSON-compatible dict.""" + return { + "id": self.id, + "name": self.name, + "channels": list(self.channels), + "categories": list(self.categories), + "regions": list(self.regions), + "retention_hours": self.retention_hours, + "allowed_keys": list(self.allowed_keys), + } + + @staticmethod + def from_dict(d: Dict) -> "BbsBoard": + """Deserialise from a config dict.""" + return BbsBoard( + id=d.get("id", ""), + name=d.get("name", ""), + channels=list(d.get("channels", [])), + categories=list(d.get("categories", DEFAULT_CATEGORIES)), + regions=list(d.get("regions", [])), + retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)), + allowed_keys=list(d.get("allowed_keys", [])), + ) + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + class BbsConfigStore: - """Persistent store for BBS channel configuration. + """Persistent store for BBS board configuration. Args: config_path: Path to the JSON config file. @@ -49,7 +115,7 @@ class BbsConfigStore: def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None: self._path = config_path self._lock = threading.Lock() - self._data: Dict = {"version": CONFIG_VERSION, "channels": []} + self._boards: List[BbsBoard] = [] self._load() # ------------------------------------------------------------------ @@ -57,36 +123,79 @@ class BbsConfigStore: # ------------------------------------------------------------------ def _load(self) -> None: - """Load config from disk; create defaults if file is absent.""" + """Load config from disk; migrate v1 β†’ v2 if needed.""" BBS_DIR.mkdir(parents=True, exist_ok=True) if not self._path.exists(): self._save_unlocked() - debug_print("BBS config: created new config file") + debug_print("BBS config: created new config file (v2)") return try: raw = self._path.read_text(encoding="utf-8") data = json.loads(raw) - if data.get("version") == CONFIG_VERSION: - self._data = data + version = data.get("version", 1) + + if version == CONFIG_VERSION: + self._boards = [ + BbsBoard.from_dict(b) for b in data.get("boards", []) + ] + debug_print(f"BBS config: loaded {len(self._boards)} boards") + + elif version == 1: + # Migrate: each v1 channel β†’ one board + self._boards = self._migrate_v1(data.get("channels", [])) + self._save_unlocked() debug_print( - f"BBS config: loaded {len(self._data.get('channels', []))} channels" + f"BBS config: migrated v1 β†’ v2 ({len(self._boards)} boards)" ) else: debug_print( - f"BBS config: version mismatch " - f"(got {data.get('version')}, expected {CONFIG_VERSION}) β€” using defaults" + f"BBS config: unknown version {version}, using empty config" ) + except (json.JSONDecodeError, OSError) as exc: - debug_print(f"BBS config: load error ({exc}) β€” using defaults") + debug_print(f"BBS config: load error ({exc}), using empty config") + + @staticmethod + def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]: + """Convert v1 per-channel entries to v2 boards. + + Only enabled channels are migrated. + + Args: + v1_channels: List of v1 channel config dicts. + + Returns: + List of ``BbsBoard`` instances. + """ + boards = [] + for ch in v1_channels: + if not ch.get("enabled", False): + continue + idx = ch.get("channel", 0) + board_id = f"ch{idx}" + boards.append(BbsBoard( + id=board_id, + name=ch.get("name", f"Channel {idx}"), + channels=[idx], + categories=list(ch.get("categories", DEFAULT_CATEGORIES)), + regions=list(ch.get("regions", [])), + retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)), + allowed_keys=list(ch.get("allowed_keys", [])), + )) + return boards def _save_unlocked(self) -> None: """Write config to disk. MUST be called with self._lock held.""" BBS_DIR.mkdir(parents=True, exist_ok=True) + data = { + "version": CONFIG_VERSION, + "boards": [b.to_dict() for b in self._boards], + } tmp = self._path.with_suffix(".tmp") tmp.write_text( - json.dumps(self._data, indent=2, ensure_ascii=False), + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8", ) tmp.replace(self._path) @@ -97,136 +206,97 @@ class BbsConfigStore: self._save_unlocked() # ------------------------------------------------------------------ - # Channel management + # Board queries # ------------------------------------------------------------------ - def get_channels(self) -> List[Dict]: - """Return a copy of all configured channels (enabled and disabled). + def get_boards(self) -> List[BbsBoard]: + """Return a copy of all configured boards. Returns: - List of channel config dicts. + List of ``BbsBoard`` instances. """ with self._lock: - return [ch.copy() for ch in self._data.get("channels", [])] + return list(self._boards) - def get_enabled_channels(self) -> List[Dict]: - """Return only channels with ``enabled: true``. - - Returns: - List of enabled channel config dicts. - """ - with self._lock: - return [ - ch.copy() - for ch in self._data.get("channels", []) - if ch.get("enabled", False) - ] - - def get_channel(self, channel_idx: int) -> Optional[Dict]: - """Return config for a single channel index, or ``None``. + def get_board(self, board_id: str) -> Optional[BbsBoard]: + """Return a board by its id, or ``None``. Args: - channel_idx: MeshCore channel index. + board_id: Board identifier string. Returns: - Channel config dict copy, or ``None`` if not found. + ``BbsBoard`` instance or ``None``. """ with self._lock: - for ch in self._data.get("channels", []): - if ch.get("channel") == channel_idx: - return ch.copy() + for b in self._boards: + if b.id == board_id: + return BbsBoard.from_dict(b.to_dict()) return None - def set_channel(self, channel_cfg: Dict) -> None: - """Insert or update a channel configuration entry. + def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]: + """Return the first board that includes *channel_idx*, or ``None``. - The channel is identified by the ``channel`` key in *channel_cfg*. - If an entry with the same index exists it is replaced; otherwise - a new entry is appended. - - Args: - channel_cfg: Channel config dict (must contain ``'channel'``). - """ - idx = channel_cfg["channel"] - with self._lock: - channels = self._data.setdefault("channels", []) - for i, ch in enumerate(channels): - if ch.get("channel") == idx: - channels[i] = channel_cfg.copy() - self._save_unlocked() - debug_print(f"BBS config: updated ch={idx}") - return - channels.append(channel_cfg.copy()) - self._save_unlocked() - debug_print(f"BBS config: added ch={idx}") - - def enable_channel( - self, - channel_idx: int, - name: str, - *, - categories: Optional[List[str]] = None, - regions: Optional[List[str]] = None, - retention_hours: int = DEFAULT_RETENTION_HOURS, - allowed_keys: Optional[List[str]] = None, - ) -> None: - """Enable BBS on a device channel, creating a default config if needed. - - If the channel already exists its ``enabled`` flag is set to - ``True`` and other fields are left as-is. Pass explicit keyword - arguments to override any field on a new channel. - - Args: - channel_idx: MeshCore channel index. - name: Human-readable channel name. - categories: Category list (defaults to ``DEFAULT_CATEGORIES``). - regions: Region list (defaults to empty β€” no regions). - retention_hours: Retention in hours (default 48). - allowed_keys: Sender key whitelist (default empty = all allowed). - """ - existing = self.get_channel(channel_idx) - if existing: - existing["enabled"] = True - self.set_channel(existing) - else: - self.set_channel({ - "channel": channel_idx, - "name": name, - "enabled": True, - "categories": categories if categories is not None else list(DEFAULT_CATEGORIES), - "regions": regions if regions is not None else list(DEFAULT_REGIONS), - "retention_hours": retention_hours, - "allowed_keys": allowed_keys if allowed_keys is not None else [], - }) - - def disable_channel(self, channel_idx: int) -> None: - """Set ``enabled: false`` for a channel without removing its config. + Used by ``BbsCommandHandler`` to route incoming mesh commands. Args: channel_idx: MeshCore channel index. - """ - existing = self.get_channel(channel_idx) - if existing: - existing["enabled"] = False - self.set_channel(existing) - debug_print(f"BBS config: disabled ch={channel_idx}") - - def update_channel_field( - self, channel_idx: int, field: str, value - ) -> bool: - """Update a single field on an existing channel entry. - - Args: - channel_idx: MeshCore channel index. - field: Field name to update. - value: New value. Returns: - ``True`` if the channel was found and updated, ``False`` otherwise. + ``BbsBoard`` instance or ``None``. """ - existing = self.get_channel(channel_idx) - if not existing: - return False - existing[field] = value - self.set_channel(existing) - return True + with self._lock: + for b in self._boards: + if channel_idx in b.channels: + return BbsBoard.from_dict(b.to_dict()) + return None + + # ------------------------------------------------------------------ + # Board management + # ------------------------------------------------------------------ + + def set_board(self, board: BbsBoard) -> None: + """Insert or replace a board (matched by ``board.id``). + + Args: + board: ``BbsBoard`` to persist. + """ + with self._lock: + for i, b in enumerate(self._boards): + if b.id == board.id: + self._boards[i] = BbsBoard.from_dict(board.to_dict()) + self._save_unlocked() + debug_print(f"BBS config: updated board '{board.id}'") + return + self._boards.append(BbsBoard.from_dict(board.to_dict())) + self._save_unlocked() + debug_print(f"BBS config: added board '{board.id}'") + + def delete_board(self, board_id: str) -> bool: + """Remove a board by id. + + Args: + board_id: Board identifier to remove. + + Returns: + ``True`` if removed, ``False`` if not found. + """ + with self._lock: + before = len(self._boards) + self._boards = [b for b in self._boards if b.id != board_id] + if len(self._boards) < before: + self._save_unlocked() + debug_print(f"BBS config: deleted board '{board_id}'") + return True + return False + + def board_id_exists(self, board_id: str) -> bool: + """Check whether a board id is already in use. + + Args: + board_id: Board identifier to check. + + Returns: + ``True`` if a board with this id exists. + """ + with self._lock: + return any(b.id == board_id for b in self._boards) diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 2286c08..52b7968 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -1,27 +1,27 @@ """ Offline Bulletin Board System (BBS) service for MeshCore GUI. -Stores BBS messages in a local SQLite database, one table per channel. -Channel configuration is managed by -:class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` and -persisted to ``~/.meshcore-gui/bbs/bbs_config.json``. +Stores BBS messages in a local SQLite database. Messages are keyed by +their originating MeshCore channel index. A **board** (see +:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or +more channel indices to a single bulletin board, so queries are always +issued as ``WHERE channel IN (...)``. Architecture ~~~~~~~~~~~~ -- ``BbsService`` β€” persistence layer (SQLite, retention, queries). -- ``BbsCommandHandler`` β€” parses incoming ``!bbs`` text commands and - delegates to ``BbsService``. Returns reply text. +- ``BbsService`` -- persistence layer (SQLite, retention, queries). +- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and + delegates to ``BbsService``. Returns reply text. Thread safety ~~~~~~~~~~~~~ -SQLite connections are created in the calling thread. The service uses -``check_same_thread=False`` combined with an internal ``threading.Lock`` -so it is safe to call from both the GUI thread and the worker thread. +SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by +multiple application instances (e.g. 800 MHz + 433 MHz on one Pi). -Storage location -~~~~~~~~~~~~~~~~ -``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib). -``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore). +Storage +~~~~~~~ +``~/.meshcore-gui/bbs/bbs_messages.db`` +``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore) """ import sqlite3 @@ -33,10 +33,6 @@ from typing import Dict, List, Optional from meshcore_gui.config import debug_print -# --------------------------------------------------------------------------- -# Storage -# --------------------------------------------------------------------------- - BBS_DIR = Path.home() / ".meshcore-gui" / "bbs" BBS_DB_PATH = BBS_DIR / "bbs_messages.db" @@ -51,9 +47,9 @@ class BbsMessage: Attributes: id: Database row id (``None`` before insert). - channel: MeshCore channel index. - region: Region tag (empty string when channel has no regions). - category: Category tag (e.g. ``'MEDISCH'``). + channel: MeshCore channel index the message arrived on. + region: Region tag (empty string when board has no regions). + category: Category tag. sender: Display name of the sender. sender_key: Public key of the sender (hex string). text: Message body. @@ -81,7 +77,6 @@ class BbsService: Args: db_path: Path to the SQLite database file. - Defaults to ``~/.meshcore-gui/bbs/bbs_messages.db``. """ def __init__(self, db_path: Path = BBS_DB_PATH) -> None: @@ -89,10 +84,6 @@ class BbsService: self._lock = threading.Lock() self._init_db() - # ------------------------------------------------------------------ - # Initialisation - # ------------------------------------------------------------------ - def _init_db(self) -> None: """Create the database directory and schema if not present.""" BBS_DIR.mkdir(parents=True, exist_ok=True) @@ -121,10 +112,7 @@ class BbsService: debug_print(f"BBS: database ready at {self._db_path}") def _connect(self) -> sqlite3.Connection: - """Return a new SQLite connection (check_same_thread=False).""" - return sqlite3.connect( - str(self._db_path), check_same_thread=False - ) + return sqlite3.connect(str(self._db_path), check_same_thread=False) # ------------------------------------------------------------------ # Write @@ -142,163 +130,150 @@ class BbsService: with self._lock: with self._connect() as conn: cur = conn.execute( - """ - INSERT INTO bbs_messages - (channel, region, category, sender, sender_key, text, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - msg.channel, - msg.region, - msg.category, - msg.sender, - msg.sender_key, - msg.text, - msg.timestamp, - ), + """INSERT INTO bbs_messages + (channel, region, category, sender, sender_key, text, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (msg.channel, msg.region, msg.category, + msg.sender, msg.sender_key, msg.text, msg.timestamp), ) conn.commit() msg.id = cur.lastrowid debug_print( - f"BBS: posted msg id={msg.id} ch={msg.channel} " + f"BBS: posted id={msg.id} ch={msg.channel} " f"cat={msg.category} sender={msg.sender}" ) return msg.id # ------------------------------------------------------------------ - # Read + # Read (channels is a list to support multi-channel boards) # ------------------------------------------------------------------ def get_messages( self, - channel: int, + channels: List[int], region: Optional[str] = None, category: Optional[str] = None, limit: int = 5, ) -> List[BbsMessage]: - """Return the *limit* most recent messages for a channel. + """Return the *limit* most recent messages for a set of channels. Args: - channel: MeshCore channel index. - region: Optional region filter (exact match; ``None`` = all). - category: Optional category filter (exact match; ``None`` = all). + channels: MeshCore channel indices to query (board's channel list). + region: Optional region filter. + category: Optional category filter. limit: Maximum number of messages to return. Returns: List of ``BbsMessage`` objects, newest first. """ + if not channels: + return [] + placeholders = ",".join("?" * len(channels)) query = ( - "SELECT id, channel, region, category, sender, sender_key, text, timestamp " - "FROM bbs_messages WHERE channel = ?" + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" ) - params: list = [channel] - + params: list = list(channels) if region: query += " AND region = ?" params.append(region) if category: query += " AND category = ?" params.append(category) - query += " ORDER BY timestamp DESC LIMIT ?" params.append(limit) with self._lock: with self._connect() as conn: rows = conn.execute(query, params).fetchall() - - return [self._row_to_msg(row) for row in rows] + return [self._row_to_msg(r) for r in rows] def get_all_messages( self, - channel: int, + channels: List[int], region: Optional[str] = None, category: Optional[str] = None, ) -> List[BbsMessage]: - """Return all messages for a channel (oldest first) for the GUI panel. + """Return all messages for a set of channels (oldest first). Args: - channel: MeshCore channel index. + channels: MeshCore channel indices to query. region: Optional region filter. category: Optional category filter. Returns: List of ``BbsMessage`` objects, oldest first. """ + if not channels: + return [] + placeholders = ",".join("?" * len(channels)) query = ( - "SELECT id, channel, region, category, sender, sender_key, text, timestamp " - "FROM bbs_messages WHERE channel = ?" + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" ) - params: list = [channel] - + params: list = list(channels) if region: query += " AND region = ?" params.append(region) if category: query += " AND category = ?" params.append(category) - query += " ORDER BY timestamp ASC" with self._lock: with self._connect() as conn: rows = conn.execute(query, params).fetchall() - - return [self._row_to_msg(row) for row in rows] + return [self._row_to_msg(r) for r in rows] @staticmethod def _row_to_msg(row: tuple) -> BbsMessage: return BbsMessage( - id=row[0], - channel=row[1], - region=row[2], - category=row[3], - sender=row[4], - sender_key=row[5], - text=row[6], - timestamp=row[7], + id=row[0], channel=row[1], region=row[2], category=row[3], + sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7], ) # ------------------------------------------------------------------ # Retention # ------------------------------------------------------------------ - def purge_expired(self, channel: int, retention_hours: int) -> int: - """Delete messages older than *retention_hours* for a channel. + def purge_expired(self, channels: List[int], retention_hours: int) -> int: + """Delete messages older than *retention_hours* for a set of channels. Args: - channel: MeshCore channel index. + channels: MeshCore channel indices to purge. retention_hours: Messages older than this are deleted. Returns: Number of rows deleted. """ + if not channels: + return 0 cutoff = ( datetime.now(timezone.utc) - timedelta(hours=retention_hours) ).isoformat() - + placeholders = ",".join("?" * len(channels)) with self._lock: with self._connect() as conn: cur = conn.execute( - "DELETE FROM bbs_messages WHERE channel = ? AND timestamp < ?", - (channel, cutoff), + f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?", + list(channels) + [cutoff], ) conn.commit() deleted = cur.rowcount if deleted: debug_print( - f"BBS: purged {deleted} expired messages from ch={channel}" + f"BBS: purged {deleted} expired messages from ch={channels}" ) return deleted - def purge_all_expired(self, channels_config: List[Dict]) -> None: - """Run retention cleanup for all configured channels. + def purge_all_expired(self, boards) -> None: + """Run retention cleanup for all boards. Args: - channels_config: List of channel config dicts. + boards: Iterable of ``BbsBoard`` instances. """ - for cfg in channels_config: - self.purge_expired(cfg["channel"], cfg["retention_hours"]) + for board in boards: + self.purge_expired(board.channels, board.retention_hours) # --------------------------------------------------------------------------- @@ -308,14 +283,13 @@ class BbsService: class BbsCommandHandler: """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. - Channel configuration is read live from the supplied - :class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` - so that changes made in the GUI take effect immediately without - restarting the application. + Looks up the board for the incoming channel via ``BbsConfigStore`` + so that a single board spanning multiple channels handles commands + from all of them. Args: service: Shared ``BbsService`` instance. - config_store: ``BbsConfigStore`` instance for live channel config. + config_store: ``BbsConfigStore`` instance for live board config. """ READ_LIMIT: int = 5 @@ -324,13 +298,6 @@ class BbsCommandHandler: self._service = service self._config_store = config_store - def _get_cfg(self, channel_idx: int) -> Optional[Dict]: - """Return enabled channel config, or ``None``.""" - cfg = self._config_store.get_channel(channel_idx) - if cfg and cfg.get("enabled", False): - return cfg - return None - # ------------------------------------------------------------------ # Public entry point # ------------------------------------------------------------------ @@ -357,46 +324,44 @@ class BbsCommandHandler: if not text.lower().startswith("!bbs"): return None - cfg = self._get_cfg(channel_idx) - if cfg is None: + board = self._config_store.get_board_for_channel(channel_idx) + if board is None: return None # Whitelist check - allowed = cfg.get("allowed_keys", []) - if allowed and sender_key not in allowed: + if board.allowed_keys and sender_key not in board.allowed_keys: debug_print( f"BBS: silently dropping msg from {sender} " - f"(key not in whitelist for ch={channel_idx})" + 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(cfg, sender, sender_key, args) + return self._dispatch(board, channel_idx, sender, sender_key, args) # ------------------------------------------------------------------ # Dispatch # ------------------------------------------------------------------ - def _dispatch(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: + 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(cfg, sender, sender_key, rest) + return self._handle_post(board, channel_idx, sender, sender_key, rest) if sub == "read": - return self._handle_read(cfg, rest) + return self._handle_read(board, rest) if sub == "help" or not sub: - return self._handle_help(cfg) - return f"Unknown command '{sub}'. {self._handle_help(cfg)}" + return self._handle_help(board) + return f"Unknown command '{sub}'. {self._handle_help(board)}" # ------------------------------------------------------------------ - # Sub-command: post + # post # ------------------------------------------------------------------ - def _handle_post(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] + def _handle_post(self, board, channel_idx, sender, sender_key, args): + regions = board.regions + categories = board.categories tokens = args.split(None, 2) if args else [] if regions: @@ -407,16 +372,14 @@ class BbsCommandHandler: f"Categories: {', '.join(categories)}" ) region, category, text = tokens[0], tokens[1], tokens[2] - region_upper = region.upper() - valid_regions = [r.upper() for r in regions] - if region_upper not in valid_regions: + 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_regions.index(region_upper)] - category_upper = category.upper() - valid_cats = [c.upper() for c in categories] - if category_upper not in valid_cats: + 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_cats.index(category_upper)] + category = categories[valid_c.index(category.upper())] else: if len(tokens) < 2: return ( @@ -425,67 +388,57 @@ class BbsCommandHandler: ) region = "" category, text = tokens[0], tokens[1] - category_upper = category.upper() - valid_cats = [c.upper() for c in categories] - if category_upper not in valid_cats: + 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_cats.index(category_upper)] + category = categories[valid_c.index(category.upper())] msg = BbsMessage( - channel=cfg["channel"], - region=region, - category=category, - sender=sender, - sender_key=sender_key, - text=text, + 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]}" # ------------------------------------------------------------------ - # Sub-command: read + # read # ------------------------------------------------------------------ - def _handle_read(self, cfg: Dict, args: str) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] + def _handle_read(self, board, args): + regions = board.regions + categories = board.categories tokens = args.split() if args else [] - - region: Optional[str] = None - category: Optional[str] = None + region = None + category = None if regions: - valid_regions_upper = [r.upper() for r in regions] - valid_cats_upper = [c.upper() for c in categories] - if len(tokens) >= 1: - tok0 = tokens[0].upper() - if tok0 in valid_regions_upper: - region = regions[valid_regions_upper.index(tok0)] + 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: - tok1 = tokens[1].upper() - if tok1 in valid_cats_upper: - category = categories[valid_cats_upper.index(tok1)] + 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_cats_upper = [c.upper() for c in categories] - if len(tokens) >= 1: - tok0 = tokens[0].upper() - if tok0 in valid_cats_upper: - category = categories[valid_cats_upper.index(tok0)] + 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)}" messages = self._service.get_messages( - cfg["channel"], region=region, category=category, limit=self.READ_LIMIT, + board.channels, region=region, category=category, limit=self.READ_LIMIT, ) - if not messages: return "BBS: no messages found." - lines = [] for m in messages: ts = m.timestamp[:16].replace("T", " ") @@ -494,25 +447,22 @@ class BbsCommandHandler: return "\n".join(lines) # ------------------------------------------------------------------ - # Sub-command: help + # help # ------------------------------------------------------------------ - def _handle_help(self, cfg: Dict) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] - name = cfg.get("name", f"ch{cfg['channel']}") - if regions: + def _handle_help(self, board) -> str: + cats = ", ".join(board.categories) + if board.regions: + regs = ", ".join(board.regions) return ( - f"BBS [{name}] | " + f"BBS [{board.name}] | " f"!bbs post [region] [cat] [text] | " f"!bbs read [region] [cat] | " - f"Regions: {', '.join(regions)} | " - f"Categories: {', '.join(categories)}" + f"Regions: {regs} | Categories: {cats}" ) return ( - f"BBS [{name}] | " + f"BBS [{board.name}] | " f"!bbs post [cat] [text] | " f"!bbs read [cat] | " - f"Categories: {', '.join(categories)}" + f"Categories: {cats}" ) -