mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-03-28 17:42:38 +01:00
feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax(#v1.14.0)
Adds an offline BBS accessible via Direct Message to the node's own key. Access is channel-based: anyone seen on a configured BBS channel is automatically whitelisted for DM access. Channels stay clean. - Multi-channel configuration: any combination of device channels can be selected; senders on any of them are auto-whitelisted - Short syntax: !p <cat> <text> and !r [cat] alongside full !bbs syntax - Category abbreviations computed automatically (shortest unique prefix) - handle_channel_msg: bootstrap reply on channel + auto-whitelist sender - handle_dm: DM entry point, checks whitelist, routes to post/read/help - DM reply routed back to sender via command_sink - SQLite message store with WAL mode and configurable retention
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -35,18 +35,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
|
||||
### Added
|
||||
|
||||
- 🆕 **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.
|
||||
- **Toegangsmodel:** de beheerder selecteert één of meer channels in de settings. Iedereen die op een van die channels een bericht stuurt, wordt automatisch gewhitelist en kan daarna commando's sturen via **Direct Message** aan de node. Het channel blijft schoon; alleen de eerste interactie verloopt via het channel.
|
||||
- Korte syntax: `!p <cat> <tekst>` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`).
|
||||
- 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.
|
||||
- Optioneel regio-filter en handmatige allowed-keys override in Advanced.
|
||||
- Settings-pagina (`/bbs-settings`): checkboxes per channel, categorieën, retentie, Advanced voor regio's en handmatige keys.
|
||||
- Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode).
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔄 **`ble/events.py`** — DMs die beginnen met `!` worden direct verwerkt door `BbsCommandHandler`, volledig los van `MeshBot`.
|
||||
- 🔄 **`ble/events.py`** — `on_channel_msg` roept `BbsCommandHandler.handle_channel_msg()` aan op geconfigureerde BBS-channels: auto-whitelist + bootstrap reply. `on_contact_msg` stuurt `!`-DMs direct naar `handle_dm()`. Beide paden 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.
|
||||
- 🔄 **`services/bbs_config_store.py`** — `configure_board()` (multi-channel), `add_allowed_key()` (auto-whitelist), `clear_board()`.
|
||||
- 🔄 **`gui/dashboard.py`** — `BbsPanel` geregistreerd, `📋 BBS` drawer-item toegevoegd.
|
||||
|
||||
### Storage
|
||||
|
||||
87
README.md
87
README.md
@@ -1188,91 +1188,94 @@ meshcore-gui/
|
||||
|
||||
MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging.
|
||||
|
||||
### Design
|
||||
### Toegangsmodel
|
||||
|
||||
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.
|
||||
De beheerder koppelt één of meer channels aan het BBS. Iedereen die op zo'n channel een bericht stuurt wordt automatisch gewhitelist. Daarna kunnen zij commando's sturen via **Direct Message** aan de BBS-node — het channel zelf blijft schoon.
|
||||
|
||||
```
|
||||
User ──DM──▶ BBS node (public key)
|
||||
processes command
|
||||
User ◀──DM── reply (only visible to sender)
|
||||
Eerste contact: !bbs help op het channel
|
||||
→ node ziet de public key → whitelist
|
||||
Daarna: !p U hulp nodig als DM naar de node
|
||||
→ verwerkt, reply via DM terug
|
||||
```
|
||||
|
||||
Channel commands remain available as a fallback, but DM is the primary interface.
|
||||
Wie nooit iets heeft gestuurd op een geconfigureerd channel staat niet op de whitelist en wordt silently genegeerd.
|
||||
|
||||
### Settings
|
||||
|
||||
Open the BBS settings via the gear icon (⚙) in the BBS panel, or navigate to `/bbs-settings`.
|
||||
Open via het tandwiel (⚙) in het BBS-panel, of navigeer naar `/bbs-settings`.
|
||||
|
||||
```
|
||||
BBS Settings
|
||||
─────────────────────────────────────────────
|
||||
Channel: [2] NoodNet Zwolle ▼
|
||||
──────────────────────────────────────────
|
||||
Channels: ☑ [1] NoodNet Zwolle
|
||||
☑ [2] NoodNet Dalfsen
|
||||
☐ [3] NoodNet OV
|
||||
Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL
|
||||
Retain: 48 hours
|
||||
[Save]
|
||||
|
||||
▶ Advanced
|
||||
Regions (comma-separated)
|
||||
Allowed keys (empty = everyone on the channel)
|
||||
Allowed keys (leeg = auto-geleerd via channel-activiteit)
|
||||
```
|
||||
|
||||
- **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.
|
||||
- **Channels** — vink alle channels aan waarvan deelnemers toegang krijgen tot het BBS.
|
||||
- **Categories** — komma-gescheiden lijst van geldige categorie-tags.
|
||||
- **Retain** — berichtretentie in uren (standaard 48).
|
||||
- **Advanced → Regions** — optionele regio-tags voor geografische filtering.
|
||||
- **Advanced → Allowed keys** — handmatige whitelist-override; leeg laten om alleen automatisch te leren.
|
||||
|
||||
### Command syntax
|
||||
### Commando-syntax
|
||||
|
||||
#### Short syntax
|
||||
#### Korte syntax
|
||||
|
||||
| Command | Description |
|
||||
| Commando | Beschrijving |
|
||||
|---|---|
|
||||
| `!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 |
|
||||
| `!p <cat> <tekst>` | Bericht posten |
|
||||
| `!p <regio> <cat> <tekst>` | Posten met regio |
|
||||
| `!r` | Laatste 5 berichten lezen (alle categorieën) |
|
||||
| `!r <cat>` | Lezen gefilterd op categorie |
|
||||
| `!r <regio> <cat>` | Lezen gefilterd op regio en categorie |
|
||||
|
||||
Category abbreviations are computed automatically as the shortest unique prefix within the configured category list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`:
|
||||
Categorie-afkortingen worden automatisch berekend als de kortste unieke prefix. Voorbeeld met `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.
|
||||
`!r` zonder argumenten en `!bbs help` geven altijd de afkortingstabel mee.
|
||||
|
||||
#### Full syntax
|
||||
#### Volledige syntax
|
||||
|
||||
| Command | Description |
|
||||
| Commando | Beschrijving |
|
||||
|---|---|
|
||||
| `!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 |
|
||||
| `!bbs help` | Toon commando's en afkortingstabel |
|
||||
| `!bbs post <category> <tekst>` | Bericht posten |
|
||||
| `!bbs post <regio> <category> <tekst>` | Posten met regio |
|
||||
| `!bbs read` | Laatste 5 berichten |
|
||||
| `!bbs read <category>` | Gefilterd op categorie |
|
||||
| `!bbs read <regio> <category>` | Gefilterd op regio en categorie |
|
||||
|
||||
#### Example help reply
|
||||
#### Voorbeeld help-reply
|
||||
|
||||
```
|
||||
BBS [NoodNet Zwolle] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
|
||||
BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
|
||||
```
|
||||
|
||||
### Error handling
|
||||
### Foutafhandeling
|
||||
|
||||
| Situation | Reply |
|
||||
| Situatie | Reply |
|
||||
|---|---|
|
||||
| Unknown category | Lists valid categories and abbreviations |
|
||||
| Ambiguous abbreviation | Lists all matching categories |
|
||||
| Sender not on whitelist | Silent drop — no reply |
|
||||
| Onbekende categorie | Lijst met geldige categorieën en afkortingen |
|
||||
| Ambigue afkorting | Lijst met overeenkomende categorieën |
|
||||
| Sender niet op whitelist | Silent drop — geen reply |
|
||||
|
||||
### Storage
|
||||
|
||||
```
|
||||
~/.meshcore-gui/bbs/bbs_messages.db — SQLite message store (WAL mode)
|
||||
~/.meshcore-gui/bbs/bbs_config.json — Board configuration (v2 format)
|
||||
~/.meshcore-gui/bbs/bbs_messages.db — SQLite berichtenopslag (WAL-mode)
|
||||
~/.meshcore-gui/bbs/bbs_config.json — Board-configuratie
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -301,6 +301,23 @@ class EventHandler:
|
||||
message_hash=msg_hash,
|
||||
))
|
||||
|
||||
# BBS channel hook: auto-whitelist sender + bootstrap reply for !-commands.
|
||||
# Runs on every message on a configured BBS channel, independent of the bot.
|
||||
if self._bbs_handler is not None and self._command_sink is not None:
|
||||
bbs_reply = self._bbs_handler.handle_channel_msg(
|
||||
channel_idx=ch_idx,
|
||||
sender=sender,
|
||||
sender_key=sender_pubkey,
|
||||
text=msg_text,
|
||||
)
|
||||
if bbs_reply is not None:
|
||||
debug_print(f"BBS channel reply on ch{ch_idx} to {sender!r}: {bbs_reply[:60]}")
|
||||
self._command_sink({
|
||||
"action": "send_message",
|
||||
"channel": ch_idx,
|
||||
"text": bbs_reply,
|
||||
})
|
||||
|
||||
self._bot.check_and_reply(
|
||||
sender=sender,
|
||||
text=msg_text,
|
||||
|
||||
@@ -427,20 +427,9 @@ class BbsSettingsPage:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_settings(self) -> None:
|
||||
"""Render the single-board settings block."""
|
||||
"""Render the board settings block."""
|
||||
board = self._config_store.get_single_board()
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
current_idx = (
|
||||
board.channels[0] if board and board.channels
|
||||
else next(iter(ch_options), 0)
|
||||
)
|
||||
active_channels = set(board.channels) if board else set()
|
||||
cats_value = (
|
||||
', '.join(board.categories) if board
|
||||
else ', '.join(DEFAULT_CATEGORIES)
|
||||
@@ -452,25 +441,31 @@ class BbsSettingsPage:
|
||||
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')
|
||||
# ── Channel checkboxes ───────────────────────────────────────
|
||||
ch_checks: Dict[int, object] = {}
|
||||
with ui.column().classes('w-full gap-1'):
|
||||
ui.label('Channels:').classes('text-xs text-gray-600')
|
||||
with ui.column().classes('w-full gap-1 pl-2'):
|
||||
for ch in self._device_channels:
|
||||
idx = ch.get('idx', ch.get('index', 0))
|
||||
name = ch.get('name', f'Ch {idx}')
|
||||
cb = ui.checkbox(
|
||||
f'[{idx}] {name}',
|
||||
value=idx in active_channels,
|
||||
).classes('text-xs')
|
||||
ch_checks[idx] = cb
|
||||
|
||||
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')
|
||||
# ── Categories + retention ───────────────────────────────────
|
||||
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'):
|
||||
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')
|
||||
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'):
|
||||
@@ -482,20 +477,27 @@ class BbsSettingsPage:
|
||||
).classes('w-full text-xs')
|
||||
|
||||
keys_input = ui.input(
|
||||
label='Allowed keys (empty = everyone on the channel)',
|
||||
label='Allowed keys (empty = auto-learned from channel activity)',
|
||||
value=adv_keys_value,
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# ── Save ─────────────────────────────────────────────────────
|
||||
def _save(
|
||||
cs=ch_select,
|
||||
cc=ch_checks,
|
||||
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}')
|
||||
selected = [idx for idx, cb in cc.items() if cb.value]
|
||||
if not selected:
|
||||
ui.notify('Select at least one channel.', type='warning')
|
||||
return
|
||||
|
||||
ch_names = {
|
||||
ch.get('idx', ch.get('index', 0)): ch.get('name', '?')
|
||||
for ch in self._device_channels
|
||||
}
|
||||
categories = [
|
||||
c.strip().upper()
|
||||
for c in (ci.value or '').split(',') if c.strip()
|
||||
@@ -505,18 +507,22 @@ class BbsSettingsPage:
|
||||
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()]
|
||||
# Only pass allowed_keys if the field was explicitly filled;
|
||||
# empty field means "keep auto-learned keys"
|
||||
raw_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()]
|
||||
allowed_keys = raw_keys if raw_keys else None
|
||||
|
||||
self._config_store.set_single_board(
|
||||
channel_idx=idx,
|
||||
channel_name=ch_name,
|
||||
self._config_store.configure_board(
|
||||
channel_indices=selected,
|
||||
channel_names=ch_names,
|
||||
categories=categories,
|
||||
retention_hours=ret_hours,
|
||||
regions=regions,
|
||||
allowed_keys=allowed_keys,
|
||||
)
|
||||
debug_print(f'BBS settings: saved ch{idx} {ch_name}')
|
||||
ui.notify(f'BBS saved — {ch_name}.', type='positive')
|
||||
ch_labels = ', '.join(f"[{i}] {ch_names.get(i, '?')}" for i in sorted(selected))
|
||||
debug_print(f'BBS settings: configured channels {ch_labels}')
|
||||
ui.notify(f'BBS saved — {ch_labels}.', type='positive')
|
||||
self._rebuild()
|
||||
|
||||
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2')
|
||||
|
||||
@@ -9,7 +9,7 @@ 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`.
|
||||
cleared through :meth:`configure_board` / :meth:`clear_board`.
|
||||
|
||||
Multiple-board storage is retained internally so that the storage layer
|
||||
(``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged.
|
||||
@@ -305,13 +305,11 @@ class BbsConfigStore:
|
||||
return any(b.id == board_id for b in self._boards)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Single-board convenience API (v1.14.0 redesign)
|
||||
# Board 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.
|
||||
"""Return the configured board, or ``None`` if none exists.
|
||||
|
||||
Returns:
|
||||
The first ``BbsBoard`` in the store, or ``None``.
|
||||
@@ -321,52 +319,89 @@ class BbsConfigStore:
|
||||
return BbsBoard.from_dict(self._boards[0].to_dict())
|
||||
return None
|
||||
|
||||
def set_single_board(
|
||||
def configure_board(
|
||||
self,
|
||||
channel_idx: int,
|
||||
channel_name: str,
|
||||
channel_indices: List[int],
|
||||
channel_names: Dict[int, 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.
|
||||
"""Save the board configuration.
|
||||
|
||||
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.
|
||||
Multiple channels can be assigned. Every sender seen on any of
|
||||
these channels is automatically eligible for DM access (the
|
||||
worker calls :meth:`add_allowed_key` when it sees them).
|
||||
|
||||
The board id is always ``'bbs_board'``. The board name is built
|
||||
from the channel names in *channel_names*.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index to assign to this board.
|
||||
channel_name: Human-readable name of the channel (display only).
|
||||
channel_indices: MeshCore channel indices to assign.
|
||||
channel_names: Mapping ``idx → display name`` for labelling.
|
||||
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).
|
||||
regions: Optional region tags.
|
||||
allowed_keys: Manual sender key whitelist seed (auto-learned
|
||||
keys are added via :meth:`add_allowed_key`).
|
||||
"""
|
||||
name = ", ".join(
|
||||
channel_names.get(i, f"Ch {i}") for i in sorted(channel_indices)
|
||||
) or "BBS"
|
||||
|
||||
# Preserve existing auto-learned keys unless caller supplies a new list
|
||||
existing = self.get_single_board()
|
||||
merged_keys = list(allowed_keys) if allowed_keys is not None else (
|
||||
existing.allowed_keys if existing else []
|
||||
)
|
||||
|
||||
board = BbsBoard(
|
||||
id=f"ch{channel_idx}",
|
||||
name=channel_name,
|
||||
channels=[channel_idx],
|
||||
id="bbs_board",
|
||||
name=name,
|
||||
channels=sorted(channel_indices),
|
||||
categories=list(categories),
|
||||
regions=list(regions) if regions else [],
|
||||
retention_hours=retention_hours,
|
||||
allowed_keys=list(allowed_keys) if allowed_keys else [],
|
||||
allowed_keys=merged_keys,
|
||||
)
|
||||
with self._lock:
|
||||
self._boards = [board]
|
||||
self._save_unlocked()
|
||||
debug_print(
|
||||
f"BBS config: single board set → ch{channel_idx} '{channel_name}'"
|
||||
f"BBS config: board configured → channels={sorted(channel_indices)} "
|
||||
f"name='{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.
|
||||
"""
|
||||
def clear_board(self) -> None:
|
||||
"""Remove the configured board (disable BBS on this node)."""
|
||||
with self._lock:
|
||||
self._boards = []
|
||||
self._save_unlocked()
|
||||
debug_print("BBS config: single board cleared")
|
||||
debug_print("BBS config: board cleared")
|
||||
|
||||
def add_allowed_key(self, sender_key: str) -> bool:
|
||||
"""Add *sender_key* to the board's allowed_keys whitelist.
|
||||
|
||||
Called automatically by the worker whenever a sender is seen on
|
||||
a configured BBS channel. No-op if the key is already present
|
||||
or if no board is configured.
|
||||
|
||||
Args:
|
||||
sender_key: Public key hex string of the sender.
|
||||
|
||||
Returns:
|
||||
``True`` if the key was newly added, ``False`` otherwise.
|
||||
"""
|
||||
if not sender_key:
|
||||
return False
|
||||
with self._lock:
|
||||
if not self._boards:
|
||||
return False
|
||||
board = self._boards[0]
|
||||
if sender_key in board.allowed_keys:
|
||||
return False
|
||||
board.allowed_keys.append(sender_key)
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: auto-whitelisted key {sender_key[:12]}…")
|
||||
return True
|
||||
|
||||
@@ -322,7 +322,76 @@ class BbsCommandHandler:
|
||||
self._config_store = config_store
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point — called from EventHandler.on_contact_msg
|
||||
# Public entry points
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_channel_msg(
|
||||
self,
|
||||
channel_idx: int,
|
||||
sender: str,
|
||||
sender_key: str,
|
||||
text: str,
|
||||
) -> Optional[str]:
|
||||
"""Handle a channel message on a configured BBS channel.
|
||||
|
||||
Called from ``EventHandler.on_channel_msg`` **after** the message
|
||||
has been stored. Two responsibilities:
|
||||
|
||||
1. **Auto-whitelist**: every sender seen on a BBS channel gets their
|
||||
key added to ``allowed_keys`` so they can use DMs afterwards.
|
||||
2. **Bootstrap reply**: if the message starts with ``!``, reply on
|
||||
the channel so the sender knows the BBS is active and receives
|
||||
the abbreviation table.
|
||||
|
||||
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 to post on the channel, or ``None``.
|
||||
"""
|
||||
board = self._config_store.get_single_board()
|
||||
if board is None:
|
||||
return None
|
||||
if channel_idx not in board.channels:
|
||||
return None
|
||||
|
||||
# Auto-whitelist: register this sender so they can use DMs
|
||||
if sender_key:
|
||||
self._config_store.add_allowed_key(sender_key)
|
||||
|
||||
# Bootstrap reply only for !-commands
|
||||
text = (text or "").strip()
|
||||
if not text.startswith("!"):
|
||||
return None
|
||||
|
||||
first = text.split()[0].lower()
|
||||
channel_for_post = channel_idx
|
||||
|
||||
if first == "!p":
|
||||
rest = text[len(first):].strip()
|
||||
return self._handle_post_short(board, channel_for_post, sender, sender_key, rest)
|
||||
|
||||
if first == "!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_for_post, 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)
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_dm(
|
||||
|
||||
Reference in New Issue
Block a user