Files
meshcore-bot/meshcore_bot.py
T
2026-07-12 11:57:28 -07:00

242 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""
MeshCore Bot using the meshcore-cli and meshcore.py packages
Uses a modular structure for command creation and organization
"""
import argparse
import asyncio
import configparser
import json
import signal
import sys
from modules.config_snapshot import config_to_redacted_sections, redacted_sections_to_ini_text
def _configure_unix_signal_handlers(loop, bot, shutdown_event: asyncio.Event) -> None:
"""Register Unix signal handlers for shutdown and config reload."""
def shutdown_handler():
"""Signal handler for graceful shutdown."""
print("\nShutting down...")
# asyncio.add_signal_handler replaces SIGINT/SIGTERM handling for the loop; the
# bot's threading.Event + connected flag must be set here too or the main loop
# can run another iteration and restart the web viewer before stop() runs.
bot._shutdown_event.set()
bot.connected = False
shutdown_event.set()
def reload_handler():
"""Reload config on SIGHUP without exiting."""
bot.logger.info("Received SIGHUP, reloading configuration...")
success, msg = bot.reload_config()
if success:
bot.logger.info("SIGHUP config reload succeeded: %s", msg)
else:
bot.logger.warning("SIGHUP config reload failed: %s", msg)
# Register shutdown signals
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, shutdown_handler)
# Register config reload signal (Unix daemons convention)
if hasattr(signal, "SIGHUP"):
loop.add_signal_handler(signal.SIGHUP, reload_handler)
def main():
parser = argparse.ArgumentParser(
description="MeshCore Bot - Mesh network bot for MeshCore devices"
)
parser.add_argument(
"--config",
default="config.ini",
help="Path to configuration file (default: config.ini)",
)
parser.add_argument(
"--validate-config",
action="store_true",
help="Validate config section names and exit before starting the bot (exit 1 on errors)",
)
parser.add_argument(
"--show-config",
action="store_true",
help="Print resolved config.ini with sensitive keys redacted and exit",
)
parser.add_argument(
"--show-config-json",
action="store_true",
help="Print resolved config.ini as redacted JSON and exit",
)
args = parser.parse_args()
if args.show_config and args.show_config_json:
print("Error: --show-config and --show-config-json are mutually exclusive", file=sys.stderr)
sys.exit(1)
if args.show_config or args.show_config_json:
cfg = configparser.ConfigParser()
try:
loaded_paths = cfg.read(args.config, encoding="utf-8")
except configparser.Error as exc:
print(f"Error: Invalid config file '{args.config}': {exc}", file=sys.stderr)
sys.exit(1)
if not loaded_paths:
print(f"Error: Config file not found: {args.config}", file=sys.stderr)
sys.exit(1)
sections = config_to_redacted_sections(cfg)
if args.show_config_json:
print(json.dumps(sections, indent=2, sort_keys=True))
else:
print(redacted_sections_to_ini_text(sections))
sys.exit(0)
if args.validate_config:
from modules.config_validation import (
SEVERITY_ERROR,
SEVERITY_WARNING,
validate_config,
)
results = validate_config(args.config)
has_error = False
for severity, message in results:
if severity == SEVERITY_ERROR:
print(f"Error: {message}", file=sys.stderr)
has_error = True
elif severity == SEVERITY_WARNING:
print(f"Warning: {message}", file=sys.stderr)
else:
print(f"Info: {message}", file=sys.stderr)
sys.exit(1 if has_error else 0)
# Always sanity-check the config on normal startup too — misspelled
# sections/keys otherwise fail silently and are the most common source of
# "why doesn't my setting work" confusion. Non-fatal: warn and continue.
try:
from modules.config_validation import (
SEVERITY_ERROR as _SEV_ERR,
)
from modules.config_validation import (
SEVERITY_WARNING as _SEV_WARN,
)
from modules.config_validation import (
validate_config as _startup_validate,
)
_issues = [
(sev, msg) for sev, msg in _startup_validate(args.config)
if sev in (_SEV_ERR, _SEV_WARN)
]
_MAX_SHOWN = 15
for sev, msg in _issues[:_MAX_SHOWN]:
print(f"Config {sev}: {msg}", file=sys.stderr)
if len(_issues) > _MAX_SHOWN:
print(
f"Config: ... and {len(_issues) - _MAX_SHOWN} more issue(s); "
"run with --validate-config for the full report",
file=sys.stderr,
)
except Exception as exc: # noqa: BLE001 - never block startup on the linter itself
print(f"Config validation skipped: {exc}", file=sys.stderr)
from modules.core import MeshCoreBot
bot = MeshCoreBot(config_file=args.config)
# Use asyncio.run() which handles KeyboardInterrupt properly
# For SIGTERM, we'll handle it in the async context
async def run_bot():
"""Run bot with proper signal handling"""
loop = asyncio.get_running_loop()
def meshcore_task_exception_handler(loop, context):
"""Log unhandled exceptions from asyncio tasks (e.g. meshcore reader)."""
exc = context.get('exception')
msg = context.get('message', 'Unhandled exception in task')
if exc is not None:
bot.logger.warning(
"%s: %s",
msg,
exc,
exc_info=(type(exc), exc, exc.__traceback__),
)
else:
bot.logger.warning("%s: %s", msg, context)
loop.set_exception_handler(meshcore_task_exception_handler)
# Set up signal handlers for graceful shutdown (Unix only)
if sys.platform != 'win32':
shutdown_event = asyncio.Event()
bot_task = None
try:
# Register signal handlers
_configure_unix_signal_handlers(loop, bot, shutdown_event)
# Start bot
bot_task = asyncio.create_task(bot.start())
# Wait for shutdown or completion
done, pending = await asyncio.wait(
[bot_task, asyncio.create_task(shutdown_event.wait())],
return_when=asyncio.FIRST_COMPLETED
)
# Cancel pending tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Handle bot task completion
if bot_task:
if shutdown_event.is_set() and not bot_task.done():
# Ensure the bot loop sees shutdown even if the signal handler ordering
# left a race before cancel.
bot._shutdown_event.set()
bot.connected = False
# Shutdown triggered: cancel if still running
bot_task.cancel()
# Always await bot_task to ensure proper cleanup
# This is necessary because:
# 1. If the task completed normally, we need to await to surface exceptions
# 2. If the task was cancelled, it only becomes "done" after being awaited
# (cancellation is not immediate - the task must be awaited for the
# CancelledError to be raised and the task to fully terminate)
try:
await bot_task
except asyncio.CancelledError:
# Expected when cancelled, ignore
pass
finally:
# Always ensure cleanup happens
await bot.stop()
else:
# Windows: just run and catch KeyboardInterrupt
try:
await bot.start()
finally:
await bot.stop()
try:
asyncio.run(run_bot())
except KeyboardInterrupt:
# Cleanup already handled in run_bot's finally block
print("\nShutdown complete.")
except Exception as e:
# Cleanup already handled in run_bot's finally block
print(f"Error: {e}")
if __name__ == "__main__":
main()