From e3bd422dfd7e1a4d3775832dd89596e29c000f75 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:05:30 +0100 Subject: [PATCH] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- CHANGELOG.md | 25 ++ meshcore_gui/config.py | 43 +- meshcore_gui/gui/dashboard.py | 21 + meshcore_gui/gui/panels/__init__.py | 1 + meshcore_gui/gui/panels/bbs_panel.py | 310 ++++++++++++++ meshcore_gui/services/bbs_service.py | 610 +++++++++++++++++++++++++++ meshcore_gui/services/bot.py | 35 +- 7 files changed, 1043 insertions(+), 2 deletions(-) create mode 100644 meshcore_gui/gui/panels/bbs_panel.py create mode 100644 meshcore_gui/services/bbs_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 11beeb6..be12974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +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) + +### Added +- πŸ†• **`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`. Database at `~/.meshcore-gui/bbs/bbs_messages.db`. + - `BbsCommandHandler`: parses `!bbs post`, `!bbs read`, `!bbs help` mesh commands. Whitelist enforcement (silent drop on unknown sender key). Per-channel region/category validation with error reply. +- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel for the dashboard. + - Channel selector (NoodNet Zwolle / NoodNet OV / Dalfsen). + - Region filter (shown only when the active channel has regions configured). + - Category filter (all or specific). + - Scrollable message list with timestamp, sender, category and optional region tag. + - Post form: region select (conditional), category select, text input, Send button. + - Send broadcasts `!bbs post …` on the mesh channel so other nodes receive it. +- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` configuration block added; version bumped to `1.14.0`. +- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepts optional `bbs_handler` parameter. Incoming `!bbs` messages are routed to `BbsCommandHandler` before keyword matching; replies are sent on the originating channel. +- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsPanel` registered as standalone panel `'bbs'`; menu item `πŸ“‹ BBS` added to the drawer. +- πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. + +### Not changed +- BLE layer, SharedData, core/models, route_page, map_panel, message_archive, all other services. +- All existing bot keyword behaviour, room server flow, archive page, contacts, map, device, actions, rxlog panels. + +--- + ## [1.13.5] - 2026-03-14 β€” Route back-button and map popup flicker fixes ### Fixed diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index ab034ac..6b6a1dd 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.13.5" +VERSION: str = "1.14.0" # ============================================================================== @@ -388,3 +388,44 @@ RXLOG_RETENTION_DAYS: int = 7 # Retention period for contacts (in days). # Contacts not seen for longer than this are removed from cache. CONTACT_RETENTION_DAYS: int = 90 + + +# ============================================================================== +# BBS β€” Bulletin Board System +# ============================================================================== + +# One entry per BBS-enabled channel. Each entry configures: +# channel β€” MeshCore channel index (never use channel 0). +# name β€” Human-readable channel name shown in the BBS panel. +# regions β€” Optional list of region tags; empty list = no region filtering. +# categories β€” List of valid category tags for this channel. +# allowed_keys β€” Whitelist of sender public keys (hex strings). +# Empty list = only channel security applies (all keys allowed). +# retention_hours β€” How long messages are kept before automatic deletion. + +BBS_CHANNELS: List[Dict] = [ + { + "channel": 2, + "name": "NoodNet Zwolle", + "regions": ["Zwolle", "Dalfsen", "OV-Algemeen"], + "categories": ["MEDISCH", "LOGISTIEK", "STATUS", "ALGEMEEN"], + "allowed_keys": [], + "retention_hours": 48, + }, + { + "channel": 3, + "name": "NoodNet OV", + "regions": [], + "categories": ["STATUS", "ALGEMEEN", "INFRA"], + "allowed_keys": [], + "retention_hours": 48, + }, + { + "channel": 4, + "name": "Dalfsen", + "regions": [], + "categories": ["MEDISCH", "STATUS", "ALGEMEEN"], + "allowed_keys": [], + "retention_hours": 24, + }, +] diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py index d7cef43..e9876ac 100644 --- a/meshcore_gui/gui/dashboard.py +++ b/meshcore_gui/gui/dashboard.py @@ -16,6 +16,7 @@ from meshcore_gui import config from meshcore_gui.core.protocols import SharedDataReader from meshcore_gui.gui.panels import ( ActionsPanel, + BbsPanel, ContactsPanel, DevicePanel, MapPanel, @@ -24,6 +25,7 @@ from meshcore_gui.gui.panels import ( RxLogPanel, ) from meshcore_gui.gui.archive_page import ArchivePage +from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService from meshcore_gui.services.pin_store import PinStore from meshcore_gui.services.room_password_store import RoomPasswordStore @@ -264,6 +266,7 @@ _STANDALONE_ITEMS = [ ('\U0001f4e1', 'DEVICE', 'device'), ('\u26a1', 'ACTIONS', 'actions'), ('\U0001f4ca', 'RX LOG', 'rxlog'), + ('\U0001f4cb', 'BBS', 'bbs'), ] _EXT_LINKS = config.EXT_LINKS @@ -295,6 +298,13 @@ class DashboardPage: self._pin_store = pin_store self._room_password_store = room_password_store + # BBS service (singleton, shared with bot routing) + from meshcore_gui import config as _cfg + self._bbs_service = BbsService() + self._bbs_handler = BbsCommandHandler( + self._bbs_service, _cfg.BBS_CHANNELS + ) + # Panels (created fresh on each render) self._device: DevicePanel | None = None self._contacts: ContactsPanel | None = None @@ -303,6 +313,7 @@ class DashboardPage: self._actions: ActionsPanel | None = None self._rxlog: RxLogPanel | None = None self._room_server: RoomServerPanel | None = None + self._bbs: BbsPanel | None = None # Header status label self._status_label = None @@ -349,6 +360,8 @@ class DashboardPage: self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled) self._rxlog = RxLogPanel() self._room_server = RoomServerPanel(put_cmd, self._room_password_store) + from meshcore_gui import config as _cfg + self._bbs = BbsPanel(put_cmd, self._bbs_service, _cfg.BBS_CHANNELS) # Inject DOMCA theme (fonts + CSS variables) ui.add_head_html(_DOMCA_HEAD) @@ -509,6 +522,7 @@ class DashboardPage: ('actions', self._actions), ('rxlog', self._rxlog), ('rooms', self._room_server), + ('bbs', self._bbs), ] for panel_id, panel_obj in panel_defs: @@ -735,6 +749,9 @@ class DashboardPage: self._room_server.update(data) elif self._active_panel == 'rxlog': self._rxlog.update(data) + elif self._active_panel == 'bbs': + if self._bbs: + self._bbs.update(data) # ------------------------------------------------------------------ # Room Server callback (from ContactsPanel) @@ -817,6 +834,10 @@ class DashboardPage: if data['rxlog_updated'] or is_first: self._rxlog.update(data) + elif self._active_panel == 'bbs': + if self._bbs: + self._bbs.update(data) + # Signal worker that GUI is ready for data if is_first and data['channels'] and data['contacts']: self._shared.mark_gui_initialized() diff --git a/meshcore_gui/gui/panels/__init__.py b/meshcore_gui/gui/panels/__init__.py index 018396d..f9245f4 100644 --- a/meshcore_gui/gui/panels/__init__.py +++ b/meshcore_gui/gui/panels/__init__.py @@ -15,3 +15,4 @@ from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401 from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401 from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401 from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401 +from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401 diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py new file mode 100644 index 0000000..2a55ea5 --- /dev/null +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -0,0 +1,310 @@ +"""BBS panel β€” offline Bulletin Board System viewer and post form.""" + +from typing import Callable, Dict, List, Optional + +from nicegui import ui + +from meshcore_gui.config import debug_print +from meshcore_gui.services.bbs_service import BbsMessage, BbsService + + +class BbsPanel: + """BBS panel: channel selector, region/category filters, message list and post form. + + All data access goes through :class:`~meshcore_gui.services.bbs_service.BbsService`. + No direct SQLite access in this class (SOLID: SRP / DIP). + + Args: + put_command: Callable to enqueue a command dict for the worker. + bbs_service: Shared ``BbsService`` instance. + channels_config: ``BBS_CHANNELS`` list from ``config.py``. + """ + + def __init__( + self, + put_command: Callable[[Dict], None], + bbs_service: BbsService, + channels_config: List[Dict], + ) -> None: + self._put_command = put_command + self._service = bbs_service + self._channels_config = channels_config + + # Indexed for fast lookup + self._channels_by_idx: Dict[int, Dict] = { + cfg["channel"]: cfg for cfg in channels_config + } + + # UI state + self._active_channel_idx: int = ( + channels_config[0]["channel"] if channels_config else 0 + ) + self._active_region: Optional[str] = None + self._active_category: Optional[str] = None + + # UI element references + self._msg_list_container = None + self._region_select = None + self._region_row = None + self._category_select = None + self._text_input = None + self._post_region_select = None + self._post_region_row = None + self._post_category_select = None + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the complete BBS panel layout.""" + with ui.card().classes('w-full'): + ui.label('πŸ“‹ BBS β€” Bulletin Board System').classes('font-bold text-gray-600') + + # ── Channel selector ────────────────────────────────────── + with ui.row().classes('w-full items-center gap-4'): + ui.label('Channel:').classes('text-sm text-gray-600') + for cfg in self._channels_config: + idx = cfg["channel"] + name = cfg["name"] + ui.button( + name, + on_click=lambda i=idx: self._select_channel(i), + ).props('flat no-caps').classes('text-xs') + + ui.separator() + + # ── Filter row ──────────────────────────────────────────── + with ui.row().classes('w-full items-center gap-4'): + ui.label('Filter:').classes('text-sm text-gray-600') + + # Region filter (hidden when channel has no regions) + self._region_row = ui.row().classes('items-center gap-2') + with self._region_row: + ui.label('Region:').classes('text-xs text-gray-600') + self._region_select = ui.select( + options=[], + value=None, + on_change=lambda e: self._on_region_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + # Category filter + 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, + 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).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') + + # Post region select (hidden when channel has no regions) + self._post_region_row = ui.row().classes('items-center gap-1') + with self._post_region_row: + self._post_region_select = ui.select( + options=[], + label='Region', + ).classes('text-xs').style('min-width: 110px') + + # Post category select + self._post_category_select = ui.select( + options=[], + label='Category', + ).classes('text-xs').style('min-width: 110px') + + self._text_input = ui.input( + placeholder='Message text…', + ).classes('flex-grow text-sm') + + ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') + + # Initial render for the default channel + self._select_channel(self._active_channel_idx) + + # ------------------------------------------------------------------ + # Channel selection + # ------------------------------------------------------------------ + + def _select_channel(self, channel_idx: int) -> None: + """Switch the active channel and rebuild filter options. + + Args: + channel_idx: MeshCore channel index to activate. + """ + self._active_channel_idx = channel_idx + self._active_region = None + self._active_category = None + + cfg = self._channels_by_idx.get(channel_idx, {}) + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg.get("categories", []) + + # Region filter visibility + has_regions = bool(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) + + # Populate region selects + region_opts = ["(all)"] + 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 + + # Populate category selects + cat_opts = ["(all)"] + 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._refresh_messages() + + # ------------------------------------------------------------------ + # Filter callbacks + # ------------------------------------------------------------------ + + def _on_region_filter(self, value: Optional[str]) -> None: + """Handle region filter change. + + Args: + value: Selected region string, or ``'(all)'``. + """ + self._active_region = None if (not value or value == "(all)") else value + self._refresh_messages() + + def _on_category_filter(self, value: Optional[str]) -> None: + """Handle category filter change. + + Args: + value: Selected category string, or ``'(all)'``. + """ + self._active_category = None if (not value or value == "(all)") else value + self._refresh_messages() + + # ------------------------------------------------------------------ + # Message list refresh + # ------------------------------------------------------------------ + + def _refresh_messages(self) -> None: + """Query the BBS service and rebuild the message list UI.""" + if not self._msg_list_container: + return + + messages = self._service.get_all_messages( + channel=self._active_channel_idx, + region=self._active_region, + category=self._active_category, + ) + + self._msg_list_container.clear() + with self._msg_list_container: + if not messages: + ui.label('No messages.').classes('text-xs text-gray-400 italic') + for msg in messages: + self._render_message_row(msg) + + def _render_message_row(self, msg: BbsMessage) -> None: + """Render a single message row in the message list. + + 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}" + + with ui.column().classes('w-full gap-0 py-1 border-b border-gray-200'): + ui.label(header).classes('text-xs text-gray-500') + ui.label(msg.text).classes('text-sm') + + # ------------------------------------------------------------------ + # Post + # ------------------------------------------------------------------ + + def _on_post(self) -> None: + """Handle the Send button: validate inputs and post a BBS message.""" + cfg = self._channels_by_idx.get(self._active_channel_idx, {}) + 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: + ui.notify("Message text cannot be empty.", type="warning") + return + + category = ( + self._post_category_select.value + if self._post_category_select + else (categories[0] if categories else "") + ) + if not category: + ui.notify("Please select a category.", type="warning") + return + + region = "" + if regions and self._post_region_select: + region = self._post_region_select.value or "" + + # Build and persist the message (GUI post β€” sender is the local device) + msg = BbsMessage( + channel=self._active_channel_idx, + region=region, + category=category, + sender="Me", + sender_key="", + text=text, + ) + self._service.post_message(msg) + + # Optionally also broadcast via the mesh (put_command enqueues for worker) + 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, + "text": mesh_text, + }) + + debug_print(f"BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}") + + if self._text_input: + self._text_input.value = "" + self._refresh_messages() + ui.notify("Message posted.", type="positive") + + # ------------------------------------------------------------------ + # External update hook (called from dashboard timer) + # ------------------------------------------------------------------ + + def update(self, data: Dict) -> None: + """Called by the dashboard timer. Refreshes if new data arrived. + + Currently a lightweight no-op: the BBS panel refreshes on user + interaction. Override for real-time auto-refresh if desired. + + Args: + data: SharedData snapshot (unused; kept for interface consistency). + """ + # No-op: BBS data is local SQLite, not pushed via SharedData. + # Active refresh only happens on user action or channel switch. diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py new file mode 100644 index 0000000..18b2a6a --- /dev/null +++ b/meshcore_gui/services/bbs_service.py @@ -0,0 +1,610 @@ +""" +Offline Bulletin Board System (BBS) service for MeshCore GUI. + +Stores BBS messages in a local SQLite database, one table per channel. +Each channel is configured via ``BBS_CHANNELS`` in ``config.py``. + +Architecture +~~~~~~~~~~~~ +- ``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. + +Storage location +~~~~~~~~~~~~~~~~ +``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib). +""" + +import sqlite3 +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +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" + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BbsMessage: + """A single BBS message. + + 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'``). + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Message body. + timestamp: UTC ISO-8601 timestamp string. + """ + + channel: int + region: str + category: str + sender: str + sender_key: str + text: str + timestamp: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + id: Optional[int] = None + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + +class BbsService: + """SQLite-backed BBS storage service. + + 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: + self._db_path = db_path + 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) + with self._connect() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS bbs_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel INTEGER NOT NULL, + region TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL, + sender TEXT NOT NULL, + sender_key TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL, + timestamp TEXT NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)" + ) + conn.commit() + 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 + ) + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + def post_message(self, msg: BbsMessage) -> int: + """Insert a BBS message and return its row id. + + Args: + msg: ``BbsMessage`` dataclass to persist. + + Returns: + Assigned ``rowid`` (also set on ``msg.id``). + """ + 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, + ), + ) + conn.commit() + msg.id = cur.lastrowid + debug_print( + f"BBS: posted msg id={msg.id} ch={msg.channel} " + f"cat={msg.category} sender={msg.sender}" + ) + return msg.id + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def get_messages( + self, + channel: int, + region: Optional[str] = None, + category: Optional[str] = None, + limit: int = 5, + ) -> List[BbsMessage]: + """Return the *limit* most recent messages for a channel. + + Args: + channel: MeshCore channel index. + region: Optional region filter (exact match; ``None`` = all). + category: Optional category filter (exact match; ``None`` = all). + limit: Maximum number of messages to return. + + Returns: + List of ``BbsMessage`` objects, newest first. + """ + query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + params: list = [channel] + + 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 [ + 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], + ) + for row in rows + ] + + def get_all_messages( + self, + channel: int, + region: Optional[str] = None, + category: Optional[str] = None, + ) -> List[BbsMessage]: + """Return all messages for a channel (oldest first) for the GUI panel. + + Args: + channel: MeshCore channel index. + region: Optional region filter. + category: Optional category filter. + + Returns: + List of ``BbsMessage`` objects, oldest first. + """ + query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + params: list = [channel] + + 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 [ + 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], + ) + for row in rows + ] + + # ------------------------------------------------------------------ + # Retention + # ------------------------------------------------------------------ + + def purge_expired(self, channel: int, retention_hours: int) -> int: + """Delete messages older than *retention_hours* for a channel. + + Args: + channel: MeshCore channel index. + retention_hours: Messages older than this are deleted. + + Returns: + Number of rows deleted. + """ + cutoff = ( + datetime.now(timezone.utc) - timedelta(hours=retention_hours) + ).isoformat() + + with self._lock: + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM bbs_messages WHERE channel = ? AND timestamp < ?", + (channel, cutoff), + ) + conn.commit() + deleted = cur.rowcount + if deleted: + debug_print( + f"BBS: purged {deleted} expired messages from ch={channel}" + ) + return deleted + + def purge_all_expired(self, channels_config: List[Dict]) -> None: + """Run retention cleanup for all configured channels. + + Args: + channels_config: List of channel config dicts from ``BBS_CHANNELS``. + """ + for cfg in channels_config: + self.purge_expired(cfg["channel"], cfg["retention_hours"]) + + +# --------------------------------------------------------------------------- +# Command handler +# --------------------------------------------------------------------------- + +class BbsCommandHandler: + """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. + + One handler is shared across all configured channels. Channel context + is passed per call so the handler is stateless. + + Args: + service: Shared ``BbsService`` instance. + channels_config: ``BBS_CHANNELS`` list from ``config.py``. + """ + + # Maximum messages returned per !bbs read call + READ_LIMIT: int = 5 + + def __init__( + self, + service: BbsService, + channels_config: List[Dict], + ) -> None: + self._service = service + # Index by channel number for O(1) lookup + self._channels: Dict[int, Dict] = { + cfg["channel"]: cfg for cfg in channels_config + } + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def handle( + self, + channel_idx: int, + sender: str, + sender_key: str, + text: str, + ) -> Optional[str]: + """Parse an incoming message and return a reply string (or ``None``). + + Returns ``None`` when the message is not a BBS command, the channel + is not configured, or the sender fails the whitelist check. + + Args: + channel_idx: MeshCore channel index the message arrived on. + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Raw message text. + + Returns: + Reply string, or ``None`` if no reply should be sent. + """ + text = (text or "").strip() + if not text.lower().startswith("!bbs"): + return None + + cfg = self._channels.get(channel_idx) + if cfg is None: + return None # Channel not configured β€” ignore + + # Whitelist check + allowed = cfg.get("allowed_keys", []) + if allowed and sender_key not in allowed: + debug_print( + f"BBS: silently dropping msg from {sender} " + f"(key not in whitelist for ch={channel_idx})" + ) + return None # Silent drop β€” no error reply + + parts = text.split(None, 1) + args = parts[1].strip() if len(parts) > 1 else "" + + return self._dispatch(cfg, sender, sender_key, args) + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + + def _dispatch( + self, + cfg: Dict, + sender: str, + sender_key: str, + args: str, + ) -> str: + """Route to the appropriate sub-command handler. + + Args: + cfg: Channel configuration dict. + sender: Display name of the sender. + sender_key: Public key of the sender. + args: Everything after ``!bbs ``. + + Returns: + Reply string (always non-empty). + """ + 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) + if sub == "read": + return self._handle_read(cfg, rest) + if sub == "help" or not sub: + return self._handle_help(cfg) + + return f"Unknown command '{sub}'. {self._handle_help(cfg)}" + + # ------------------------------------------------------------------ + # Sub-command: post + # ------------------------------------------------------------------ + + def _handle_post( + self, + cfg: Dict, + sender: str, + sender_key: str, + args: str, + ) -> str: + """Handle ``!bbs post [region] [category] [text]``. + + When the channel has regions, the first token is the region, + the second is the category, and the remainder is the text. + Without regions, the first token is the category and the + remainder is the text. + + Args: + cfg: Channel configuration dict. + sender: Display name of the sender. + sender_key: Public key of the sender. + args: Everything after ``!bbs post ``. + + Returns: + Confirmation or error message string. + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + tokens = args.split(None, 2) if args else [] + + if regions: + # Syntax: !bbs post [region] [category] [text] + 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] + + region_upper = region.upper() + valid_regions = [r.upper() for r in regions] + if region_upper not in valid_regions: + return ( + f"Invalid region '{region}'. " + f"Valid: {', '.join(regions)}" + ) + # Normalise to configured casing + 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: + return ( + f"Invalid category '{category}'. " + f"Valid: {', '.join(categories)}" + ) + category = categories[valid_cats.index(category_upper)] + else: + # Syntax: !bbs post [category] [text] + if len(tokens) < 2: + return ( + f"Usage: !bbs post [category] [text] | " + f"Categories: {', '.join(categories)}" + ) + 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: + return ( + f"Invalid category '{category}'. " + f"Valid: {', '.join(categories)}" + ) + category = categories[valid_cats.index(category_upper)] + + msg = BbsMessage( + channel=cfg["channel"], + 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 + # ------------------------------------------------------------------ + + def _handle_read(self, cfg: Dict, args: str) -> str: + """Handle ``!bbs read [region] [category]``. + + With regions: ``!bbs read`` / ``!bbs read [region]`` / + ``!bbs read [region] [category]`` + Without regions: ``!bbs read`` / ``!bbs read [category]`` + + Args: + cfg: Channel configuration dict. + args: Everything after ``!bbs read ``. + + Returns: + Formatted message list or error string. + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + tokens = args.split() if args else [] + + region: Optional[str] = None + category: Optional[str] = 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)] + if len(tokens) >= 2: + tok1 = tokens[1].upper() + if tok1 in valid_cats_upper: + category = categories[valid_cats_upper.index(tok1)] + else: + return ( + f"Invalid category '{tokens[1]}'. " + f"Valid: {', '.join(categories)}" + ) + else: + return ( + f"Invalid region '{tokens[0]}'. " + f"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)] + else: + return ( + f"Invalid category '{tokens[0]}'. " + f"Valid: {', '.join(categories)}" + ) + + messages = self._service.get_messages( + cfg["channel"], 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", " ") # YYYY-MM-DD HH:MM + region_label = f"[{m.region}] " if m.region else "" + lines.append( + f"{ts} {m.sender} [{m.category}] {region_label}{m.text}" + ) + return "\n".join(lines) + + # ------------------------------------------------------------------ + # Sub-command: help + # ------------------------------------------------------------------ + + def _handle_help(self, cfg: Dict) -> str: + """Return a compact command reference for this channel. + + Args: + cfg: Channel configuration dict. + + Returns: + Help string (single line). + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + name = cfg.get("name", f"ch{cfg['channel']}") + + if regions: + return ( + f"BBS [{name}] | " + f"!bbs post [region] [cat] [text] | " + f"!bbs read [region] [cat] | " + f"Regions: {', '.join(regions)} | " + f"Categories: {', '.join(categories)}" + ) + return ( + f"BBS [{name}] | " + f"!bbs post [cat] [text] | " + f"!bbs read [cat] | " + f"Categories: {', '.join(categories)}" + ) diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py index 6e4465a..9279a72 100644 --- a/meshcore_gui/services/bot.py +++ b/meshcore_gui/services/bot.py @@ -10,11 +10,21 @@ Open/Closed 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. """ import time from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +if TYPE_CHECKING: + from meshcore_gui.services.bbs_service import BbsCommandHandler from meshcore_gui.config import debug_print @@ -81,11 +91,13 @@ 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, @@ -129,6 +141,27 @@ 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: