From f86fe0b140e7945c53b919dda0aa0607205ef791 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 7 Mar 2026 12:31:54 -0800 Subject: [PATCH] Add support for local plugins configuration and directory handling - Updated `config.ini.example` and `config.ini.minimal-example` to include optional `local_dir_path` for specifying a custom local plugins directory. - Enhanced `install-service.sh` to preserve the `local/` directory during installation and create its structure if it doesn't exist. - Modified `CommandManager` and `MeshCoreBot` to resolve the local commands and services directory based on the new configuration. - Added validation in `config_validation.py` to check the existence and readability of the specified local plugins path. - Improved documentation in `local-plugins.md` to clarify the usage of local plugins and the new configuration options. --- config.ini.example | 6 ++++++ config.ini.minimal-example | 3 +++ docs/local-plugins.md | 4 +++- install-service.sh | 27 +++++++++++++++++++++++++++ modules/command_manager.py | 7 +++++-- modules/config_validation.py | 20 ++++++++++++++++++++ modules/core.py | 13 ++++++++++--- 7 files changed, 74 insertions(+), 6 deletions(-) diff --git a/config.ini.example b/config.ini.example index 91c1993..48b856a 100644 --- a/config.ini.example +++ b/config.ini.example @@ -130,6 +130,12 @@ auto_manage_contacts = bot # Default: meshcore_bot.db db_path = meshcore_bot.db +# Local plugins directory (optional) +# Directory containing commands/, service_plugins/, and optional config.ini. +# Relative paths are resolved from the bot root; absolute paths used as-is. +# Default: local (i.e. bot_root/local). Changing this requires a bot restart. +#local_dir_path = local + # Seconds to wait after a failed service restart before retrying (default: 300) service_restart_backoff_seconds = 300 diff --git a/config.ini.minimal-example b/config.ini.minimal-example index 7ae8eac..638b610 100644 --- a/config.ini.minimal-example +++ b/config.ini.minimal-example @@ -143,6 +143,9 @@ auto_manage_contacts = false # Default: meshcore_bot.db db_path = meshcore_bot.db +# Local plugins directory (optional). Default: local. See config.ini.example. +#local_dir_path = local + [Localization] # Language code for bot responses (en, es, es-MX, es-ES, fr, de, ja, etc.) # Default: en (English) diff --git a/docs/local-plugins.md b/docs/local-plugins.md index 181215d..03b912c 100644 --- a/docs/local-plugins.md +++ b/docs/local-plugins.md @@ -1,6 +1,6 @@ # Local plugins and services -You can add your own **command plugins** and **service plugins** without modifying the bot’s code by placing them in the **`local/`** directory. Their configuration can live in **`local/config.ini`** so it stays separate from the main `config.ini`. +You can add your own **command plugins** and **service plugins** without modifying the bot’s code by placing them in a **local** directory. By default that directory is **`local/`** next to your main config.ini; you can change it with **`[Bot]` `local_dir_path`** (see below). Their configuration can live in **`local/config.ini`** so it stays separate from the main `config.ini`. ## Directories @@ -12,6 +12,8 @@ You can add your own **command plugins** and **service plugins** without modifyi Local plugins are **additive**: they are loaded after built-in (and alternative) plugins. If a local plugin or service has the same logical **name** as one already loaded, it is **skipped** and a warning is logged. There is no override-by-name for local code. +**Custom local path:** In `config.ini`, under `[Bot]`, you can set **`local_dir_path`** to a directory that contains `commands/`, `service_plugins/`, and optional `config.ini`. Use a relative path (resolved from the bot root) or an absolute path so the local folder can live outside the install directory (e.g. `/home/user/meshcore-local`). Changing `local_dir_path` requires a **bot restart**. + ## Minimal command plugin Create a file in **local/commands/** (e.g. `local/commands/hello_local.py`): diff --git a/install-service.sh b/install-service.sh index 991decc..9561341 100755 --- a/install-service.sh +++ b/install-service.sh @@ -308,6 +308,12 @@ copy_files_smart() { print_info "Preserving existing alternative commands (only updating if source is newer)" fi + # Preserve install dir's local/ entirely (user custom commands and service plugins) + # Never overwrite or delete anything under $dest_dir/local/ + if [ -d "$dest_dir/local" ]; then + print_info "Preserving existing local/ directory (not overwriting)" + fi + # Exclude patterns rsync -a --update --exclude='.git' \ --exclude='__pycache__' \ @@ -320,10 +326,20 @@ copy_files_smart() { --exclude='*.db-wal' \ --exclude='*.log' \ --exclude='backups' \ + --exclude='local/' \ $preserve_config \ "$source_dir/" "$dest_dir/" 2>/dev/null || { print_warning "rsync had some issues, falling back to manual copy" } + # If install dir has no local/, create minimal structure from source so service has valid layout + if [ ! -d "$dest_dir/local" ]; then + print_info "Creating local/ directory structure (first-time install)" + mkdir -p "$dest_dir/local/commands" "$dest_dir/local/service_plugins" + [ -f "$source_dir/local/README.md" ] && cp "$source_dir/local/README.md" "$dest_dir/local/" || true + [ -f "$source_dir/local/__init__.py" ] && cp "$source_dir/local/__init__.py" "$dest_dir/local/" || true + [ -f "$source_dir/local/commands/.gitkeep" ] && cp "$source_dir/local/commands/.gitkeep" "$dest_dir/local/commands/" || true + [ -f "$source_dir/local/service_plugins/.gitkeep" ] && cp "$source_dir/local/service_plugins/.gitkeep" "$dest_dir/local/service_plugins/" || true + fi print_success "Files synchronized using rsync" return 0 fi @@ -336,6 +352,11 @@ copy_files_smart() { print_info "Preserving existing alternative commands (not overwriting)" fi + # Preserve install dir's local/ entirely - never overwrite when it exists + if [ -d "$dest_dir/local" ]; then + print_info "Preserving existing local/ directory (not overwriting)" + fi + # Copy files, preserving config.ini if it exists while IFS= read -r file; do local rel_path="${file#$source_dir/}" @@ -355,6 +376,12 @@ copy_files_smart() { [[ "$rel_path" == *".log" ]] && continue [[ "$rel_path" == *"/backups/"* ]] && continue + # Preserve install dir's local/ entirely - skip all files under local/ when dest has local/ + if [[ "$rel_path" == "local/"* ]] && [ -d "$dest_dir/local" ]; then + files_skipped=$((files_skipped + 1)) + continue + fi + # Preserve alternatives directory - only update if source file is newer if [[ "$rel_path" == "modules/commands/alternatives/"* ]] && [ -d "$dest_dir/modules/commands/alternatives" ]; then if [ -f "$dest_file" ]; then diff --git a/modules/command_manager.py b/modules/command_manager.py index e8db587..daadf61 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -89,9 +89,12 @@ class CommandManager: self.command_prefix = self.load_command_prefix() # Initialize plugin loader and load all plugins - self.plugin_loader = PluginLoader( - bot, local_commands_dir=str(bot.bot_root / "local" / "commands") + local_commands_dir = ( + str(bot._local_root / "commands") + if getattr(bot, "_local_root", None) is not None + else str(bot.bot_root / "local" / "commands") ) + self.plugin_loader = PluginLoader(bot, local_commands_dir=local_commands_dir) self.commands = self.plugin_loader.load_all_plugins() # Cache for internet connectivity status to avoid checking on every command diff --git a/modules/config_validation.py b/modules/config_validation.py index 5c81c91..9156788 100644 --- a/modules/config_validation.py +++ b/modules/config_validation.py @@ -198,6 +198,26 @@ def validate_config(config_path: str) -> List[Tuple[str, str]]: msg = _check_path_writable(db_path, bot_root, "Database path") if msg: results.append((SEVERITY_WARNING, msg)) + # Optional: local_dir_path should exist and be readable when set + local_dir = config.get("Bot", "local_dir_path", fallback="").strip() + if local_dir: + try: + resolved = _resolve_path(local_dir, bot_root) + if not resolved.exists(): + results.append(( + SEVERITY_WARNING, + f"Local plugins path '{resolved}' does not exist; local commands/plugins will not load.", + )) + elif not os.access(str(resolved), os.R_OK): + results.append(( + SEVERITY_WARNING, + f"Local plugins path '{resolved}' is not readable.", + )) + except (OSError, RuntimeError): + results.append(( + SEVERITY_WARNING, + f"Local plugins path: cannot resolve '{local_dir}'.", + )) if config.has_section("Logging"): log_file = config.get("Logging", "log_file", fallback="").strip() if log_file: diff --git a/modules/core.py b/modules/core.py index 6099e43..a27b0fd 100644 --- a/modules/core.py +++ b/modules/core.py @@ -214,7 +214,7 @@ class MeshCoreBot: self.logger.info("Initializing service plugin loader") try: self.service_loader = ServicePluginLoader( - self, local_services_dir=str(self.bot_root / "local" / "service_plugins") + self, local_services_dir=str(self._local_root / "service_plugins") ) self.services = self.service_loader.load_all_services() self.logger.info(f"Service plugin loader initialized with {len(self.services)} service(s)") @@ -269,8 +269,15 @@ class MeshCoreBot: # Force UTF-8 so emoji and non-ASCII characters in config.ini parse on Windows. self.config.read(self.config_file, encoding="utf-8") + # Resolve local plugins directory from [Bot] local_dir_path (default: local) + self._local_root = Path( + resolve_path( + self.config.get("Bot", "local_dir_path", fallback="local"), + self.bot_root, + ) + ) # Merge local config if present (user plugins/services settings) - local_config = self.bot_root / "local" / "config.ini" + local_config = self._local_root / "config.ini" if local_config.exists(): self.config.read(local_config, encoding="utf-8") @@ -335,7 +342,7 @@ class MeshCoreBot: # Reload the config self.config.read(self.config_file, encoding="utf-8") - local_config = self.bot_root / "local" / "config.ini" + local_config = self._local_root / "config.ini" if local_config.exists(): self.config.read(local_config, encoding="utf-8")