mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-03-28 17:42:38 +01:00
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:
25
CHANGELOG.md
25
CHANGELOG.md
@@ -30,6 +30,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
|
||||
> lower CPU usage during idle operation, and more stable map rendering.
|
||||
|
||||
---
|
||||
## [1.14.0] - 2026-03-14 — Offline BBS (Bulletin Board System)
|
||||
|
||||
### Added
|
||||
- 🆕 **`meshcore_gui/services/bbs_service.py`** — SQLite-backed BBS persistence layer.
|
||||
- `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp.
|
||||
- `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. Database at `~/.meshcore-gui/bbs/bbs_messages.db`.
|
||||
- `BbsCommandHandler`: parses `!bbs post`, `!bbs read`, `!bbs help` mesh commands. Whitelist enforcement (silent drop on unknown sender key). Per-channel region/category validation with error reply.
|
||||
- 🆕 **`meshcore_gui/gui/panels/bbs_panel.py`** — BBS panel for the dashboard.
|
||||
- Channel selector (NoodNet Zwolle / NoodNet OV / Dalfsen).
|
||||
- Region filter (shown only when the active channel has regions configured).
|
||||
- Category filter (all or specific).
|
||||
- Scrollable message list with timestamp, sender, category and optional region tag.
|
||||
- Post form: region select (conditional), category select, text input, Send button.
|
||||
- Send broadcasts `!bbs post …` on the mesh channel so other nodes receive it.
|
||||
- 🔄 **`meshcore_gui/config.py`** — `BBS_CHANNELS` configuration block added; version bumped to `1.14.0`.
|
||||
- 🔄 **`meshcore_gui/services/bot.py`** — `MeshBot` accepts optional `bbs_handler` parameter. Incoming `!bbs` messages are routed to `BbsCommandHandler` before keyword matching; replies are sent on the originating channel.
|
||||
- 🔄 **`meshcore_gui/gui/dashboard.py`** — `BbsPanel` registered as standalone panel `'bbs'`; menu item `📋 BBS` added to the drawer.
|
||||
- 🔄 **`meshcore_gui/gui/panels/__init__.py`** — `BbsPanel` re-exported.
|
||||
|
||||
### Not changed
|
||||
- BLE layer, SharedData, core/models, route_page, map_panel, message_archive, all other services.
|
||||
- All existing bot keyword behaviour, room server flow, archive page, contacts, map, device, actions, rxlog panels.
|
||||
|
||||
---
|
||||
|
||||
## [1.13.5] - 2026-03-14 — Route back-button and map popup flicker fixes
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
310
meshcore_gui/gui/panels/bbs_panel.py
Normal file
310
meshcore_gui/gui/panels/bbs_panel.py
Normal 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.
|
||||
610
meshcore_gui/services/bbs_service.py
Normal file
610
meshcore_gui/services/bbs_service.py
Normal 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)}"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user