Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Bot_Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,11 @@ admin subcommand reference).
## 3. Configuration Reference

`mcbot.conf` uses INI syntax (`configparser`). CLI flags override conf
values where applicable.
values where applicable. Unrecognized sections/keys (typos, or settings
removed in an upgrade) are ignored, but each is logged loudly as a
`WARNING` at startup (e.g. `mcbot.conf: [bot] unrecognized key 'foo'
(ignored)`). The `[env]` and `[channels]` sections accept arbitrary keys
and are not checked.

### `[radio]`

Expand Down
64 changes: 64 additions & 0 deletions mcbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,9 @@ class Config:
# path of the config file actually loaded (None if none was found);
# recorded so the startup banner can show where settings came from.
config_path: Optional[str] = None
# human-readable warnings about the config file (e.g. unrecognized keys),
# collected during load and logged loudly at startup.
config_warnings: list = field(default_factory=list)
commands_dir: Path = Path("./commands")
privkey_path: Optional[Path] = None # default: <db>.privkey
log_channels: str = "all"
Expand Down Expand Up @@ -450,6 +453,58 @@ def target_desc(self) -> str:
return f"tcp {self.host}:{self.port}"


# Recognized option keys per config section (lowercase — configparser folds
# option names to lowercase). Used to warn about typos / stale settings at
# startup. Keep in sync with the keys load_config actually reads below.
_KNOWN_CONFIG_KEYS = {
"radio": {"transport", "host", "port", "serial_port", "serial_baud",
"device_pin"},
"storage": {"db", "max_channel_messages", "max_dms", "max_contacts",
"max_packets"},
"channel_logging": {"channels"},
"logging": {"logs_dir", "log_level"},
"bot": {"commands_dir", "enabled", "repeat_tracking", "repeat_timeout",
"privkey_path", "dm_max_attempts", "dm_flood_after",
"dm_max_flood_attempts", "radio_evict_enabled",
"radio_evict_headroom", "radio_evict_max_per_run",
"radio_evict_min_interval", "radio_evict_protect_types",
"advert_interval_hours", "owner_pubkeys"},
"web": {"enabled", "host", "port", "admin_user", "admin_password_hash",
"session_secret", "cors_origins", "api_tokens", "tls_cert",
"tls_key"},
}
# Sections whose keys are user data (env-var names, channel indexes), not a
# fixed set of option names — accept any key there.
_DYNAMIC_CONFIG_SECTIONS = {"env", "channels"}
# Options that were removed; flagged as deprecated (ignored) rather than
# "unrecognized" so upgraders get a clear, friendly nudge.
_DEPRECATED_CONFIG_KEYS = {"bot": {"rx_log_decrypt"}}


def _check_unknown_config_keys(parser) -> list:
"""Return warnings for unrecognized sections/keys in the parsed config."""
warnings = []
for section in parser.sections():
if section in _DYNAMIC_CONFIG_SECTIONS:
continue
if section not in _KNOWN_CONFIG_KEYS:
warnings.append(f"unrecognized section [{section}] (ignored)")
continue
allowed = _KNOWN_CONFIG_KEYS[section]
deprecated = _DEPRECATED_CONFIG_KEYS.get(section, frozenset())
for key in parser[section]:
if key in allowed:
continue
if key in deprecated:
warnings.append(
f"[{section}] '{key}' is deprecated and ignored "
"(safe to remove)"
)
else:
warnings.append(f"[{section}] unrecognized key '{key}' (ignored)")
return warnings


def load_config(args) -> Config:
cfg = Config()

Expand Down Expand Up @@ -625,6 +680,10 @@ def load_config(args) -> Config:
sys.exit(2)
cfg.channels[idx] = (name, secret)

# flag unrecognized sections/keys (typos, stale settings); logged
# loudly at startup once the logger is configured.
cfg.config_warnings = _check_unknown_config_keys(parser)

# CLI overrides
if args.transport:
cfg.transport = args.transport.strip().lower()
Expand Down Expand Up @@ -3608,6 +3667,11 @@ async def run(self) -> int:
)
self.logger.info("=" * 60)

# surface config typos / stale settings loudly so they're not silently
# ignored (mcbot.conf is not strictly validated).
for w in self.cfg.config_warnings:
self.logger.warning("mcbot.conf: %s", w)

# MeshCore.__init__ forces the "meshcore" logger level from its `debug`
# arg (debug -> DEBUG, else INFO), overriding setup_logging. Drive that
# arg from our effective level, then re-assert the level after connect
Expand Down
79 changes: 79 additions & 0 deletions tests/test_config_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""load_config flags unrecognized config sections/keys (and deprecated ones),
while accepting dynamic [env]/[channels] keys and valid options silently.

Run: /home/steve/dev/meshcore/meshcore-bot/venv/bin/python tests/test_config_keys.py
"""

import os
import sys
import tempfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import mcbot # noqa: E402

_failures = 0


def check(cond, msg):
global _failures
print(f" {'ok' if cond else 'FAIL'}: {msg}")
if not cond:
_failures += 1


def warnings_for(text):
fd, path = tempfile.mkstemp(suffix=".conf")
os.write(fd, text.encode())
os.close(fd)
try:
cfg = mcbot.load_config(mcbot.parse_args(["--config", path]))
return cfg.config_warnings
finally:
os.unlink(path)


def main():
# a fully valid config -> no warnings (no false positives)
valid = (
"[radio]\nhost = 1.2.3.4\nport = 4000\n\n"
"[bot]\nenabled = true\nrepeat_tracking = true\nadvert_interval_hours = 3\n\n"
"[env]\nPWS_API_KEY = abc\n\n"
"[channels]\n0 = #bot\n"
)
w = warnings_for(valid)
check(w == [], f"valid config -> no warnings (got {w})")

# bad config: unknown key, unknown section, deprecated key; dynamic ok
bad = (
"[radio]\nhost = 1.2.3.4\n\n"
"[bot]\nenabled = true\nbogus_key = 1\nrx_log_decrypt = false\n\n"
"[bogus]\nfoo = bar\n\n"
"[env]\nANY_NAME = x\n\n"
"[channels]\n1 = #bot-cmd-test\n"
)
w = warnings_for(bad)
joined = " | ".join(w)
check(any("unrecognized key 'bogus_key'" in x for x in w),
"warns on unrecognized [bot] key")
check(any("unrecognized section [bogus]" in x for x in w),
"warns on unrecognized section")
check(any("rx_log_decrypt" in x and "deprecated" in x for x in w),
"flags rx_log_decrypt as deprecated (not unrecognized)")
check(not any("ANY_NAME" in x or "any_name" in x for x in w),
"does NOT warn on [env] keys")
check(not any("bot-cmd-test" in x or "'1'" in x for x in w),
"does NOT warn on [channels] keys")
print(" (warnings:", joined, ")")

print()
if _failures:
print(f"FAILED: {_failures} check(s)")
sys.exit(1)
print("ALL TESTS PASSED")


if __name__ == "__main__":
main()
Loading