mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-08-07 09:22:50 +02:00
feat(bbs): DM-based BBS with short syntax and auto-abbreviations(#v1.14.0)
Adds an offline Bulletin Board System accessible via Direct Message to the node's own key. All BBS commands (!p, !r, !bbs) are handled directly in EventHandler.on_contact_msg, independent of MeshBot. - One node = one board; settings reduced to a single channel selector - Short syntax: !p <cat> <text> and !r [cat] alongside full !bbs syntax - Category abbreviations computed automatically (shortest unique prefix) - !r and !bbs help always include the abbreviation table in the reply - DM reply routed back to sender via command_sink - SQLite message store with WAL mode and configurable retention
This commit is contained in:
+16
-18
@@ -30,33 +30,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
|
||||
> lower CPU usage during idle operation, and more stable map rendering.
|
||||
|
||||
---
|
||||
## [1.14.0] - 2026-03-14 — Offline BBS (Bulletin Board System)
|
||||
## [1.14.0] - 2026-03-14 — BBS (Bulletin Board System)
|
||||
|
||||
### Added
|
||||
- 🆕 **`meshcore_gui/services/bbs_config_store.py`** — `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`.
|
||||
- 🆕 **`meshcore_gui/services/bbs_service.py`** — SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`.
|
||||
- 🆕 **`meshcore_gui/gui/panels/bbs_panel.py`** — BBS panel voor het dashboard.
|
||||
- Board-selector (knoppen per geconfigureerd board).
|
||||
- Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft).
|
||||
- Scrollbare berichtenlijst over alle channels van het actieve board.
|
||||
- Post-formulier: post op het eerste channel van het board.
|
||||
- **Settings-sectie**: boards aanmaken (naam → Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieën, regio's, retentie, whitelist, Save en Delete.
|
||||
|
||||
- 🆕 **BBS — Bulletin Board System** — offline berichtenbord voor mesh-netwerken.
|
||||
- Één node beheert één board op één channel. Alle commando's via **Direct Message** aan de node; het channel blijft schoon.
|
||||
- Korte syntax: `!p <cat> <tekst>` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). `!r` zonder args toont de afkortingstabel altijd mee.
|
||||
- Volledige syntax behouden: `!bbs post`, `!bbs read`, `!bbs help`.
|
||||
- Optioneel regio-filter (`!p Zwolle U hulp nodig`) en sender-whitelist.
|
||||
- Settings-pagina (`/bbs-settings`): één channel-selector, categorieën, retentie (uur), en een ingeklapte Advanced-sectie voor regio's en allowed keys.
|
||||
- Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode).
|
||||
|
||||
### Changed
|
||||
- 🔄 **`meshcore_gui/services/bot.py`** — `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`.
|
||||
- 🔄 **`meshcore_gui/config.py`** — `BBS_CHANNELS` verwijderd; versie `1.14.0`.
|
||||
- 🔄 **`meshcore_gui/gui/dashboard.py`** — `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `📋 BBS` drawer-item.
|
||||
- 🔄 **`meshcore_gui/gui/panels/__init__.py`** — `BbsPanel` re-exported.
|
||||
|
||||
- 🔄 **`ble/events.py`** — DMs die beginnen met `!` worden direct verwerkt door `BbsCommandHandler`, volledig los van `MeshBot`.
|
||||
- 🔄 **`services/bot.py`** — `MeshBot` is weer een pure keyword/channel responder; BBS-routing verwijderd.
|
||||
- 🔄 **`services/bbs_config_store.py`** — `get_single_board()`, `set_single_board()`, `clear_single_board()` toegevoegd.
|
||||
- 🔄 **`gui/dashboard.py`** — `BbsPanel` geregistreerd, `📋 BBS` drawer-item toegevoegd.
|
||||
|
||||
### Storage
|
||||
```
|
||||
~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2)
|
||||
~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag
|
||||
~/.meshcore-gui/bbs/bbs_config.json — board configuratie
|
||||
~/.meshcore-gui/bbs/bbs_messages.db — SQLite berichtenopslag
|
||||
```
|
||||
|
||||
### Not changed
|
||||
- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels.
|
||||
|
||||
---
|
||||
|
||||
## [1.13.5] - 2026-03-14 — Route back-button and map popup flicker fixes
|
||||
|
||||
@@ -1184,11 +1184,105 @@ meshcore-gui/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 15. Roadmap
|
||||
## 15. BBS — Bulletin Board System
|
||||
|
||||
MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging.
|
||||
|
||||
### Design
|
||||
|
||||
One node manages one board. Multiple boards require multiple nodes. All BBS commands are sent as a **Direct Message to the BBS node** — the channel stays clean and replies are private to the sender.
|
||||
|
||||
```
|
||||
User ──DM──▶ BBS node (public key)
|
||||
processes command
|
||||
User ◀──DM── reply (only visible to sender)
|
||||
```
|
||||
|
||||
Channel commands remain available as a fallback, but DM is the primary interface.
|
||||
|
||||
### Settings
|
||||
|
||||
Open the BBS settings via the gear icon (⚙) in the BBS panel, or navigate to `/bbs-settings`.
|
||||
|
||||
```
|
||||
BBS Settings
|
||||
─────────────────────────────────────────────
|
||||
Channel: [2] NoodNet Zwolle ▼
|
||||
Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL
|
||||
Retain: 48 hours
|
||||
[Save]
|
||||
|
||||
▶ Advanced
|
||||
Regions (comma-separated)
|
||||
Allowed keys (empty = everyone on the channel)
|
||||
```
|
||||
|
||||
- **Channel** — select which device channel this node's board listens on.
|
||||
- **Categories** — comma-separated list of valid category tags.
|
||||
- **Retain** — message retention in hours (default 48).
|
||||
- **Advanced → Regions** — optional region tags for geographic filtering.
|
||||
- **Advanced → Allowed keys** — sender public key whitelist; empty = all senders allowed.
|
||||
|
||||
### Command syntax
|
||||
|
||||
#### Short syntax
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `!p <cat> <text>` | Post a message |
|
||||
| `!p <region> <cat> <text>` | Post with region |
|
||||
| `!r` | Read 5 most recent (all categories) |
|
||||
| `!r <cat>` | Read filtered by category |
|
||||
| `!r <region> <cat>` | Read filtered by region and category |
|
||||
|
||||
Category abbreviations are computed automatically as the shortest unique prefix within the configured category list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`:
|
||||
|
||||
```
|
||||
U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
|
||||
```
|
||||
|
||||
If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), the node calculates longer prefixes automatically: `ME` and `MI`. The `!r` and `!bbs help` replies always include the current abbreviation table.
|
||||
|
||||
#### Full syntax
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `!bbs help` | Show commands and abbreviation table |
|
||||
| `!bbs post <category> <text>` | Post a message |
|
||||
| `!bbs post <region> <category> <text>` | Post with region |
|
||||
| `!bbs read` | Read 5 most recent |
|
||||
| `!bbs read <category>` | Read filtered by category |
|
||||
| `!bbs read <region> <category>` | Read filtered by region and category |
|
||||
|
||||
#### Example help reply
|
||||
|
||||
```
|
||||
BBS [NoodNet Zwolle] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
|
||||
```
|
||||
|
||||
### Error handling
|
||||
|
||||
| Situation | Reply |
|
||||
|---|---|
|
||||
| Unknown category | Lists valid categories and abbreviations |
|
||||
| Ambiguous abbreviation | Lists all matching categories |
|
||||
| Sender not on whitelist | Silent drop — no reply |
|
||||
|
||||
### Storage
|
||||
|
||||
```
|
||||
~/.meshcore-gui/bbs/bbs_messages.db — SQLite message store (WAL mode)
|
||||
~/.meshcore-gui/bbs/bbs_config.json — Board configuration (v2 format)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. Roadmap
|
||||
|
||||
This project is under active development. The most common features from the official MeshCore Companion apps are being implemented gradually. Planned additions include:
|
||||
|
||||
- [x] **Cross-frequency bridge** — standalone daemon connecting two devices on different frequencies via configurable channel forwarding (see [11. Cross-Frequency Bridge](#11-cross-frequency-bridge))
|
||||
- [x] **BBS — Bulletin Board System** — offline message board with DM-based commands, category/region filtering and automatic abbreviations (see [15. BBS](#15-bbs--bulletin-board-system))
|
||||
- [ ] **Observer mode** — passively monitor mesh traffic without transmitting, useful for network analysis, coverage mapping and long-term logging
|
||||
- [ ] **Room Server administration** — authenticate as admin to manage Room Server settings and users directly from the GUI
|
||||
- [ ] **Repeater management** — connect to repeater nodes to view status and adjust configuration
|
||||
|
||||
@@ -4,9 +4,16 @@ Device event callbacks for MeshCore GUI.
|
||||
Handles ``CHANNEL_MSG_RECV``, ``CONTACT_MSG_RECV`` and ``RX_LOG_DATA``
|
||||
events from the MeshCore library. Extracted from ``SerialWorker`` so the
|
||||
worker only deals with connection lifecycle.
|
||||
|
||||
BBS routing
|
||||
~~~~~~~~~~~
|
||||
Direct Messages (``CONTACT_MSG_RECV``) whose text starts with ``!`` are
|
||||
forwarded to :class:`~meshcore_gui.services.bbs_service.BbsCommandHandler`
|
||||
**before** any other DM processing. This path is completely independent of
|
||||
:class:`~meshcore_gui.services.bot.MeshBot`.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
from meshcore_gui.core.models import Message, RxLogEntry
|
||||
@@ -15,6 +22,9 @@ from meshcore_gui.ble.packet_decoder import PacketDecoder, PayloadType
|
||||
from meshcore_gui.services.bot import MeshBot
|
||||
from meshcore_gui.services.dedup import DualDeduplicator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_gui.services.bbs_service import BbsCommandHandler
|
||||
|
||||
|
||||
class EventHandler:
|
||||
"""Processes device events and writes results to shared data.
|
||||
@@ -35,11 +45,15 @@ class EventHandler:
|
||||
decoder: PacketDecoder,
|
||||
dedup: DualDeduplicator,
|
||||
bot: MeshBot,
|
||||
bbs_handler: Optional["BbsCommandHandler"] = None,
|
||||
command_sink: Optional[Callable[[Dict], None]] = None,
|
||||
) -> None:
|
||||
self._shared = shared
|
||||
self._decoder = decoder
|
||||
self._dedup = dedup
|
||||
self._bot = bot
|
||||
self._bbs_handler = bbs_handler
|
||||
self._command_sink = command_sink
|
||||
|
||||
# Cache: message_hash → path_hashes (from RX_LOG decode).
|
||||
# Used by on_channel_msg fallback to recover hashes that the
|
||||
@@ -409,9 +423,45 @@ class EventHandler:
|
||||
or (pubkey[:8] if pubkey else '')
|
||||
)
|
||||
|
||||
dm_text = payload.get('text', '')
|
||||
|
||||
# BBS routing: DMs starting with '!' go directly to BbsCommandHandler.
|
||||
# This path is independent of the bot (MeshBot is for channel messages only).
|
||||
if (
|
||||
self._bbs_handler is not None
|
||||
and self._command_sink is not None
|
||||
and dm_text.strip().startswith("!")
|
||||
):
|
||||
bbs_reply = self._bbs_handler.handle_dm(
|
||||
sender=sender,
|
||||
sender_key=pubkey,
|
||||
text=dm_text,
|
||||
)
|
||||
if bbs_reply is not None:
|
||||
debug_print(f"BBS DM reply to {sender} ({pubkey[:8]}): {bbs_reply[:60]}")
|
||||
self._command_sink({
|
||||
"action": "send_dm",
|
||||
"pubkey": pubkey,
|
||||
"text": bbs_reply,
|
||||
})
|
||||
# Always store the incoming DM in the message archive too
|
||||
self._shared.add_message(Message.incoming(
|
||||
sender,
|
||||
dm_text,
|
||||
None,
|
||||
snr=self._extract_snr(payload),
|
||||
path_len=path_len,
|
||||
sender_pubkey=pubkey,
|
||||
path_hashes=path_hashes,
|
||||
path_names=path_names,
|
||||
message_hash=msg_hash,
|
||||
))
|
||||
debug_print(f"BBS DM stored from {sender}: {dm_text[:30]}")
|
||||
return
|
||||
|
||||
self._shared.add_message(Message.incoming(
|
||||
sender,
|
||||
payload.get('text', ''),
|
||||
dm_text,
|
||||
None,
|
||||
snr=self._extract_snr(payload),
|
||||
path_len=path_len,
|
||||
@@ -420,7 +470,7 @@ class EventHandler:
|
||||
path_names=path_names,
|
||||
message_hash=msg_hash,
|
||||
))
|
||||
debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}")
|
||||
debug_print(f"DM received from {sender}: {dm_text[:30]}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
@@ -54,6 +54,8 @@ from meshcore_gui.ble.commands import CommandHandler
|
||||
from meshcore_gui.ble.events import EventHandler
|
||||
from meshcore_gui.ble.packet_decoder import PacketDecoder
|
||||
from meshcore_gui.services.bot import BotConfig, MeshBot
|
||||
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
|
||||
from meshcore_gui.services.bbs_config_store import BbsConfigStore
|
||||
from meshcore_gui.services.cache import DeviceCache
|
||||
from meshcore_gui.services.dedup import DualDeduplicator
|
||||
from meshcore_gui.services.device_identity import write_device_identity
|
||||
@@ -124,6 +126,12 @@ class _BaseWorker(abc.ABC):
|
||||
enabled_check=shared.is_bot_enabled,
|
||||
)
|
||||
|
||||
# BBS handler — wired directly into EventHandler for DM routing.
|
||||
# Independent of the bot; uses a shared config store and service.
|
||||
_bbs_config = BbsConfigStore()
|
||||
_bbs_service = BbsService()
|
||||
self._bbs_handler = BbsCommandHandler(service=_bbs_service, config_store=_bbs_config)
|
||||
|
||||
# Channel indices that still need keys from device
|
||||
self._pending_keys: Set[int] = set()
|
||||
|
||||
@@ -244,6 +252,8 @@ class _BaseWorker(abc.ABC):
|
||||
decoder=self._decoder,
|
||||
dedup=self._dedup,
|
||||
bot=self._bot,
|
||||
bbs_handler=self._bbs_handler,
|
||||
command_sink=self.shared.put_command,
|
||||
)
|
||||
self._cmd_handler = CommandHandler(
|
||||
mc=self.mc, shared=self.shared, cache=self._cache,
|
||||
|
||||
@@ -368,8 +368,10 @@ class BbsPanel:
|
||||
class BbsSettingsPage:
|
||||
"""Standalone BBS settings page, registered at /bbs-settings.
|
||||
|
||||
Follows the same pattern as RoutePage: one instance, render() called
|
||||
per page load.
|
||||
One node = one board. The page shows a single channel selector
|
||||
populated from the active device channels, plus a categories field,
|
||||
a retention field, and a collapsible Advanced section for regions
|
||||
and allowed keys. There is no board creation or deletion UI.
|
||||
|
||||
Args:
|
||||
shared: SharedData instance (for device channel list).
|
||||
@@ -384,7 +386,7 @@ class BbsSettingsPage:
|
||||
self._shared = shared
|
||||
self._config_store = config_store
|
||||
self._device_channels: List[Dict] = []
|
||||
self._boards_settings_container = None
|
||||
self._container = None
|
||||
|
||||
def render(self) -> None:
|
||||
"""Render the BBS settings page."""
|
||||
@@ -411,204 +413,125 @@ class BbsSettingsPage:
|
||||
ui.label('BBS Settings').classes('font-bold text-gray-600')
|
||||
ui.separator()
|
||||
|
||||
self._boards_settings_container = ui.column().classes('w-full gap-3')
|
||||
with self._boards_settings_container:
|
||||
self._container = ui.column().classes('w-full gap-3')
|
||||
with self._container:
|
||||
if not self._device_channels:
|
||||
ui.label('Connect device to see channels.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
else:
|
||||
self._render_all()
|
||||
self._render_settings()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_all(self) -> None:
|
||||
"""Render all channel rows and the advanced section."""
|
||||
for ch in self._device_channels:
|
||||
self._render_channel_settings_row(ch)
|
||||
def _render_settings(self) -> None:
|
||||
"""Render the single-board settings block."""
|
||||
board = self._config_store.get_single_board()
|
||||
|
||||
ui.separator()
|
||||
# Build channel options: {idx: "[idx] name"}
|
||||
ch_options = {
|
||||
ch.get('idx', ch.get('index', 0)):
|
||||
f"[{ch.get('idx', ch.get('index', 0))}] {ch.get('name', '?')}"
|
||||
for ch in self._device_channels
|
||||
}
|
||||
|
||||
with ui.expansion('Advanced', value=False).classes('w-full').props('dense'):
|
||||
ui.label('Regions and key list per channel').classes(
|
||||
'text-xs text-gray-500 pb-1'
|
||||
current_idx = (
|
||||
board.channels[0] if board and board.channels
|
||||
else next(iter(ch_options), 0)
|
||||
)
|
||||
cats_value = (
|
||||
', '.join(board.categories) if board
|
||||
else ', '.join(DEFAULT_CATEGORIES)
|
||||
)
|
||||
retention_value = (
|
||||
str(board.retention_hours) if board
|
||||
else str(DEFAULT_RETENTION_HOURS)
|
||||
)
|
||||
adv_regions_value = ', '.join(board.regions) if board else ''
|
||||
adv_keys_value = ', '.join(board.allowed_keys) if board else ''
|
||||
|
||||
# ── Main block ───────────────────────────────────────────────
|
||||
with ui.column().classes('w-full gap-2'):
|
||||
with ui.row().classes('w-full items-center gap-2'):
|
||||
ui.label('Channel:').classes('text-xs text-gray-600 w-24 shrink-0')
|
||||
ch_select = ui.select(
|
||||
options=ch_options,
|
||||
value=current_idx,
|
||||
).classes('text-xs flex-grow')
|
||||
|
||||
with ui.row().classes('w-full items-center gap-2'):
|
||||
ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0')
|
||||
cats_input = ui.input(value=cats_value).classes('text-xs flex-grow')
|
||||
|
||||
with ui.row().classes('w-full items-center gap-2'):
|
||||
ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0')
|
||||
retention_input = ui.input(
|
||||
value=retention_value,
|
||||
).classes('text-xs').style('max-width: 80px')
|
||||
ui.label('hours').classes('text-xs text-gray-600')
|
||||
|
||||
# ── Advanced (collapsed) ─────────────────────────────────────
|
||||
with ui.expansion('Advanced', value=False).classes('w-full mt-2').props('dense'):
|
||||
ui.label('Regions and allowed keys').classes('text-xs text-gray-500 pb-1')
|
||||
|
||||
regions_input = ui.input(
|
||||
label='Regions (comma-separated)',
|
||||
value=adv_regions_value,
|
||||
).classes('w-full text-xs')
|
||||
|
||||
keys_input = ui.input(
|
||||
label='Allowed keys (empty = everyone on the channel)',
|
||||
value=adv_keys_value,
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# ── Save ─────────────────────────────────────────────────────
|
||||
def _save(
|
||||
cs=ch_select,
|
||||
ci=cats_input,
|
||||
ri=retention_input,
|
||||
rgi=regions_input,
|
||||
ki=keys_input,
|
||||
) -> None:
|
||||
idx = cs.value
|
||||
ch_name = ch_options.get(idx, f'Ch {idx}')
|
||||
categories = [
|
||||
c.strip().upper()
|
||||
for c in (ci.value or '').split(',') if c.strip()
|
||||
] or list(DEFAULT_CATEGORIES)
|
||||
try:
|
||||
ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS)
|
||||
except ValueError:
|
||||
ret_hours = DEFAULT_RETENTION_HOURS
|
||||
regions = [r.strip() for r in (rgi.value or '').split(',') if r.strip()]
|
||||
allowed_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()]
|
||||
|
||||
self._config_store.set_single_board(
|
||||
channel_idx=idx,
|
||||
channel_name=ch_name,
|
||||
categories=categories,
|
||||
retention_hours=ret_hours,
|
||||
regions=regions,
|
||||
allowed_keys=allowed_keys,
|
||||
)
|
||||
advanced_any = False
|
||||
for ch in self._device_channels:
|
||||
idx = ch.get('idx', ch.get('index', 0))
|
||||
board = self._config_store.get_board(f'ch{idx}')
|
||||
if board is not None:
|
||||
self._render_channel_advanced_row(ch, board)
|
||||
advanced_any = True
|
||||
if not advanced_any:
|
||||
ui.label(
|
||||
'Enable at least one channel to see advanced options.'
|
||||
).classes('text-xs text-gray-400 italic')
|
||||
debug_print(f'BBS settings: saved ch{idx} {ch_name}')
|
||||
ui.notify(f'BBS saved — {ch_name}.', type='positive')
|
||||
self._rebuild()
|
||||
|
||||
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2')
|
||||
|
||||
def _rebuild(self) -> None:
|
||||
"""Clear and re-render the settings container in-place."""
|
||||
if not self._boards_settings_container:
|
||||
if not self._container:
|
||||
return
|
||||
self._boards_settings_container.clear()
|
||||
with self._boards_settings_container:
|
||||
data = self._shared.get_snapshot()
|
||||
self._device_channels = data.get('channels', [])
|
||||
self._container.clear()
|
||||
with self._container:
|
||||
if not self._device_channels:
|
||||
ui.label('Connect device to see channels.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
else:
|
||||
self._render_all()
|
||||
|
||||
def _render_channel_settings_row(self, ch: Dict) -> None:
|
||||
"""Render the standard settings row for a single device channel.
|
||||
|
||||
Args:
|
||||
ch: Device channel dict with 'idx'/'index' and 'name' keys.
|
||||
"""
|
||||
idx = ch.get('idx', ch.get('index', 0))
|
||||
ch_name = ch.get('name', f'Ch {idx}')
|
||||
board_id = f'ch{idx}'
|
||||
board = self._config_store.get_board(board_id)
|
||||
|
||||
is_active = board is not None
|
||||
cats_value = ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES)
|
||||
retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS)
|
||||
|
||||
with ui.card().classes('w-full p-2'):
|
||||
with ui.row().classes('w-full items-center justify-between'):
|
||||
ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium')
|
||||
active_toggle = ui.toggle(
|
||||
{True: '● Active', False: '○ Off'},
|
||||
value=is_active,
|
||||
).classes('text-xs')
|
||||
|
||||
with ui.row().classes('w-full items-center gap-2 mt-1'):
|
||||
ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0')
|
||||
cats_input = ui.input(value=cats_value).classes('text-xs flex-grow')
|
||||
|
||||
with ui.row().classes('w-full items-center gap-2 mt-1'):
|
||||
ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0')
|
||||
retention_input = ui.input(value=retention_value).classes('text-xs').style(
|
||||
'max-width: 80px'
|
||||
)
|
||||
ui.label('hrs').classes('text-xs text-gray-600')
|
||||
|
||||
def _save(
|
||||
bid=board_id,
|
||||
bname=ch_name,
|
||||
bidx=idx,
|
||||
tog=active_toggle,
|
||||
ci=cats_input,
|
||||
ri=retention_input,
|
||||
) -> None:
|
||||
if tog.value:
|
||||
existing = self._config_store.get_board(bid)
|
||||
categories = [
|
||||
c.strip().upper()
|
||||
for c in (ci.value or '').split(',') if c.strip()
|
||||
] or list(DEFAULT_CATEGORIES)
|
||||
try:
|
||||
ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS)
|
||||
except ValueError:
|
||||
ret_hours = DEFAULT_RETENTION_HOURS
|
||||
extra_channels = (
|
||||
[c for c in existing.channels if c != bidx]
|
||||
if existing else []
|
||||
)
|
||||
updated = BbsBoard(
|
||||
id=bid,
|
||||
name=bname,
|
||||
channels=[bidx] + extra_channels,
|
||||
categories=categories,
|
||||
regions=existing.regions if existing else [],
|
||||
retention_hours=ret_hours,
|
||||
allowed_keys=existing.allowed_keys if existing else [],
|
||||
)
|
||||
self._config_store.set_board(updated)
|
||||
debug_print(f'BBS settings: channel {bid} saved')
|
||||
ui.notify(f'{bname} saved.', type='positive')
|
||||
else:
|
||||
self._config_store.delete_board(bid)
|
||||
debug_print(f'BBS settings: channel {bid} disabled')
|
||||
ui.notify(f'{bname} disabled.', type='warning')
|
||||
self._rebuild()
|
||||
|
||||
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1')
|
||||
|
||||
def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None:
|
||||
"""Render the advanced settings block for a single active channel.
|
||||
|
||||
Args:
|
||||
ch: Device channel dict.
|
||||
board: Existing BbsBoard for this channel.
|
||||
"""
|
||||
idx = ch.get('idx', ch.get('index', 0))
|
||||
ch_name = ch.get('name', f'Ch {idx}')
|
||||
board_id = f'ch{idx}'
|
||||
|
||||
with ui.column().classes('w-full gap-1 py-2'):
|
||||
ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium')
|
||||
|
||||
regions_input = ui.input(
|
||||
label='Regions (comma-separated)',
|
||||
value=', '.join(board.regions),
|
||||
).classes('w-full text-xs')
|
||||
|
||||
wl_input = ui.input(
|
||||
label='Allowed keys (empty = everyone on the channel)',
|
||||
value=', '.join(board.allowed_keys),
|
||||
).classes('w-full text-xs')
|
||||
|
||||
other_channels = [
|
||||
c for c in self._device_channels
|
||||
if c.get('idx', c.get('index', 0)) != idx
|
||||
]
|
||||
ch_checks: Dict[int, object] = {}
|
||||
if other_channels:
|
||||
ui.label('Combine with channels:').classes('text-xs text-gray-600 mt-1')
|
||||
with ui.row().classes('flex-wrap gap-2'):
|
||||
for other_ch in other_channels:
|
||||
other_idx = other_ch.get('idx', other_ch.get('index', 0))
|
||||
other_name = other_ch.get('name', f'Ch {other_idx}')
|
||||
cb = ui.checkbox(
|
||||
f'[{other_idx}] {other_name}',
|
||||
value=other_idx in board.channels,
|
||||
).classes('text-xs')
|
||||
ch_checks[other_idx] = cb
|
||||
|
||||
def _save_adv(
|
||||
bid=board_id,
|
||||
bidx=idx,
|
||||
bname=ch_name,
|
||||
ri=regions_input,
|
||||
wli=wl_input,
|
||||
cc=ch_checks,
|
||||
) -> None:
|
||||
existing = self._config_store.get_board(bid)
|
||||
if existing is None:
|
||||
ui.notify('Enable this channel first.', type='warning')
|
||||
return
|
||||
regions = [
|
||||
r.strip() for r in (ri.value or '').split(',') if r.strip()
|
||||
]
|
||||
allowed_keys = [
|
||||
k.strip() for k in (wli.value or '').split(',') if k.strip()
|
||||
]
|
||||
combined = [bidx] + [oidx for oidx, cb in cc.items() if cb.value]
|
||||
updated = BbsBoard(
|
||||
id=bid,
|
||||
name=bname,
|
||||
channels=combined,
|
||||
categories=existing.categories,
|
||||
regions=regions,
|
||||
retention_hours=existing.retention_hours,
|
||||
allowed_keys=allowed_keys,
|
||||
)
|
||||
self._config_store.set_board(updated)
|
||||
debug_print(f'BBS settings (advanced): {bid} saved')
|
||||
ui.notify(f'{bname} saved.', type='positive')
|
||||
self._rebuild()
|
||||
|
||||
ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1')
|
||||
ui.separator()
|
||||
self._render_settings()
|
||||
|
||||
@@ -4,12 +4,15 @@ BBS board configuration store for MeshCore GUI.
|
||||
Persists BBS board configuration to
|
||||
``~/.meshcore-gui/bbs/bbs_config.json``.
|
||||
|
||||
A **board** groups one or more MeshCore channel indices into a single
|
||||
bulletin board. Messages posted on any of the board's channels are
|
||||
visible in the board view. This supports two usage patterns:
|
||||
Design (v1.14.0 redesign)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
One node = one board. The settings UI exposes a single channel selector;
|
||||
the board id is always ``ch{channel_idx}`` and the name is taken from the
|
||||
device channel. There is no Create/Delete UI — the board is saved or
|
||||
cleared through :meth:`set_single_board` / :meth:`clear_single_board`.
|
||||
|
||||
- One board per channel (classic per-channel BBS)
|
||||
- One board spanning multiple channels (shared bulletin board)
|
||||
Multiple-board storage is retained internally so that the storage layer
|
||||
(``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged.
|
||||
|
||||
Config version history
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -300,3 +303,70 @@ class BbsConfigStore:
|
||||
"""
|
||||
with self._lock:
|
||||
return any(b.id == board_id for b in self._boards)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Single-board convenience API (v1.14.0 redesign)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_single_board(self) -> Optional[BbsBoard]:
|
||||
"""Return the one configured board, or ``None`` if none exists.
|
||||
|
||||
This is the primary accessor for the simplified single-board UI.
|
||||
|
||||
Returns:
|
||||
The first ``BbsBoard`` in the store, or ``None``.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._boards:
|
||||
return BbsBoard.from_dict(self._boards[0].to_dict())
|
||||
return None
|
||||
|
||||
def set_single_board(
|
||||
self,
|
||||
channel_idx: int,
|
||||
channel_name: str,
|
||||
categories: List[str],
|
||||
retention_hours: int = DEFAULT_RETENTION_HOURS,
|
||||
regions: Optional[List[str]] = None,
|
||||
allowed_keys: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Replace the single board with a fresh config derived from one channel.
|
||||
|
||||
The board id is always ``ch{channel_idx}`` and the board name is
|
||||
taken from *channel_name*. Any previously stored boards are
|
||||
discarded so the store always holds at most one board.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index to assign to this board.
|
||||
channel_name: Human-readable name of the channel (display only).
|
||||
categories: Category tag list.
|
||||
retention_hours: Message retention period in hours.
|
||||
regions: Optional region tags (``None`` → empty list).
|
||||
allowed_keys: Sender public key whitelist (``None`` → all allowed).
|
||||
"""
|
||||
board = BbsBoard(
|
||||
id=f"ch{channel_idx}",
|
||||
name=channel_name,
|
||||
channels=[channel_idx],
|
||||
categories=list(categories),
|
||||
regions=list(regions) if regions else [],
|
||||
retention_hours=retention_hours,
|
||||
allowed_keys=list(allowed_keys) if allowed_keys else [],
|
||||
)
|
||||
with self._lock:
|
||||
self._boards = [board]
|
||||
self._save_unlocked()
|
||||
debug_print(
|
||||
f"BBS config: single board set → ch{channel_idx} '{channel_name}'"
|
||||
)
|
||||
|
||||
def clear_single_board(self) -> None:
|
||||
"""Remove the configured board (disable BBS on this node).
|
||||
|
||||
After this call :meth:`get_single_board` returns ``None`` and
|
||||
the BBS command handler will not respond to any channel.
|
||||
"""
|
||||
with self._lock:
|
||||
self._boards = []
|
||||
self._save_unlocked()
|
||||
debug_print("BBS config: single board cleared")
|
||||
|
||||
@@ -281,11 +281,34 @@ class BbsService:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BbsCommandHandler:
|
||||
"""Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`.
|
||||
"""Parses BBS commands arriving as DMs and delegates to :class:`BbsService`.
|
||||
|
||||
Looks up the board for the incoming channel via ``BbsConfigStore``
|
||||
so that a single board spanning multiple channels handles commands
|
||||
from all of them.
|
||||
Entry point
|
||||
~~~~~~~~~~~
|
||||
All BBS commands arrive as **Direct Messages** addressed to the node's
|
||||
own public key. :meth:`handle_dm` is the sole public entry point and is
|
||||
called directly from
|
||||
:class:`~meshcore_gui.ble.events.EventHandler.on_contact_msg`.
|
||||
It is completely independent of :class:`~meshcore_gui.services.bot.MeshBot`.
|
||||
|
||||
Command syntax
|
||||
~~~~~~~~~~~~~~
|
||||
Both styles are accepted:
|
||||
|
||||
Short syntax::
|
||||
|
||||
!p [region] <abbrev> <text> — post a message
|
||||
!r [region] [abbrev] — read (5 most recent)
|
||||
|
||||
Full syntax::
|
||||
|
||||
!bbs post [region] <category> <text>
|
||||
!bbs read [region] [category]
|
||||
!bbs help
|
||||
|
||||
Category abbreviations are computed automatically as the shortest unique
|
||||
prefix per category within the configured list. ``!r`` and ``!bbs help``
|
||||
always include the abbreviation table in the reply.
|
||||
|
||||
Args:
|
||||
service: Shared ``BbsService`` instance.
|
||||
@@ -299,99 +322,179 @@ class BbsCommandHandler:
|
||||
self._config_store = config_store
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# Public entry point — called from EventHandler.on_contact_msg
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle(
|
||||
def handle_dm(
|
||||
self,
|
||||
channel_idx: int,
|
||||
sender: str,
|
||||
sender_key: str,
|
||||
text: str,
|
||||
) -> Optional[str]:
|
||||
"""Parse an incoming message and return a reply string (or ``None``).
|
||||
"""Parse a DM addressed to this node and return a reply (or ``None``).
|
||||
|
||||
This is the **only** entry point for BBS commands. It is called
|
||||
directly by ``EventHandler.on_contact_msg`` when a DM arrives whose
|
||||
text starts with ``!``. The bot is never involved.
|
||||
|
||||
The board is looked up from the single configured board via
|
||||
``BbsConfigStore.get_single_board()``.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index the message arrived on.
|
||||
sender: Display name of the sender.
|
||||
sender_key: Public key of the sender (hex string).
|
||||
text: Raw message text.
|
||||
sender: Display name of the DM sender.
|
||||
sender_key: Public key of the sender (hex string).
|
||||
text: Raw DM text.
|
||||
|
||||
Returns:
|
||||
Reply string, or ``None`` if no reply should be sent.
|
||||
Reply string to send back as DM, or ``None`` for silent drop.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text.lower().startswith("!bbs"):
|
||||
first = text.split()[0].lower() if text else ""
|
||||
if not first.startswith("!"):
|
||||
return None
|
||||
|
||||
board = self._config_store.get_board_for_channel(channel_idx)
|
||||
board = self._config_store.get_single_board()
|
||||
if board is None:
|
||||
debug_print("BBS: no board configured, ignoring DM")
|
||||
return None
|
||||
|
||||
# Whitelist check
|
||||
if board.allowed_keys and sender_key not in board.allowed_keys:
|
||||
debug_print(
|
||||
f"BBS: silently dropping msg from {sender} "
|
||||
f"BBS: silently dropping DM from {sender} "
|
||||
f"(key not in whitelist for board '{board.id}')"
|
||||
)
|
||||
return None
|
||||
|
||||
parts = text.split(None, 1)
|
||||
args = parts[1].strip() if len(parts) > 1 else ""
|
||||
return self._dispatch(board, channel_idx, sender, sender_key, args)
|
||||
# Channel for storing posted messages
|
||||
channel_idx = board.channels[0] if board.channels else 0
|
||||
|
||||
# Route by command prefix
|
||||
if first in ("!p",):
|
||||
rest = text[len(first):].strip()
|
||||
return self._handle_post_short(board, channel_idx, sender, sender_key, rest)
|
||||
|
||||
if first in ("!r",):
|
||||
rest = text[len(first):].strip()
|
||||
return self._handle_read_short(board, rest)
|
||||
|
||||
if first == "!bbs":
|
||||
parts = text.split(None, 2)
|
||||
sub = parts[1].lower() if len(parts) > 1 else ""
|
||||
rest = parts[2] if len(parts) > 2 else ""
|
||||
if sub == "post":
|
||||
return self._handle_post(board, channel_idx, sender, sender_key, rest)
|
||||
if sub == "read":
|
||||
return self._handle_read(board, rest)
|
||||
if sub == "help" or not sub:
|
||||
return self._handle_help(board)
|
||||
return f"Unknown subcommand '{sub}'. " + self._handle_help(board)
|
||||
|
||||
# Unknown !-command starting with something else
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# Abbreviation helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _dispatch(self, board, channel_idx, sender, sender_key, args):
|
||||
sub = args.split(None, 1)[0].lower() if args else ""
|
||||
rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else ""
|
||||
if sub == "post":
|
||||
return self._handle_post(board, channel_idx, sender, sender_key, rest)
|
||||
if sub == "read":
|
||||
return self._handle_read(board, rest)
|
||||
if sub == "help" or not sub:
|
||||
return self._handle_help(board)
|
||||
return f"Unknown command '{sub}'. {self._handle_help(board)}"
|
||||
@staticmethod
|
||||
def compute_abbreviations(categories: List[str]) -> Dict[str, str]:
|
||||
"""Compute shortest unique prefix for each category.
|
||||
|
||||
Returns a dict mapping ``abbrev.upper()`` → ``category``.
|
||||
|
||||
Examples::
|
||||
|
||||
["URGENT", "MEDICAL", "LOGISTICS", "STATUS", "GENERAL"]
|
||||
→ {"U": "URGENT", "M": "MEDICAL", "L": "LOGISTICS",
|
||||
"S": "STATUS", "G": "GENERAL"}
|
||||
|
||||
["MEDICAL", "MISSING"]
|
||||
→ {"ME": "MEDICAL", "MI": "MISSING"}
|
||||
"""
|
||||
abbrevs: Dict[str, str] = {}
|
||||
cats_upper = [c.upper() for c in categories]
|
||||
for cat in cats_upper:
|
||||
for length in range(1, len(cat) + 1):
|
||||
prefix = cat[:length]
|
||||
# Unique if no other category starts with this prefix
|
||||
if sum(1 for c in cats_upper if c.startswith(prefix)) == 1:
|
||||
abbrevs[prefix] = cat
|
||||
break
|
||||
return abbrevs
|
||||
|
||||
def _abbrev_table(self, categories: List[str]) -> str:
|
||||
"""Return a compact abbreviation table string, e.g. ``U=URGENT M=MEDICAL``."""
|
||||
abbrevs = self.compute_abbreviations(categories)
|
||||
# abbrevs maps prefix → full name; invert for display
|
||||
inv = {v: k for k, v in abbrevs.items()}
|
||||
return " ".join(f"{inv[c]}={c}" for c in [cu.upper() for cu in categories] if cu.upper() in inv)
|
||||
|
||||
def _resolve_category(self, token: str, categories: List[str]) -> Optional[str]:
|
||||
"""Resolve *token* to a category via exact match or abbreviation.
|
||||
|
||||
Returns the matching category string (original case from board
|
||||
config), or ``None`` if unresolvable.
|
||||
"""
|
||||
token_up = token.upper()
|
||||
cats_upper = [c.upper() for c in categories]
|
||||
|
||||
# Exact match first
|
||||
if token_up in cats_upper:
|
||||
return categories[cats_upper.index(token_up)]
|
||||
|
||||
# Abbreviation match
|
||||
abbrevs = self.compute_abbreviations(categories)
|
||||
if token_up in abbrevs:
|
||||
matched = abbrevs[token_up]
|
||||
return categories[cats_upper.index(matched)]
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_region(self, token: str, regions: List[str]) -> Optional[str]:
|
||||
"""Resolve *token* to a region via exact (case-insensitive) match."""
|
||||
token_up = token.upper()
|
||||
regs_upper = [r.upper() for r in regions]
|
||||
if token_up in regs_upper:
|
||||
return regions[regs_upper.index(token_up)]
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# post
|
||||
# Short syntax — !p and !r
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_post(self, board, channel_idx, sender, sender_key, args):
|
||||
def _handle_post_short(self, board, channel_idx, sender, sender_key, args):
|
||||
"""Handle ``!p [region] <abbrev> <text>``."""
|
||||
regions = board.regions
|
||||
categories = board.categories
|
||||
tokens = args.split(None, 2) if args else []
|
||||
|
||||
if regions:
|
||||
if len(tokens) < 3:
|
||||
return (
|
||||
f"Usage: !bbs post [region] [category] [text] | "
|
||||
f"Regions: {', '.join(regions)} | "
|
||||
f"Categories: {', '.join(categories)}"
|
||||
)
|
||||
region, category, text = tokens[0], tokens[1], tokens[2]
|
||||
valid_r = [r.upper() for r in regions]
|
||||
if region.upper() not in valid_r:
|
||||
return f"Invalid region '{region}'. Valid: {', '.join(regions)}"
|
||||
region = regions[valid_r.index(region.upper())]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if category.upper() not in valid_c:
|
||||
return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
|
||||
category = categories[valid_c.index(category.upper())]
|
||||
else:
|
||||
if len(tokens) < 2:
|
||||
return (
|
||||
f"Usage: !bbs post [category] [text] | "
|
||||
f"Categories: {', '.join(categories)}"
|
||||
)
|
||||
region = ""
|
||||
category, text = tokens[0], tokens[1]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if category.upper() not in valid_c:
|
||||
return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
|
||||
category = categories[valid_c.index(category.upper())]
|
||||
region = ""
|
||||
if regions and tokens:
|
||||
resolved_r = self._resolve_region(tokens[0], regions)
|
||||
if resolved_r:
|
||||
region = resolved_r
|
||||
tokens = tokens[1:] # consume region token
|
||||
|
||||
# Now tokens should be [abbrev, text]
|
||||
if len(tokens) < 2:
|
||||
abbr = self._abbrev_table(categories)
|
||||
return (
|
||||
f"Usage: !p [region] <cat> <text> | {abbr}"
|
||||
)
|
||||
|
||||
cat_token, text = tokens[0], tokens[1] if len(tokens) >= 2 else ""
|
||||
# Rebuild text in case split(None,2) on a shorter string
|
||||
if len(args.split(None, 2 if not region else 3)) > (2 if not region else 3):
|
||||
# re-split with region consumed
|
||||
pass
|
||||
|
||||
category = self._resolve_category(cat_token, categories)
|
||||
if category is None:
|
||||
abbr = self._abbrev_table(categories)
|
||||
return (
|
||||
f"Unknown category '{cat_token}'. Valid: {abbr}"
|
||||
)
|
||||
|
||||
msg = BbsMessage(
|
||||
channel=channel_idx,
|
||||
@@ -402,44 +505,107 @@ class BbsCommandHandler:
|
||||
region_label = f" [{region}]" if region else ""
|
||||
return f"Posted [{category}]{region_label}: {text[:60]}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# read
|
||||
# ------------------------------------------------------------------
|
||||
def _handle_read_short(self, board, args):
|
||||
"""Handle ``!r [region] [abbrev]``.
|
||||
|
||||
def _handle_read(self, board, args):
|
||||
With no arguments returns 5 most recent messages across all
|
||||
categories and always includes the abbreviation table.
|
||||
"""
|
||||
regions = board.regions
|
||||
categories = board.categories
|
||||
tokens = args.split() if args else []
|
||||
|
||||
region = None
|
||||
category = None
|
||||
|
||||
if regions:
|
||||
valid_r = [r.upper() for r in regions]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if tokens:
|
||||
if tokens[0].upper() in valid_r:
|
||||
region = regions[valid_r.index(tokens[0].upper())]
|
||||
if len(tokens) >= 2:
|
||||
if tokens[1].upper() in valid_c:
|
||||
category = categories[valid_c.index(tokens[1].upper())]
|
||||
else:
|
||||
return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}"
|
||||
else:
|
||||
return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}"
|
||||
else:
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if tokens:
|
||||
if tokens[0].upper() in valid_c:
|
||||
category = categories[valid_c.index(tokens[0].upper())]
|
||||
else:
|
||||
return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}"
|
||||
if tokens and regions:
|
||||
resolved_r = self._resolve_region(tokens[0], regions)
|
||||
if resolved_r:
|
||||
region = resolved_r
|
||||
tokens = tokens[1:]
|
||||
|
||||
if tokens:
|
||||
category = self._resolve_category(tokens[0], categories)
|
||||
if category is None:
|
||||
abbr = self._abbrev_table(categories)
|
||||
return f"Unknown category '{tokens[0]}'. Valid: {abbr}"
|
||||
|
||||
return self._format_messages(board, region, category, include_abbrevs=not args)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Full syntax — !bbs post / read
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_post(self, board, channel_idx, sender, sender_key, args):
|
||||
"""Handle ``!bbs post [region] <category> <text>``."""
|
||||
regions = board.regions
|
||||
categories = board.categories
|
||||
tokens = args.split(None, 2) if args else []
|
||||
|
||||
region = ""
|
||||
if regions and tokens:
|
||||
resolved_r = self._resolve_region(tokens[0], regions)
|
||||
if resolved_r:
|
||||
region = resolved_r
|
||||
tokens = tokens[1:]
|
||||
|
||||
if len(tokens) < 2:
|
||||
abbr = self._abbrev_table(categories)
|
||||
region_hint = f" [region]" if regions else ""
|
||||
return f"Usage: !bbs post{region_hint} <cat> <text> | {abbr}"
|
||||
|
||||
cat_token, text = tokens[0], tokens[1]
|
||||
category = self._resolve_category(cat_token, categories)
|
||||
if category is None:
|
||||
abbr = self._abbrev_table(categories)
|
||||
return f"Unknown category '{cat_token}'. Valid: {abbr}"
|
||||
|
||||
msg = BbsMessage(
|
||||
channel=channel_idx,
|
||||
region=region, category=category,
|
||||
sender=sender, sender_key=sender_key, text=text,
|
||||
)
|
||||
self._service.post_message(msg)
|
||||
region_label = f" [{region}]" if region else ""
|
||||
return f"Posted [{category}]{region_label}: {text[:60]}"
|
||||
|
||||
def _handle_read(self, board, args):
|
||||
"""Handle ``!bbs read [region] [category]``."""
|
||||
regions = board.regions
|
||||
categories = board.categories
|
||||
tokens = args.split() if args else []
|
||||
|
||||
region = None
|
||||
category = None
|
||||
|
||||
if tokens and regions:
|
||||
resolved_r = self._resolve_region(tokens[0], regions)
|
||||
if resolved_r:
|
||||
region = resolved_r
|
||||
tokens = tokens[1:]
|
||||
|
||||
if tokens:
|
||||
category = self._resolve_category(tokens[0], categories)
|
||||
if category is None:
|
||||
abbr = self._abbrev_table(categories)
|
||||
return f"Unknown category '{tokens[0]}'. Valid: {abbr}"
|
||||
|
||||
return self._format_messages(board, region, category, include_abbrevs=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Shared message formatter
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _format_messages(self, board, region, category, include_abbrevs: bool) -> str:
|
||||
messages = self._service.get_messages(
|
||||
board.channels, region=region, category=category, limit=self.READ_LIMIT,
|
||||
)
|
||||
if not messages:
|
||||
return "BBS: no messages found."
|
||||
lines = []
|
||||
if include_abbrevs:
|
||||
lines.append(self._handle_help(board))
|
||||
if not messages:
|
||||
lines.append("BBS: no messages found.")
|
||||
return "\n".join(lines)
|
||||
for m in messages:
|
||||
ts = m.timestamp[:16].replace("T", " ")
|
||||
region_label = f"[{m.region}] " if m.region else ""
|
||||
@@ -447,22 +613,13 @@ class BbsCommandHandler:
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# help
|
||||
# Help
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_help(self, board) -> str:
|
||||
cats = ", ".join(board.categories)
|
||||
abbr = self._abbrev_table(board.categories)
|
||||
header = f"BBS [{board.name}] | !p [cat] [text] | !r [cat]"
|
||||
if board.regions:
|
||||
regs = ", ".join(board.regions)
|
||||
return (
|
||||
f"BBS [{board.name}] | "
|
||||
f"!bbs post [region] [cat] [text] | "
|
||||
f"!bbs read [region] [cat] | "
|
||||
f"Regions: {regs} | Categories: {cats}"
|
||||
)
|
||||
return (
|
||||
f"BBS [{board.name}] | "
|
||||
f"!bbs post [cat] [text] | "
|
||||
f"!bbs read [cat] | "
|
||||
f"Categories: {cats}"
|
||||
)
|
||||
return f"{header} | Regions: {regs} | {abbr}"
|
||||
return f"{header} | {abbr}"
|
||||
|
||||
@@ -11,20 +11,19 @@ New keywords are added via ``BotConfig.keywords`` (data) without
|
||||
modifying the ``MeshBot`` class (code). Custom matching strategies
|
||||
can be implemented by subclassing and overriding ``_match_keyword``.
|
||||
|
||||
BBS integration
|
||||
~~~~~~~~~~~~~~~
|
||||
``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a
|
||||
:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one
|
||||
is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is
|
||||
``None`` (default), BBS routing is simply skipped.
|
||||
BBS separation
|
||||
~~~~~~~~~~~~~~
|
||||
BBS commands (``!bbs``, ``!p``, ``!r``) are handled by
|
||||
:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` which is
|
||||
wired directly into
|
||||
:class:`~meshcore_gui.ble.events.EventHandler`. They never pass
|
||||
through ``MeshBot`` — the bot is a pure keyword/channel-message
|
||||
responder only.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_gui.services.bbs_service import BbsCommandHandler
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
|
||||
@@ -91,13 +90,11 @@ class MeshBot:
|
||||
config: BotConfig,
|
||||
command_sink: Callable[[Dict], None],
|
||||
enabled_check: Callable[[], bool],
|
||||
bbs_handler: Optional["BbsCommandHandler"] = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._sink = command_sink
|
||||
self._enabled = enabled_check
|
||||
self._last_reply: float = 0.0
|
||||
self._bbs_handler = bbs_handler
|
||||
|
||||
def check_and_reply(
|
||||
self,
|
||||
@@ -108,7 +105,7 @@ class MeshBot:
|
||||
path_len: int,
|
||||
path_hashes: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Evaluate an incoming message and queue a reply if appropriate.
|
||||
"""Evaluate an incoming channel message and queue a reply if appropriate.
|
||||
|
||||
Guards (in order):
|
||||
1. Bot is enabled (checkbox in GUI).
|
||||
@@ -117,6 +114,10 @@ class MeshBot:
|
||||
4. Sender name does not end with ``'Bot'`` (prevent loops).
|
||||
5. Cooldown period has elapsed.
|
||||
6. Message text contains a recognised keyword.
|
||||
|
||||
Note: BBS commands (``!bbs``, ``!p``, ``!r``) are NOT handled here.
|
||||
They arrive as DMs and are handled by ``BbsCommandHandler`` directly
|
||||
inside ``EventHandler.on_contact_msg``.
|
||||
"""
|
||||
# Guard 1: enabled?
|
||||
if not self._enabled():
|
||||
@@ -141,27 +142,6 @@ class MeshBot:
|
||||
debug_print("BOT: cooldown active, skipping")
|
||||
return
|
||||
|
||||
# BBS routing: delegate !bbs commands to BbsCommandHandler
|
||||
if self._bbs_handler is not None:
|
||||
text_stripped = (text or "").strip()
|
||||
if text_stripped.lower().startswith("!bbs"):
|
||||
bbs_reply = self._bbs_handler.handle(
|
||||
channel_idx=channel_idx,
|
||||
sender=sender,
|
||||
sender_key="", # sender_key not available at this call-site
|
||||
text=text_stripped,
|
||||
)
|
||||
if bbs_reply is not None:
|
||||
self._last_reply = now
|
||||
self._sink({
|
||||
"action": "send_message",
|
||||
"channel": channel_idx,
|
||||
"text": bbs_reply,
|
||||
"_bot": True,
|
||||
})
|
||||
debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}")
|
||||
return # Do not fall through to keyword matching
|
||||
|
||||
# Guard 6: keyword match
|
||||
template = self._match_keyword(text)
|
||||
if template is None:
|
||||
|
||||
Reference in New Issue
Block a user