mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-07-30 07:19:48 +00:00
- Updated `config.ini.example`, `config.ini.minimal-example`, and `config.ini.quickstart` to clarify connection type precedence and required keys for serial, BLE, and TCP connections. - Added detailed comments and examples for each connection type, ensuring users understand how to configure their connection settings effectively. - Introduced a new `[Service_Overrides]` section in `config.ini.example` for alternative service plugin implementations, improving extensibility. - Enhanced documentation in `configuration.md` to reflect these changes and provide clearer guidance on connection options and templates.
463 lines
16 KiB
Python
463 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Machine-readable config schema for meshcore-bot.
|
|
|
|
Provides section/key metadata used by config validation and kept in sync with
|
|
config.ini.example. Grow incrementally as new options are added.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from modules.config_validation import (
|
|
CANONICAL_NON_COMMAND_SECTIONS,
|
|
REQUIRED_SECTIONS,
|
|
SEVERITY_INFO,
|
|
SEVERITY_WARNING,
|
|
)
|
|
|
|
# Sections where any user-defined key is valid (no fixed schema).
|
|
DYNAMIC_KEY_SECTIONS = frozenset({
|
|
"Keywords",
|
|
"Custom_Syntax",
|
|
"RandomLine",
|
|
"Scheduled_Messages",
|
|
"Channels_List",
|
|
"Plugin_Overrides",
|
|
"Service_Overrides",
|
|
"Rate_Limits",
|
|
"Banned_Users",
|
|
"Admin_ACL",
|
|
})
|
|
|
|
# Legacy section names still accepted at runtime.
|
|
DEPRECATED_SECTIONS = frozenset({"Jokes"})
|
|
|
|
# Legacy key suffixes (e.g. alert_enabled instead of enabled in *_Command sections).
|
|
LEGACY_ENABLED_KEY_RE = re.compile(r"^[a-z]+_enabled$")
|
|
|
|
# Keys supported on every *_Command section via BaseCommand (config.ini.example may omit them).
|
|
STANDARD_COMMAND_KEYS = frozenset({
|
|
"enabled",
|
|
"channels",
|
|
"aliases",
|
|
"cooldown_queue_threshold_seconds",
|
|
"require_path_bytes_greater_or_equal_to",
|
|
"require_path_bytes_failure_response",
|
|
})
|
|
|
|
# Keys supported on service plugin sections via BaseServicePlugin.
|
|
STANDARD_SERVICE_KEYS = frozenset({
|
|
"enabled",
|
|
"flood_scope",
|
|
"discord_webhook_urls",
|
|
"telegram_chat_ids",
|
|
"telegram_bot_token",
|
|
"silence_mesh_output",
|
|
})
|
|
|
|
# Mesh channel → external target mappings in bridge service sections.
|
|
BRIDGE_SECTIONS = frozenset({"DiscordBridge", "TelegramBridge"})
|
|
BRIDGE_CHANNEL_KEY_PREFIX = "bridge."
|
|
|
|
# User-defined announcement triggers in Announcements_Command.
|
|
ANNOUNCEMENTS_TRIGGER_KEY_PREFIX = "announce."
|
|
|
|
# PulsePoint agency mappings in Alert_Command (city, county, and legacy formats).
|
|
ALERT_AGENCY_KEY_PREFIXES = ("agency.", "agency_")
|
|
|
|
_VALID_CONFIG_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
|
|
|
|
@dataclass
|
|
class KeyMeta:
|
|
"""Metadata for a single config key."""
|
|
|
|
type: str = "string"
|
|
values: Optional[tuple[str, ...]] = None
|
|
default: Optional[str] = None
|
|
required: bool = False
|
|
required_when: Optional[dict[str, str]] = None
|
|
deprecated: bool = False
|
|
deprecated_message: str = ""
|
|
|
|
|
|
@dataclass
|
|
class SectionMeta:
|
|
"""Metadata for a config section."""
|
|
|
|
keys: dict[str, KeyMeta] = field(default_factory=dict)
|
|
dynamic_keys: bool = False
|
|
|
|
|
|
# Incremental schema — expand as features are added.
|
|
SECTIONS: dict[str, SectionMeta] = {
|
|
"Connection": SectionMeta(keys={
|
|
"connection_type": KeyMeta(
|
|
type="enum",
|
|
values=("serial", "ble", "tcp"),
|
|
required=True,
|
|
),
|
|
"serial_port": KeyMeta(required_when={"connection_type": "serial"}),
|
|
"ble_device_name": KeyMeta(),
|
|
"hostname": KeyMeta(required_when={"connection_type": "tcp"}),
|
|
"tcp_port": KeyMeta(type="int", default="5000"),
|
|
"timeout": KeyMeta(type="int"),
|
|
"radio_debug": KeyMeta(type="bool"),
|
|
"reconnect_max_retries": KeyMeta(type="int"),
|
|
"reconnect_delay_seconds": KeyMeta(type="int"),
|
|
"reconnect_max_delay_seconds": KeyMeta(type="int"),
|
|
"command_min_interval_ms": KeyMeta(type="int"),
|
|
"channel_fetch_interval_ms": KeyMeta(type="int"),
|
|
}),
|
|
"Bot": SectionMeta(keys={
|
|
"bot_name": KeyMeta(required=True),
|
|
"enabled": KeyMeta(type="bool"),
|
|
"db_path": KeyMeta(),
|
|
"local_dir_path": KeyMeta(),
|
|
}),
|
|
"Channels": SectionMeta(keys={
|
|
"monitor_channels": KeyMeta(required=True),
|
|
"respond_to_dms": KeyMeta(type="bool"),
|
|
"max_response_hops": KeyMeta(type="int"),
|
|
"channel_keywords": KeyMeta(),
|
|
"flood_scopes": KeyMeta(),
|
|
"outgoing_flood_scope_override": KeyMeta(),
|
|
}),
|
|
"Web_Viewer": SectionMeta(keys={
|
|
"enabled": KeyMeta(type="bool"),
|
|
"host": KeyMeta(),
|
|
"port": KeyMeta(type="int"),
|
|
"web_viewer_password": KeyMeta(),
|
|
"auto_start": KeyMeta(type="bool"),
|
|
"debug": KeyMeta(type="bool"),
|
|
}),
|
|
"Webhook": SectionMeta(keys={
|
|
"enabled": KeyMeta(type="bool"),
|
|
"host": KeyMeta(),
|
|
"port": KeyMeta(type="int"),
|
|
"secret_token": KeyMeta(),
|
|
}),
|
|
"Feed_Manager": SectionMeta(keys={
|
|
"feed_manager_enabled": KeyMeta(type="bool"),
|
|
"allow_private_urls": KeyMeta(type="bool"),
|
|
}),
|
|
"Weather_Service": SectionMeta(keys={
|
|
"enabled": KeyMeta(type="bool"),
|
|
"rain_nowcast_cache_seconds": KeyMeta(type="int", default="300"),
|
|
}),
|
|
"RepeaterPrefixCollision_Service": SectionMeta(keys={
|
|
"enabled": KeyMeta(type="bool"),
|
|
"channels": KeyMeta(),
|
|
"notify_external_on_all_new_repeaters": KeyMeta(type="bool"),
|
|
"silence_mesh_output": KeyMeta(type="bool"),
|
|
"discord_webhook_urls": KeyMeta(),
|
|
"telegram_chat_ids": KeyMeta(),
|
|
"telegram_bot_token": KeyMeta(),
|
|
}),
|
|
"Greeter_Command": SectionMeta(keys={
|
|
"enabled": KeyMeta(type="bool"),
|
|
"dead_air_delay_seconds": KeyMeta(type="int", default="0"),
|
|
"defer_to_human_greeting": KeyMeta(type="bool"),
|
|
"levenshtein_distance": KeyMeta(type="int", default="0"),
|
|
}),
|
|
"Announcements_Command": SectionMeta(dynamic_keys=True),
|
|
"Alert_Command": SectionMeta(dynamic_keys=True),
|
|
}
|
|
|
|
for _section in DYNAMIC_KEY_SECTIONS:
|
|
if _section not in SECTIONS:
|
|
SECTIONS[_section] = SectionMeta(dynamic_keys=True)
|
|
|
|
for _section in CANONICAL_NON_COMMAND_SECTIONS:
|
|
SECTIONS.setdefault(_section, SectionMeta())
|
|
|
|
|
|
def _parse_key_value_line(stripped: str) -> Optional[tuple[str, str]]:
|
|
"""Parse active or commented ``key = value`` lines."""
|
|
if not stripped or stripped.startswith(";"):
|
|
return None
|
|
commented = stripped.startswith("#")
|
|
body = stripped[1:].strip() if commented else stripped
|
|
if "=" not in body:
|
|
return None
|
|
key, _, value = body.partition("=")
|
|
key = key.strip()
|
|
if not key or key.startswith("[") or not _VALID_CONFIG_KEY_RE.match(key):
|
|
return None
|
|
return key, value.strip()
|
|
|
|
|
|
def is_command_section(section: str) -> bool:
|
|
"""Return True if section is a command plugin section."""
|
|
return section.endswith("_Command")
|
|
|
|
|
|
def is_service_section(section: str) -> bool:
|
|
"""Return True if section is a background service plugin section."""
|
|
return section.endswith("_Service")
|
|
|
|
|
|
def is_bridge_channel_key(section: str, key: str) -> bool:
|
|
"""Return True for dynamic bridge.<mesh_channel> mapping keys."""
|
|
return section in BRIDGE_SECTIONS and key.startswith(BRIDGE_CHANNEL_KEY_PREFIX)
|
|
|
|
|
|
def is_announcements_trigger_key(section: str, key: str) -> bool:
|
|
"""Return True for dynamic announce.<trigger_name> keys."""
|
|
return (
|
|
section == "Announcements_Command"
|
|
and key.startswith(ANNOUNCEMENTS_TRIGGER_KEY_PREFIX)
|
|
and len(key) > len(ANNOUNCEMENTS_TRIGGER_KEY_PREFIX)
|
|
)
|
|
|
|
|
|
def is_alert_agency_key(section: str, key: str) -> bool:
|
|
"""Return True for dynamic agency.city.* / agency.county.* / legacy agency keys."""
|
|
return section == "Alert_Command" and key.startswith(ALERT_AGENCY_KEY_PREFIXES)
|
|
|
|
|
|
def is_known_config_key(
|
|
section: str,
|
|
key: str,
|
|
example_keys: dict[str, dict[str, str]],
|
|
) -> bool:
|
|
"""Return True if key is documented or valid for this section type."""
|
|
if section in DYNAMIC_KEY_SECTIONS:
|
|
return True
|
|
ex_sec = example_keys.get(section, {})
|
|
if not ex_sec and section in example_keys:
|
|
return True
|
|
if key in ex_sec:
|
|
return True
|
|
section_meta = SECTIONS.get(section)
|
|
if section_meta and key in section_meta.keys:
|
|
return True
|
|
if is_command_section(section):
|
|
if key in STANDARD_COMMAND_KEYS:
|
|
return True
|
|
if LEGACY_ENABLED_KEY_RE.match(key):
|
|
return True
|
|
if is_service_section(section) and key in STANDARD_SERVICE_KEYS:
|
|
return True
|
|
if is_bridge_channel_key(section, key):
|
|
return True
|
|
if is_announcements_trigger_key(section, key):
|
|
return True
|
|
if is_alert_agency_key(section, key):
|
|
return True
|
|
return False
|
|
|
|
|
|
def load_documented_keys_from_example(example_path: Path) -> dict[str, dict[str, str]]:
|
|
"""Return all documented keys from example (active and commented ``#key =`` lines)."""
|
|
keys: dict[str, dict[str, str]] = {}
|
|
if not example_path.exists():
|
|
return keys
|
|
|
|
# Active keys via ConfigParser
|
|
try:
|
|
cfg = configparser.ConfigParser(allow_no_value=True)
|
|
cfg.optionxform = str
|
|
cfg.read(str(example_path), encoding="utf-8")
|
|
for section in cfg.sections():
|
|
try:
|
|
keys[section] = dict(cfg.items(section))
|
|
except configparser.Error:
|
|
keys[section] = {}
|
|
except (configparser.Error, OSError, UnicodeDecodeError):
|
|
keys = {}
|
|
|
|
current_section = ""
|
|
try:
|
|
with open(example_path, encoding="utf-8", errors="replace") as fh:
|
|
for line in fh:
|
|
stripped = line.strip()
|
|
if stripped.startswith("[") and stripped.endswith("]"):
|
|
current_section = stripped[1:-1]
|
|
keys.setdefault(current_section, {})
|
|
continue
|
|
if not current_section:
|
|
continue
|
|
parsed = _parse_key_value_line(stripped)
|
|
if parsed:
|
|
key, value = parsed
|
|
keys.setdefault(current_section, {}).setdefault(key, value)
|
|
except OSError:
|
|
pass
|
|
return keys
|
|
|
|
|
|
def get_example_sections(example_path: Path) -> list[str]:
|
|
"""Return section names in file order from config.ini.example."""
|
|
sections: list[str] = []
|
|
if not example_path.exists():
|
|
return sections
|
|
try:
|
|
with open(example_path, encoding="utf-8", errors="replace") as fh:
|
|
for line in fh:
|
|
stripped = line.strip()
|
|
if stripped.startswith("[") and stripped.endswith("]"):
|
|
sections.append(stripped[1:-1])
|
|
except OSError:
|
|
pass
|
|
return sections
|
|
|
|
|
|
def validate_config_keys(config: configparser.ConfigParser) -> list[tuple[str, str]]:
|
|
"""
|
|
Validate config keys against schema metadata.
|
|
|
|
Returns list of (severity, message). Warnings only — does not block startup.
|
|
"""
|
|
results: list[tuple[str, str]] = []
|
|
|
|
for section in config.sections():
|
|
section_stripped = section.strip()
|
|
if not section_stripped:
|
|
continue
|
|
|
|
if section_stripped in DEPRECATED_SECTIONS:
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"Deprecated section [{section_stripped}]; migrate options to "
|
|
f"[Joke_Command] / [DadJoke_Command].",
|
|
))
|
|
|
|
section_meta = SECTIONS.get(section_stripped)
|
|
is_command = section_stripped.endswith("_Command")
|
|
is_dynamic = (
|
|
section_stripped in DYNAMIC_KEY_SECTIONS
|
|
or (section_meta and section_meta.dynamic_keys)
|
|
)
|
|
|
|
if is_dynamic or is_command:
|
|
# Per-command legacy enabled keys
|
|
if is_command:
|
|
try:
|
|
for key in config.options(section):
|
|
if LEGACY_ENABLED_KEY_RE.match(key) and key != "enabled":
|
|
if config.has_option(section, "enabled"):
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[{section_stripped}] legacy key '{key}' is redundant; "
|
|
f"use 'enabled' instead.",
|
|
))
|
|
except configparser.Error:
|
|
pass
|
|
continue
|
|
|
|
if section_meta is None and section_stripped not in CANONICAL_NON_COMMAND_SECTIONS:
|
|
continue
|
|
|
|
try:
|
|
options = config.options(section)
|
|
except configparser.Error:
|
|
continue
|
|
|
|
known_keys = section_meta.keys if section_meta else {}
|
|
for key in options:
|
|
if key in known_keys:
|
|
meta = known_keys[key]
|
|
if meta.deprecated:
|
|
msg = meta.deprecated_message or f"Key '{key}' is deprecated."
|
|
results.append((SEVERITY_WARNING, f"[{section_stripped}] {msg}"))
|
|
value = config.get(section, key, fallback="").strip()
|
|
if meta.type == "enum" and meta.values and value:
|
|
if value.lower() not in meta.values:
|
|
allowed = ", ".join(meta.values)
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[{section_stripped}] '{key}' must be one of: {allowed} "
|
|
f"(got '{value}').",
|
|
))
|
|
if meta.type == "int" and value:
|
|
try:
|
|
int(value)
|
|
except ValueError:
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[{section_stripped}] '{key}' should be an integer (got '{value}').",
|
|
))
|
|
if meta.type == "bool" and value:
|
|
if value.lower() not in ("true", "false", "yes", "no", "1", "0", "on", "off"):
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[{section_stripped}] '{key}' should be a boolean (got '{value}').",
|
|
))
|
|
elif known_keys and key not in known_keys:
|
|
# Only warn for sections with explicit key lists in schema
|
|
pass
|
|
|
|
# Conditional required keys
|
|
if config.has_section("Connection"):
|
|
conn_type = config.get("Connection", "connection_type", fallback="serial").lower()
|
|
conn_meta = SECTIONS.get("Connection")
|
|
if conn_meta:
|
|
for key, meta in conn_meta.keys.items():
|
|
if not meta.required_when:
|
|
continue
|
|
if meta.required_when.get("connection_type") != conn_type:
|
|
continue
|
|
if not config.has_option("Connection", key):
|
|
continue
|
|
value = config.get("Connection", key, fallback="").strip()
|
|
if not value:
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[Connection] '{key}' is required when connection_type = {conn_type}.",
|
|
))
|
|
|
|
# Unknown keys in Connection (common mistake: host/port instead of hostname/tcp_port)
|
|
if config.has_section("Connection"):
|
|
documented = load_documented_keys_from_example(
|
|
Path(__file__).resolve().parent.parent / "config.ini.example"
|
|
).get("Connection", {})
|
|
try:
|
|
for key in config.options("Connection"):
|
|
if key in ("host", "port"):
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[Connection] '{key}' is not valid; use "
|
|
f"{'hostname' if key == 'host' else 'tcp_port'} for TCP connections.",
|
|
))
|
|
elif documented and key not in documented:
|
|
results.append((
|
|
SEVERITY_INFO,
|
|
f"[Connection] unknown key '{key}' (not documented in config.ini.example).",
|
|
))
|
|
except configparser.Error:
|
|
pass
|
|
|
|
return results
|
|
|
|
|
|
def canonical_sections_missing_from_example(example_path: Path) -> list[str]:
|
|
"""Return canonical non-command sections absent from config.ini.example."""
|
|
present = set(get_example_sections(example_path))
|
|
return sorted(CANONICAL_NON_COMMAND_SECTIONS - present)
|
|
|
|
|
|
__all__ = [
|
|
"DYNAMIC_KEY_SECTIONS",
|
|
"DEPRECATED_SECTIONS",
|
|
"KeyMeta",
|
|
"REQUIRED_SECTIONS",
|
|
"SECTIONS",
|
|
"SectionMeta",
|
|
"STANDARD_COMMAND_KEYS",
|
|
"STANDARD_SERVICE_KEYS",
|
|
"canonical_sections_missing_from_example",
|
|
"get_example_sections",
|
|
"is_command_section",
|
|
"is_known_config_key",
|
|
"is_service_section",
|
|
"load_documented_keys_from_example",
|
|
"validate_config_keys",
|
|
]
|