mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-14 14:40:00 +00:00
- Implemented caching for multi-byte mesh aggregation, allowing concurrent requests to share computation and reducing CPU load. - Updated the web viewer to coalesce live edge events and refresh the mesh graph every 30 seconds, improving responsiveness on busy networks. - Adjusted logging behavior in the web viewer to respect the configured log level, ensuring efficient logging without excessive duplicate entries. - Added configuration options for mesh graph caching duration, enhancing user control over performance settings.
701 lines
27 KiB
Python
701 lines
27 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 difflib
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from modules.config_validation import (
|
|
CANONICAL_NON_COMMAND_SECTIONS,
|
|
REQUIRED_SECTIONS,
|
|
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$")
|
|
|
|
# Legacy aliases for a plugin's canonical `[Section] enabled` key, tried in
|
|
# order when the canonical key is absent. Single source of truth shared by
|
|
# BaseCommand.get_config_value (runtime reads) and the web settings view
|
|
# (modules/settings_schema.read_enabled) — keep additions here so runtime
|
|
# behavior and the UI can never disagree.
|
|
# Maps canonical section -> ordered ((legacy_section, legacy_key), ...).
|
|
LEGACY_ENABLED_ALIASES: dict[str, tuple[tuple[str, str], ...]] = {
|
|
"Joke_Command": (("Jokes", "joke_enabled"),),
|
|
"DadJoke_Command": (("Jokes", "dadjoke_enabled"),),
|
|
"Stats_Command": (("Stats_Command", "stats_enabled"), ("Stats", "stats_enabled")),
|
|
"Sports_Command": (("Sports_Command", "sports_enabled"), ("Sports", "sports_enabled")),
|
|
"Hacker_Command": (("Hacker_Command", "hacker_enabled"), ("Hacker", "hacker_enabled")),
|
|
"Alert_Command": (("Alert_Command", "alert_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_")
|
|
|
|
# Numbered MQTT broker keys in [PacketCapture]: mqtt1_server, mqtt2_server, ...
|
|
MQTT_BROKER_KEY_RE = re.compile(r"^mqtt\d+_(.+)$")
|
|
|
|
# (section, key prefix) pairs where the suffix is an operator-chosen name, so
|
|
# config.ini.example can only ever document sample entries. Keep in sync with the
|
|
# code that reads them:
|
|
# flood_scope.<channel> modules/models.py, modules/command_manager.py
|
|
# custom.wxsim.<location> modules/commands/wx_command.py
|
|
# custom.mqtt_weather.<name> modules/clients/mqtt_weather.py
|
|
# flood_scope.<region-id> modules/service_plugins/darc_mowas_service.py
|
|
DYNAMIC_SUFFIX_KEY_PREFIXES: tuple[tuple[str, str], ...] = (
|
|
("Channels", "flood_scope."),
|
|
("Weather", "custom.wxsim."),
|
|
("Weather", "custom.mqtt_weather."),
|
|
("DARC_MoWaS_Service", "flood_scope."),
|
|
)
|
|
|
|
# Known wrong key names, per section, with the canonical spelling to suggest.
|
|
# Generic near-miss typos are caught by difflib; this is for names that are
|
|
# plausible but simply wrong (a user carrying habits over from other tools).
|
|
KEY_TYPO_SUGGESTIONS: dict[str, dict[str, str]] = {
|
|
"Connection": {"host": "hostname", "port": "tcp_port"},
|
|
}
|
|
|
|
_VALID_CONFIG_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
|
|
# A bare number is never a real key — it's comment prose like "# 0 = unlimited".
|
|
_NUMERIC_KEY_RE = re.compile(r"^\d+(\.\d+)?$")
|
|
|
|
|
|
@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(),
|
|
# Advanced tuning knobs the code reads but config.ini.example does not
|
|
# show. Listed here so unknown-key validation doesn't flag them; see
|
|
# tests/test_config_schema_sync.py::test_every_config_read_key_is_known.
|
|
"cooldown_queue_threshold_seconds": KeyMeta(type="float", default="5.0"),
|
|
"disconnect_timeout_seconds": KeyMeta(type="float", default="10.0"),
|
|
"nominatim_rate_limit_seconds": KeyMeta(type="float", default="1.1"),
|
|
"persist_settings": KeyMeta(default="config.ini"),
|
|
"radio_offline_threshold": KeyMeta(type="int", default="3"),
|
|
"radio_probe_fail_threshold": KeyMeta(type="int", default="3"),
|
|
"radio_probe_interval_seconds": KeyMeta(type="int", default="300"),
|
|
"radio_offline_alert_enabled": KeyMeta(type="bool", default="false"),
|
|
"radio_offline_alert_email": KeyMeta(),
|
|
"radio_zombie_alert_enabled": KeyMeta(type="bool", default="false"),
|
|
"radio_zombie_alert_email": 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"),
|
|
# Advanced; read by the viewer but not shown in config.ini.example.
|
|
"cors_allowed_origins": KeyMeta(),
|
|
"packet_stream_write_queue_max": KeyMeta(type="int", default="1000"),
|
|
"restore_max_bytes": KeyMeta(type="int"),
|
|
"sqlite_busy_timeout_ms": KeyMeta(type="int", default="60000"),
|
|
"sqlite_foreign_keys": KeyMeta(type="bool", default="true"),
|
|
"sqlite_journal_mode": KeyMeta(default="WAL"),
|
|
"mesh_graph_cache_seconds": KeyMeta(type="int", default="30"),
|
|
# Dashboard snapshot refresher — moves the landing page's aggregate
|
|
# queries off the request path and accumulates trends that outlive the
|
|
# raw tables' retention.
|
|
"dashboard_snapshot_enabled": KeyMeta(type="bool", default="true"),
|
|
"dashboard_snapshot_interval_seconds": KeyMeta(type="int", default="60"),
|
|
"dashboard_snapshot_history_days": KeyMeta(type="int", default="400"),
|
|
"dashboard_packet_backfill_rows": KeyMeta(type="int", default="2000"),
|
|
}),
|
|
"Localization": SectionMeta(keys={
|
|
"language": KeyMeta(default="en"),
|
|
"translation_path": KeyMeta(default="translations/"),
|
|
"auto_detect_language": KeyMeta(type="bool", default="false"),
|
|
}),
|
|
"Webhook": SectionMeta(keys={
|
|
"enabled": KeyMeta(type="bool"),
|
|
"host": KeyMeta(),
|
|
"port": KeyMeta(type="int"),
|
|
"secret_token": KeyMeta(),
|
|
"rate_limit_per_minute": KeyMeta(type="int", default="30"),
|
|
}),
|
|
"Feed_Manager": SectionMeta(keys={
|
|
"feed_manager_enabled": KeyMeta(type="bool"),
|
|
"allow_private_urls": KeyMeta(type="bool"),
|
|
"max_response_bytes": KeyMeta(type="int", default="2097152"),
|
|
"max_parsed_items": KeyMeta(type="int", default="500"),
|
|
# Typed so validation catches a non-integer before getint() raises in
|
|
# FeedManager.__init__ — that ValueError leaves feed_manager as None and
|
|
# silently stops every feed.
|
|
"max_items_per_check": KeyMeta(type="int", default="10"),
|
|
"max_posts_per_check": KeyMeta(type="int", default="10"),
|
|
"feed_request_timeout": KeyMeta(type="int", default="30"),
|
|
"max_message_length": KeyMeta(type="int", default="130"),
|
|
"default_check_interval_seconds": KeyMeta(type="int", default="300"),
|
|
}),
|
|
"Weather_Service": SectionMeta(keys={
|
|
"enabled": KeyMeta(type="bool"),
|
|
"rain_nowcast_cache_seconds": KeyMeta(type="int", default="300"),
|
|
# Siblings of the documented rain_nowcast_* keys, read but not shown.
|
|
"rain_nowcast_show_amount": KeyMeta(type="bool", default="true"),
|
|
"rain_nowcast_amount_unit": KeyMeta(type="enum", values=("in", "mm"), default="in"),
|
|
"rain_nowcast_min_probability": KeyMeta(type="int", default="50"),
|
|
}),
|
|
"Path_Command": SectionMeta(keys={
|
|
"geographic_scoring_enabled": KeyMeta(type="bool", default="true"),
|
|
}),
|
|
"Rain_Command": SectionMeta(keys={
|
|
"amount_unit": KeyMeta(type="enum", values=("in", "mm"), default="in"),
|
|
}),
|
|
"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
|
|
# Prose in comments parses as a key/value pair: "# 0 = unlimited" would
|
|
# document a key named "0" and then accept it in a real config.
|
|
if _NUMERIC_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_dynamic_suffix_key(section: str, key: str) -> bool:
|
|
"""Return True for user-named keys under a fixed prefix in a fixed section.
|
|
|
|
These sections mix a fixed schema with an open-ended family whose suffix the
|
|
operator chooses (a mesh channel name, a weather station label, a German
|
|
Regionalschlüssel), so the example can only ever show sample entries.
|
|
"""
|
|
for allowed_section, prefix in DYNAMIC_SUFFIX_KEY_PREFIXES:
|
|
if section == allowed_section and key.startswith(prefix) and len(key) > len(prefix):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_mqtt_broker_key(
|
|
section: str,
|
|
key: str,
|
|
example_keys: dict[str, dict[str, str]],
|
|
) -> bool:
|
|
"""Return True for numbered [PacketCapture] mqtt<N>_* broker keys.
|
|
|
|
config.ini.example documents broker 1 concretely plus an ``mqttN_*``
|
|
placeholder block, but packet_capture_service reads any broker index, so
|
|
match the suffix against either spelling rather than the literal key.
|
|
"""
|
|
if section != "PacketCapture":
|
|
return False
|
|
match = MQTT_BROKER_KEY_RE.match(key)
|
|
if not match:
|
|
return False
|
|
suffix = match.group(1)
|
|
documented = {k.lower() for k in example_keys.get(section, {})}
|
|
return f"mqttn_{suffix}" in documented or f"mqtt1_{suffix}" in documented
|
|
|
|
|
|
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
|
|
section_meta = SECTIONS.get(section)
|
|
if section_meta and section_meta.dynamic_keys:
|
|
return True
|
|
ex_sec = example_keys.get(section, {})
|
|
if not ex_sec and section in example_keys:
|
|
return True
|
|
# Compare case-folded throughout: ConfigParser lowercases keys read from a user
|
|
# config, the example is parsed case-preserving (e.g. bridge.Public), and
|
|
# scripts/config_tui.py reads with optionxform = str.
|
|
lowered = key.lower()
|
|
if lowered in {k.lower() for k in ex_sec}:
|
|
return True
|
|
if section_meta and lowered in {k.lower() for k in section_meta.keys}:
|
|
return True
|
|
if is_mqtt_broker_key(section, lowered, example_keys):
|
|
return True
|
|
if is_dynamic_suffix_key(section, lowered):
|
|
return True
|
|
if is_command_section(section):
|
|
if lowered in STANDARD_COMMAND_KEYS:
|
|
return True
|
|
if LEGACY_ENABLED_KEY_RE.match(lowered):
|
|
return True
|
|
if is_service_section(section) and lowered in STANDARD_SERVICE_KEYS:
|
|
return True
|
|
if is_bridge_channel_key(section, lowered):
|
|
return True
|
|
if is_announcements_trigger_key(section, lowered):
|
|
return True
|
|
if is_alert_agency_key(section, lowered):
|
|
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 # type: ignore[assignment]
|
|
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
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _shipped_example_keys() -> dict[str, dict[str, str]]:
|
|
"""Documented keys from the shipped config.ini.example (parsed once).
|
|
|
|
The example is the main source of known key names, so an install that lacks
|
|
it (a plain wheel — it sits at the repo root, outside the package) quietly
|
|
falls back to SECTIONS alone and checks far fewer keys. That degradation is
|
|
safe (fewer checks, not wrong ones) but should not be invisible.
|
|
"""
|
|
example_path = Path(__file__).resolve().parent.parent / "config.ini.example"
|
|
keys = load_documented_keys_from_example(example_path)
|
|
if not keys:
|
|
logging.getLogger(__name__).info(
|
|
"config.ini.example not found at %s; unknown-key validation is limited "
|
|
"to keys declared in the schema.",
|
|
example_path,
|
|
)
|
|
return keys
|
|
|
|
|
|
def _section_has_documented_keys(
|
|
section: str,
|
|
section_meta: Optional[SectionMeta],
|
|
example_keys: dict[str, dict[str, str]],
|
|
) -> bool:
|
|
"""True when we know enough about a section to call one of its keys unknown.
|
|
|
|
Sections absent from both the example and the schema belong to local or
|
|
third-party plugins whose keys we have no list for; flagging every one of
|
|
them would bury the real typos. The unknown-*section* check already reports
|
|
those separately.
|
|
"""
|
|
if example_keys.get(section):
|
|
return True
|
|
return bool(section_meta and section_meta.keys)
|
|
|
|
|
|
def _suggest_similar_key(key: str, candidates: set[str]) -> Optional[str]:
|
|
"""Closest documented key name to `key`, if one is near enough to be a typo."""
|
|
matches = difflib.get_close_matches(key, sorted(candidates), n=1, cutoff=0.8)
|
|
return matches[0] if matches else None
|
|
|
|
|
|
def _known_keys_for_suggestions(
|
|
section: str,
|
|
section_meta: Optional[SectionMeta],
|
|
example_keys: dict[str, dict[str, str]],
|
|
) -> set[str]:
|
|
"""Candidate key names for 'did you mean' on an unknown key."""
|
|
candidates = {k.lower() for k in example_keys.get(section, {})}
|
|
if section_meta:
|
|
candidates.update(section_meta.keys)
|
|
if is_command_section(section):
|
|
candidates.update(STANDARD_COMMAND_KEYS)
|
|
if is_service_section(section):
|
|
candidates.update(STANDARD_SERVICE_KEYS)
|
|
return candidates
|
|
|
|
|
|
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 _typed_key_results(
|
|
section: str, key: str, value: str, meta: KeyMeta
|
|
) -> list[tuple[str, str]]:
|
|
"""Deprecation and type/enum diagnostics for a key that IS in the schema."""
|
|
results: list[tuple[str, str]] = []
|
|
if meta.deprecated:
|
|
msg = meta.deprecated_message or f"Key '{key}' is deprecated."
|
|
results.append((SEVERITY_WARNING, f"[{section}] {msg}"))
|
|
if not value:
|
|
return results
|
|
if meta.type == "enum" and meta.values:
|
|
if value.lower() not in meta.values:
|
|
allowed = ", ".join(meta.values)
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[{section}] '{key}' must be one of: {allowed} (got '{value}').",
|
|
))
|
|
elif meta.type == "int":
|
|
try:
|
|
int(value)
|
|
except ValueError:
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[{section}] '{key}' should be an integer (got '{value}').",
|
|
))
|
|
elif meta.type == "float":
|
|
try:
|
|
float(value)
|
|
except ValueError:
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[{section}] '{key}' should be a number (got '{value}').",
|
|
))
|
|
elif meta.type == "bool":
|
|
if value.lower() not in ("true", "false", "yes", "no", "1", "0", "on", "off"):
|
|
results.append((
|
|
SEVERITY_WARNING,
|
|
f"[{section}] '{key}' should be a boolean (got '{value}').",
|
|
))
|
|
return results
|
|
|
|
|
|
def _unknown_key_result(
|
|
section: str, key: str, suggestions: set[str]
|
|
) -> tuple[str, str]:
|
|
"""Diagnostic for a key that matches nothing documented for this section."""
|
|
typo = KEY_TYPO_SUGGESTIONS.get(section, {}).get(key)
|
|
if typo:
|
|
detail = (
|
|
f"use {typo} for TCP connections."
|
|
if section == "Connection"
|
|
else f"use '{typo}'."
|
|
)
|
|
return (SEVERITY_WARNING, f"[{section}] '{key}' is not valid; {detail}")
|
|
similar = _suggest_similar_key(key, suggestions)
|
|
hint = (
|
|
f" Did you mean '{similar}'?"
|
|
if similar
|
|
else " (not documented in config.ini.example)"
|
|
)
|
|
return (SEVERITY_WARNING, f"[{section}] unknown key '{key}'.{hint}")
|
|
|
|
|
|
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]] = []
|
|
example_keys = _shipped_example_keys()
|
|
|
|
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 bool(section_meta and section_meta.dynamic_keys)
|
|
)
|
|
|
|
try:
|
|
# config.options() folds in [DEFAULT], which would report one stray
|
|
# global key once per section in the file.
|
|
defaults = set(config.defaults())
|
|
options = [k for k in config.options(section) if k not in defaults]
|
|
except configparser.Error:
|
|
continue
|
|
|
|
if is_command:
|
|
# Per-command legacy enabled keys
|
|
for key in options:
|
|
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.",
|
|
))
|
|
|
|
check_unknown_keys = not is_dynamic and _section_has_documented_keys(
|
|
section_stripped, section_meta, example_keys
|
|
)
|
|
suggestions = (
|
|
_known_keys_for_suggestions(section_stripped, section_meta, example_keys)
|
|
if check_unknown_keys
|
|
else set()
|
|
)
|
|
|
|
known_keys = section_meta.keys if section_meta else {}
|
|
for key in options:
|
|
meta = known_keys.get(key)
|
|
if meta is not None:
|
|
# raw=True: a literal '%' in any value raises InterpolationError,
|
|
# which startup swallows as "validation skipped" — losing every
|
|
# diagnostic over one unrelated password.
|
|
try:
|
|
value = config.get(section, key, fallback="", raw=True).strip()
|
|
except configparser.Error:
|
|
continue
|
|
results.extend(_typed_key_results(section_stripped, key, value, meta))
|
|
elif check_unknown_keys and not is_known_config_key(
|
|
section_stripped, key, example_keys
|
|
):
|
|
results.append(
|
|
_unknown_key_result(section_stripped, key, suggestions)
|
|
)
|
|
|
|
# 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}.",
|
|
))
|
|
|
|
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",
|
|
]
|