feat: Add reload configuration functionality and update admin commands

- Introduced a new method `reload_config` to allow dynamic reloading of the configuration without restarting the bot, ensuring seamless updates to settings.
- Added a helper method `_get_radio_settings` to retrieve current radio settings for comparison during reload.
- Updated `admin_commands` in `config.ini.example` to include the new `reload` command, enabling admin users to reload configurations on-the-fly.
- Enhanced the `setup_scheduled_messages` method in the scheduler to clear existing jobs before reloading, preventing duplicates.
This commit is contained in:
agessaman
2026-01-16 19:55:44 -08:00
parent 117b687b0b
commit db0db96f7e
4 changed files with 208 additions and 2 deletions
+2 -1
View File
@@ -145,7 +145,8 @@ admin_pubkeys =
# Commands that require admin access (comma-separated)
# These commands will only work for users in the admin_pubkeys list
admin_commands = repeater,webviewer
# reload: Reload configuration without restarting (radio settings cannot be changed)
admin_commands = repeater,webviewer,reload
[Plugin_Overrides]
# Plugin Overrides - Use alternative plugin implementations
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
Reload Command
Allows admin users to reload the bot configuration without restarting
"""
from .base_command import BaseCommand
from ..models import MeshMessage
class ReloadCommand(BaseCommand):
"""Command for reloading bot configuration"""
# Plugin metadata
name = "reload"
keywords = ["reload", "reloadconfig", "configreload"]
description = "Reload bot configuration without restart (DM only, admin only)"
requires_dm = True
cooldown_seconds = 2
category = "admin"
def __init__(self, bot):
"""Initialize the reload command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
def can_execute(self, message: MeshMessage) -> bool:
"""Check if this command can be executed (admin only)"""
if not self.requires_admin_access():
return False
return super().can_execute(message)
def requires_admin_access(self) -> bool:
"""Reload command requires admin access"""
return True
def get_help_text(self) -> str:
"""Get help text for the reload command.
Returns:
str: The help text for this command.
"""
return ("Reloads the bot configuration from config.ini without restarting.\n"
"Note: Radio/connection settings cannot be changed via reload.\n"
"If radio settings changed, restart the bot instead.\n"
"Usage: reload")
async def execute(self, message: MeshMessage) -> bool:
"""Execute the reload command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
# Call the bot's reload_config method
success, msg = self.bot.reload_config()
if success:
await self.send_response(message, f"{msg}")
else:
await self.send_response(message, f"{msg}")
return True
+134 -1
View File
@@ -16,7 +16,7 @@ import signal
import atexit
import sqlite3
from pathlib import Path
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Tuple
from dataclasses import dataclass
# Import the official meshcore package
@@ -218,6 +218,139 @@ class MeshCoreBot:
self.config.read(self.config_file)
def _get_radio_settings(self) -> Dict[str, Any]:
"""Get current radio/connection settings from config.
Returns:
Dict[str, Any]: Dictionary containing all radio-related settings.
"""
return {
'connection_type': self.config.get('Connection', 'connection_type', fallback='ble').lower(),
'serial_port': self.config.get('Connection', 'serial_port', fallback=''),
'ble_device_name': self.config.get('Connection', 'ble_device_name', fallback=''),
'hostname': self.config.get('Connection', 'hostname', fallback=''),
'tcp_port': self.config.getint('Connection', 'tcp_port', fallback=5000),
'timeout': self.config.getint('Connection', 'timeout', fallback=30),
}
def reload_config(self) -> Tuple[bool, str]:
"""Reload configuration from file without restarting the bot.
This method reloads the configuration file and updates all components
that depend on it. It will reject the reload if radio/connection settings
have changed, as those require a full restart.
Returns:
Tuple[bool, str]: (success, message) tuple indicating if reload succeeded
and a descriptive message.
"""
try:
# Store current radio settings before reload
old_radio_settings = self._get_radio_settings()
# Create a temporary config parser to check new settings
import configparser
new_config = configparser.ConfigParser()
if not Path(self.config_file).exists():
return (False, "Config file not found")
new_config.read(self.config_file)
# Get new radio settings
new_radio_settings = {
'connection_type': new_config.get('Connection', 'connection_type', fallback='ble').lower(),
'serial_port': new_config.get('Connection', 'serial_port', fallback=''),
'ble_device_name': new_config.get('Connection', 'ble_device_name', fallback=''),
'hostname': new_config.get('Connection', 'hostname', fallback=''),
'tcp_port': new_config.getint('Connection', 'tcp_port', fallback=5000),
'timeout': new_config.getint('Connection', 'timeout', fallback=30),
}
# Check if radio settings changed
if old_radio_settings != new_radio_settings:
changed_settings = []
for key in old_radio_settings:
if old_radio_settings[key] != new_radio_settings[key]:
changed_settings.append(f"{key}: {old_radio_settings[key]} -> {new_radio_settings[key]}")
return (False, f"Radio settings changed. Restart required. Changes: {', '.join(changed_settings)}")
# Radio settings unchanged, proceed with reload
self.logger.info("Reloading configuration (radio settings unchanged)")
# Reload the config
self.config.read(self.config_file)
# Update rate limiters
new_rate_limit = self.config.getint('Bot', 'rate_limit_seconds', fallback=10)
self.rate_limiter = RateLimiter(new_rate_limit)
new_bot_tx_rate_limit = self.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0)
self.bot_tx_rate_limiter = BotTxRateLimiter(new_bot_tx_rate_limit)
new_nominatim_rate_limit = self.config.getfloat('Bot', 'nominatim_rate_limit_seconds', fallback=1.1)
self.nominatim_rate_limiter = NominatimRateLimiter(new_nominatim_rate_limit)
# Update transmission delay
self.tx_delay_ms = self.config.getint('Bot', 'tx_delay_ms', fallback=250)
# Update translator if language changed
try:
new_language = self.config.get('Localization', 'language', fallback='en')
new_translation_path = self.config.get('Localization', 'translation_path', fallback='translations/')
if (not hasattr(self, 'translator') or
getattr(self.translator, 'language', None) != new_language or
getattr(self.translator, 'translation_path', None) != new_translation_path):
self.translator = Translator(new_language, new_translation_path)
self.logger.info(f"Translator reloaded with language: {new_language}")
# Reload translated keywords for all commands
if hasattr(self, 'command_manager'):
for cmd_name, cmd_instance in self.command_manager.commands.items():
if hasattr(cmd_instance, '_load_translated_keywords'):
cmd_instance._load_translated_keywords()
except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError) as e:
self.logger.warning(f"Failed to reload translator: {e}")
# Update solar conditions config
set_config(self.config)
# Update command manager (keywords, custom syntax, banned users, monitor channels)
if hasattr(self, 'command_manager'):
self.command_manager.keywords = self.command_manager.load_keywords()
self.command_manager.custom_syntax = self.command_manager.load_custom_syntax()
self.command_manager.banned_users = self.command_manager.load_banned_users()
self.command_manager.monitor_channels = self.command_manager.load_monitor_channels()
self.logger.info("Command manager config reloaded")
# Update scheduler (scheduled messages)
if hasattr(self, 'scheduler'):
self.scheduler.setup_scheduled_messages()
self.logger.info("Scheduler config reloaded")
# Update channel manager max_channels if changed
if hasattr(self, 'channel_manager'):
new_max_channels = self.config.getint('Bot', 'max_channels', fallback=40)
if hasattr(self.channel_manager, 'max_channels'):
old_max_channels = self.channel_manager.max_channels
self.channel_manager.max_channels = new_max_channels
if old_max_channels != new_max_channels:
self.logger.info(f"Channel manager max_channels updated to {new_max_channels}")
# Note: We don't invalidate the channel cache here because channels are fetched
# from the device, not from config. The cache should remain valid after reload.
# Note: Service plugins check config on-demand, so they'll pick up changes automatically
# Feed manager and other services that need explicit reload can be added here if needed
self.logger.info("Configuration reloaded successfully")
return (True, "Configuration reloaded successfully")
except Exception as e:
error_msg = f"Error reloading configuration: {e}"
self.logger.error(error_msg)
import traceback
self.logger.error(traceback.format_exc())
return (False, error_msg)
def create_default_config(self) -> None:
"""Create default configuration file.
+4
View File
@@ -42,6 +42,10 @@ class MessageScheduler:
def setup_scheduled_messages(self):
"""Setup scheduled messages from config"""
# Clear existing scheduled jobs to avoid duplicates on reload
schedule.clear()
self.scheduled_messages.clear()
if self.bot.config.has_section('Scheduled_Messages'):
self.logger.info("Found Scheduled_Messages section")
for time_str, message_info in self.bot.config.items('Scheduled_Messages'):