From db0db96f7ebe4c815ac83c93d0ef6b43726bdafe Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 16 Jan 2026 19:55:44 -0800 Subject: [PATCH] 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. --- config.ini.example | 3 +- modules/commands/reload_command.py | 68 +++++++++++++++ modules/core.py | 135 ++++++++++++++++++++++++++++- modules/scheduler.py | 4 + 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 modules/commands/reload_command.py diff --git a/config.ini.example b/config.ini.example index 3669188..977b30d 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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 diff --git a/modules/commands/reload_command.py b/modules/commands/reload_command.py new file mode 100644 index 0000000..15876ef --- /dev/null +++ b/modules/commands/reload_command.py @@ -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 diff --git a/modules/core.py b/modules/core.py index c20b442..a2c222d 100644 --- a/modules/core.py +++ b/modules/core.py @@ -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. diff --git a/modules/scheduler.py b/modules/scheduler.py index ecdd03e..18b9975 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -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'):