feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0)

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.
This commit is contained in:
pe1hvh
2026-03-14 08:05:30 +01:00
parent 64b6c62125
commit e3bd422dfd
7 changed files with 1043 additions and 2 deletions

View File

@@ -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

View File

@@ -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,
},
]

View File

@@ -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()

View File

@@ -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

View File

@@ -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.

View File

@@ -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)}"
)

View File

@@ -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: