mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-07-28 14:29:30 +00:00
242 lines
8.7 KiB
Python
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()
|
|
|
|
|
|
|