Files
pe1hvh 8836d9dd6e fix(bbs_service): resolve NameError in _abbrev_table that crashed !h and !help(#v1.14.1)
_abbrev_table used a list comprehension inline inside a generator
expression filter. In Python 3, list comprehensions have their own
scope, so the loop variable 'cu' was not visible to the outer 'if'
condition — causing a NameError on every !h / !help DM command.

Extract the comprehension to a local variable 'cats_upper' so both
the iteration and the filter operate on the same pre-built list.
2026-03-16 11:20:55 +01:00

868 lines
31 KiB
Python

"""
Offline Bulletin Board System (BBS) service for MeshCore GUI.
Stores BBS messages in a local SQLite database. Messages are keyed by
their originating MeshCore channel index. A **board** (see
:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or
more channel indices to a single bulletin board, so queries are always
issued as ``WHERE channel IN (...)``.
Architecture
~~~~~~~~~~~~
- ``BbsService`` -- persistence layer (SQLite, retention, queries).
- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and
delegates to ``BbsService``. Returns reply text.
Thread safety
~~~~~~~~~~~~~
SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by
multiple application instances (e.g. 800 MHz + 433 MHz on one Pi).
Storage
~~~~~~~
``~/.meshcore-gui/bbs/bbs_messages.db``
``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore)
"""
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
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 the message arrived on.
region: Region tag (empty string when board has no regions).
category: Category tag.
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.
"""
def __init__(self, db_path: Path = BBS_DB_PATH) -> None:
self._db_path = db_path
self._lock = threading.Lock()
self._init_db()
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("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=3000")
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 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 id={msg.id} ch={msg.channel} "
f"cat={msg.category} sender={msg.sender}"
)
return msg.id
# ------------------------------------------------------------------
# Read (channels is a list to support multi-channel boards)
# ------------------------------------------------------------------
def get_messages(
self,
channels: List[int],
region: Optional[str] = None,
category: Optional[str] = None,
limit: int = 5,
offset: int = 0,
) -> List[BbsMessage]:
"""Return messages for a set of channels, newest first.
Args:
channels: MeshCore channel indices to query (board's channel list).
region: Optional region filter (case-insensitive).
category: Optional category filter (case-insensitive).
limit: Maximum number of messages to return.
offset: Number of messages to skip (for pagination).
Returns:
List of ``BbsMessage`` objects, newest first.
"""
if not channels:
return []
placeholders = ",".join("?" * len(channels))
query = (
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
f"FROM bbs_messages WHERE channel IN ({placeholders})"
)
params: list = list(channels)
if region:
query += " AND region = ? COLLATE NOCASE"
params.append(region)
if category:
query += " AND category = ? COLLATE NOCASE"
params.append(category)
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
with self._lock:
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [self._row_to_msg(r) for r in rows]
def search_messages(
self,
channels: List[int],
text_query: str,
region: Optional[str] = None,
category: Optional[str] = None,
) -> List[BbsMessage]:
"""Full-text search across message bodies (case-insensitive substring).
Args:
channels: MeshCore channel indices to query.
text_query: Substring to search for (case-insensitive).
region: Optional region filter.
category: Optional category filter.
Returns:
All matching ``BbsMessage`` objects, newest first.
"""
if not channels or not text_query:
return []
placeholders = ",".join("?" * len(channels))
query = (
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
f"FROM bbs_messages WHERE channel IN ({placeholders})"
f" AND text LIKE ? COLLATE NOCASE"
)
params: list = list(channels) + [f"%{text_query}%"]
if region:
query += " AND region = ? COLLATE NOCASE"
params.append(region)
if category:
query += " AND category = ? COLLATE NOCASE"
params.append(category)
query += " ORDER BY timestamp DESC"
with self._lock:
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [self._row_to_msg(r) for r in rows]
def get_all_messages(
self,
channels: List[int],
region: Optional[str] = None,
category: Optional[str] = None,
) -> List[BbsMessage]:
"""Return all messages for a set of channels (oldest first).
Args:
channels: MeshCore channel indices to query.
region: Optional region filter.
category: Optional category filter.
Returns:
List of ``BbsMessage`` objects, oldest first.
"""
if not channels:
return []
placeholders = ",".join("?" * len(channels))
query = (
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
f"FROM bbs_messages WHERE channel IN ({placeholders})"
)
params: list = list(channels)
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 [self._row_to_msg(r) for r in rows]
@staticmethod
def _row_to_msg(row: tuple) -> BbsMessage:
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],
)
# ------------------------------------------------------------------
# Retention
# ------------------------------------------------------------------
def purge_expired(self, channels: List[int], retention_hours: int) -> int:
"""Delete messages older than *retention_hours* for a set of channels.
Args:
channels: MeshCore channel indices to purge.
retention_hours: Messages older than this are deleted.
Returns:
Number of rows deleted.
"""
if not channels:
return 0
cutoff = (
datetime.now(timezone.utc) - timedelta(hours=retention_hours)
).isoformat()
placeholders = ",".join("?" * len(channels))
with self._lock:
with self._connect() as conn:
cur = conn.execute(
f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?",
list(channels) + [cutoff],
)
conn.commit()
deleted = cur.rowcount
if deleted:
debug_print(
f"BBS: purged {deleted} expired messages from ch={channels}"
)
return deleted
def purge_all_expired(self, boards) -> None:
"""Run retention cleanup for all boards.
Args:
boards: Iterable of ``BbsBoard`` instances.
"""
for board in boards:
self.purge_expired(board.channels, board.retention_hours)
# ---------------------------------------------------------------------------
# Command handler
# ---------------------------------------------------------------------------
class BbsCommandHandler:
"""Parses BBS commands arriving as DMs and delegates to :class:`BbsService`.
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.
config_store: ``BbsConfigStore`` instance for live board config.
"""
READ_LIMIT: int = 5
def __init__(self, service: BbsService, config_store) -> None:
self._service = service
self._config_store = config_store
# ------------------------------------------------------------------
# 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.
Design rule:
- only ``!bbs`` is handled on the linked BBS channel;
- it whitelists the sender public key for later DM-BBS use;
- help/read/post/search commands are DM-only.
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
text = (text or "").strip()
if text.lower() != "!bbs":
return None
if not sender_key:
debug_print("BBS: !bbs ignored on channel because sender key is empty")
return "BBS whitelist failed: sender key unknown."
added = self._config_store.add_allowed_key(sender_key)
if added:
return "Add to BBS OK. Use !h in DM-BBS for help."
return "Already on BBS whitelist. Use !h in DM-BBS for help."
# ------------------------------------------------------------------
def handle_dm(
self,
sender: str,
sender_key: str,
text: str,
) -> Optional[str]:
"""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:
sender: Display name of the DM sender.
sender_key: Public key of the sender (hex string).
text: Raw DM text.
Returns:
Reply string to send back as DM, or ``None`` for silent drop.
"""
text = (text or "").strip()
first = text.split()[0].lower() if text else ""
if not first.startswith("!"):
return None
board = self._config_store.get_single_board()
if board is None:
debug_print("BBS: no board configured, ignoring DM")
return None
# Whitelist check (accept full-key/prefix matches in both directions)
if board.allowed_keys:
sender_key_up = (sender_key or "").upper()
allowed = False
for key in board.allowed_keys:
key_up = (key or "").upper()
if sender_key_up and key_up and (
sender_key_up == key_up
or sender_key_up.startswith(key_up)
or key_up.startswith(sender_key_up)
):
allowed = True
break
if not allowed:
debug_print(
f"BBS: silently dropping DM from {sender} "
f"(key not in whitelist for board '{board.id}')"
)
return None
# 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 in ("!s",):
rest = text[len(first):].strip()
return self._handle_search(board, rest)
if first in ("!help", "!h"):
return self._handle_help(board)
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":
return self._handle_help(board)
return "Use !bbs on the linked channel for whitelist bootstrap."
# Unknown !-command
return None
# ------------------------------------------------------------------
# Abbreviation helpers
# ------------------------------------------------------------------
@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()}
cats_upper = [c.upper() for c in categories]
return " ".join(f"{inv[c]}={c}" for c in cats_upper if c 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
# ------------------------------------------------------------------
# Short syntax — !p and !r
# ------------------------------------------------------------------
def _handle_post_short(self, board, channel_idx, sender, sender_key, args):
"""Handle ``!p [region] <abbrev> <text>``."""
regions = board.regions
categories = board.categories
# First token may be a region; split loosely to detect it
first_tokens = args.split(None, 1) if args else []
region = ""
remainder = args # everything after optional region
if regions and first_tokens:
resolved_r = self._resolve_region(first_tokens[0], regions)
if resolved_r:
region = resolved_r
remainder = first_tokens[1] if len(first_tokens) > 1 else ""
# Split remainder into exactly [category/abbrev, full_text]
cat_and_text = remainder.split(None, 1) if remainder else []
if len(cat_and_text) < 2:
abbr = self._abbrev_table(categories)
return f"Usage: !p [region] <cat> <text> | {abbr}"
cat_token, text = cat_and_text[0], cat_and_text[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_short(self, board, args):
"""Handle ``!r [region] [abbrev] [from-to]``.
Range syntax: ``!r U 6-10`` returns messages 6 to 10 (1-indexed,
newest first). Without a range the default is 1-5 (five most recent).
Bare ``!r`` returns the most recent messages across all categories.
"""
regions = board.regions
categories = board.categories
tokens = args.split() if args else []
region = None
category = None
offset = 0
limit = self.READ_LIMIT
# Optional region (only if board has regions configured)
if tokens and regions:
resolved_r = self._resolve_region(tokens[0], regions)
if resolved_r:
region = resolved_r
tokens = tokens[1:]
# Optional category / abbreviation
if tokens:
# Check if token is a range (e.g. "6-10") rather than a category
if not self._is_range(tokens[0]):
category = self._resolve_category(tokens[0], categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{tokens[0]}'. Valid: {abbr}"
tokens = tokens[1:]
# Optional range
if tokens and self._is_range(tokens[0]):
offset, limit = self._parse_range(tokens[0])
if offset is None:
return f"Invalid range '{tokens[0]}'. Use e.g. 6-10 or 1-20."
return self._format_messages(
board, region, category,
offset=offset, limit=limit,
include_abbrevs=False,
)
def _handle_search(self, board, args):
"""Handle ``!s [region] <abbrev> <query>`` — full-text search.
Searches all messages in the given category for the query string
(case-insensitive substring match) and returns all matches.
Usage: ``!s U zoektekst`` or ``!s Zwolle U zoektekst``
"""
regions = board.regions
categories = board.categories
first_tokens = args.split(None, 1) if args else []
region = None
remainder = args
# Optional region
if regions and first_tokens:
resolved_r = self._resolve_region(first_tokens[0], regions)
if resolved_r:
region = resolved_r
remainder = first_tokens[1] if len(first_tokens) > 1 else ""
# Category + query
cat_and_query = remainder.split(None, 1) if remainder else []
if len(cat_and_query) < 2:
abbr = self._abbrev_table(categories)
return f"Usage: !s [region] <cat> <query> | {abbr}"
cat_token, query = cat_and_query[0], cat_and_query[1].strip()
category = self._resolve_category(cat_token, categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{cat_token}'. Valid: {abbr}"
messages = self._service.search_messages(
board.channels,
text_query=query,
region=region,
category=category,
)
if not messages:
return f"BBS: no results for '{query}' in [{category}]."
lines = [f"Search [{category}] '{query}': {len(messages)} result(s)"]
for m in messages:
ts = m.timestamp[:16].replace("T", " ")
region_label = f"[{m.region}] " if m.region else ""
lines.append(f"{ts} {m.sender} {region_label}{m.text}")
return "\n".join(lines)
# ------------------------------------------------------------------
# Range helpers
# ------------------------------------------------------------------
@staticmethod
def _is_range(token: str) -> bool:
"""Return True if *token* looks like a range (e.g. ``6-10``)."""
parts = token.split("-")
if len(parts) != 2:
return False
return parts[0].isdigit() and parts[1].isdigit()
@staticmethod
def _parse_range(token: str):
"""Parse ``'from-to'`` into ``(offset, limit)``.
``'1-5'`` → offset=0, limit=5 (messages 1 to 5, 1-indexed newest first).
``'6-10'`` → offset=5, limit=5.
Returns ``(None, None)`` on invalid input.
"""
try:
parts = token.split("-")
start = int(parts[0])
end = int(parts[1])
if start < 1 or end < start:
return None, None
return start - 1, end - start + 1
except (ValueError, IndexError):
return None, None
# ------------------------------------------------------------------
# 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
# First token may be a region; split loosely to detect it
first_tokens = args.split(None, 1) if args else []
region = ""
remainder = args # everything after optional region
if regions and first_tokens:
resolved_r = self._resolve_region(first_tokens[0], regions)
if resolved_r:
region = resolved_r
remainder = first_tokens[1] if len(first_tokens) > 1 else ""
# Split remainder into exactly [category, full_text]
cat_and_text = remainder.split(None, 1) if remainder else []
if len(cat_and_text) < 2:
abbr = self._abbrev_table(categories)
region_hint = " [region]" if regions else ""
return f"Usage: !bbs post{region_hint} <cat> <text> | {abbr}"
cat_token, text = cat_and_text[0], cat_and_text[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] [from-to]``."""
regions = board.regions
categories = board.categories
tokens = args.split() if args else []
region = None
category = None
offset = 0
limit = self.READ_LIMIT
if tokens and regions:
resolved_r = self._resolve_region(tokens[0], regions)
if resolved_r:
region = resolved_r
tokens = tokens[1:]
if tokens and not self._is_range(tokens[0]):
category = self._resolve_category(tokens[0], categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{tokens[0]}'. Valid: {abbr}"
tokens = tokens[1:]
if tokens and self._is_range(tokens[0]):
offset, limit = self._parse_range(tokens[0])
if offset is None:
return f"Invalid range '{tokens[0]}'. Use e.g. 6-10."
return self._format_messages(
board, region, category,
offset=offset, limit=limit,
include_abbrevs=False,
)
# ------------------------------------------------------------------
# Shared message formatter
# ------------------------------------------------------------------
def _format_messages(
self,
board,
region,
category,
offset: int = 0,
limit: int = None,
include_abbrevs: bool = False,
) -> str:
if limit is None:
limit = self.READ_LIMIT
messages = self._service.get_messages(
board.channels,
region=region,
category=category,
limit=limit,
offset=offset,
)
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 ""
lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}")
return "\n".join(lines)
# ------------------------------------------------------------------
# Help
# ------------------------------------------------------------------
def _handle_help(self, board) -> str:
abbr = self._abbrev_table(board.categories)
header = (
f"BBS [{board.name}] | "
f"!p [cat] [text] | "
f"!r [cat] [1-5] | "
f"!s [cat] [query]"
)
if board.regions:
regs = ", ".join(board.regions)
return f"{header} | Regions: {regs} | {abbr}"
return f"{header} | {abbr}"