diff --git a/.gitignore b/.gitignore index 934a282..6b7cb8e 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,10 @@ config-pymc.ini data/* !data/.gitkeep +# Local user plugins and config (keep README and .gitkeep only) +local/config.ini +local/commands/* +!local/commands/.gitkeep +local/service_plugins/* +!local/service_plugins/.gitkeep + diff --git a/config.ini.example b/config.ini.example index a805082..91c1993 100644 --- a/config.ini.example +++ b/config.ini.example @@ -221,6 +221,10 @@ admin_commands = repeater,webviewer,reload # # Note: The alternative plugin must have the same 'name' metadata as the command # it's replacing, or the override will use the alternative plugin's name instead. +# +# Local plugins: You can add your own command and service plugins without editing +# bot code. Put command plugins in local/commands/ and service plugins in +# local/service_plugins/. Use local/config.ini for their settings. See docs/local-plugins.md. [Companion_Purge] # Enable companion contact purging @@ -1562,3 +1566,24 @@ enabled = false # See docs/telegram-bridge.md for setup # bridge.Public = @YourChannelName # bridge.emergency = -1001234567890 + +# ----------------------------------------------------------------------------- +# Check-in service (local plugin: local/service_plugins/checkin_service.py) +# Put [CheckIn] in local/config.ini to keep main config clean. See docs/checkin-api.md for API contract. +# ----------------------------------------------------------------------------- +# [CheckIn] +# enabled = true +# Channel to collect check-ins from (default: #meshmonday) +# channel = #meshmonday +# When to collect: monday (only Mondays) or daily +# check_in_days = monday +# If any_message_counts = true, any message in the channel counts as a check-in. +# If false, only messages containing require_phrase (case-insensitive) count. +# any_message_counts = false +# require_phrase = check in +# Time of day to flush collected check-ins and submit to API (HH:MM or HHMM). Uses [Bot] timezone. +# flush_time = 23:59 +# Optional: submit check-ins to a web API (POST). If set, api_key or CHECKIN_API_KEY env is required. +# api_url = https://example.com/checkins +# api_key = YOUR_API_KEY +# Or set CHECKIN_API_KEY in the environment (takes precedence). diff --git a/docs/checkin-api.md b/docs/checkin-api.md new file mode 100644 index 0000000..35a3c51 --- /dev/null +++ b/docs/checkin-api.md @@ -0,0 +1,137 @@ +# Check-in API contract + +The **Check-in service** (local plugin `local/service_plugins/checkin_service.py`) can submit collected check-ins to a web API. This document describes the contract so you can run or build a server that accepts submissions from one or more bots. The bot is the client; the server is not part of this repo. + +## Authentication + +- **Header**: `Authorization: Bearer ` +- The bot sends the API key configured in `[CheckIn]` `api_key` or the `CHECKIN_API_KEY` environment variable. +- Server should validate the key (e.g. constant-time comparison against a configured secret) and return **401 Unauthorized** if missing or invalid. +- Use HTTPS in production so the key is not sent in the clear. + +## Endpoint + +- **Method**: `POST` +- **URL**: Configured by the bot as `[CheckIn]` `api_url` (e.g. `https://example.com/checkins` or `https://example.com/v1/checkins`). +- **Content-Type**: `application/json` + +## Request body + +Each request is a single check-in. The bot sends one POST per check-in when flushing (e.g. daily at flush time). + +| Field | Type | Description | +|----------------|--------|-------------| +| `packet_hash` | string | Unique id for this check-in (from packet or fallback hash). Server should use this for deduplication. | +| `username` | string | Sender name (from "SENDER: message" on the mesh). | +| `message` | string | Message content (part after the colon). | +| `channel` | string | Channel name (e.g. "#meshmonday"). | +| `timestamp` | string | ISO 8601 datetime when the check-in was received (bot timezone). | +| `source_bot` | string | Optional. Bot name if configured; useful when multiple bots submit. | + +Example: + +```json +{ + "packet_hash": "A1B2C3D4E5F67890", + "username": "HOWL", + "message": "check in", + "channel": "#meshmonday", + "timestamp": "2025-03-03T14:30:00-07:00", + "source_bot": "meshcore-bot" +} +``` + +## Deduplication + +- **packet_hash** identifies the same logical check-in across bots and retries. Multiple bots that hear the same packet will send the same `packet_hash`; the server should store at most one record per `packet_hash` (e.g. upsert or ignore duplicate). +- The bot sends each check-in once per flush; retries on 5xx/429 may send the same body again, so idempotency by `packet_hash` is required. + +## Response + +- **200 OK** or **201 Created**: Success. Body is optional. +- **401 Unauthorized**: Invalid or missing API key. +- **4xx/5xx**: Bot may retry once after a short delay (e.g. 5 s) for 429 and 5xx; then it logs and continues. No need to return a specific body for errors. + +## Example server (sketch) + +A minimal server could: + +1. Verify `Authorization: Bearer ` against a configured key. +2. Parse JSON body; validate required fields (`packet_hash`, `username`, `message`, `channel`, `timestamp`). +3. Upsert into a database keyed by `packet_hash` (e.g. SQLite or Postgres). +4. Return 201 with an empty or minimal JSON body. + +No reference server is included in this repo; use any stack (e.g. Flask, FastAPI) that supports HTTP and env-based secrets. + +## Example receiver (stdlib) + +The repo includes a **stdlib-only** reference server you can run behind nginx with no pip or virtualenv. + +### Script + +- **Location**: [scripts/checkin_receiver.py](../scripts/checkin_receiver.py) +- **Dependencies**: Python 3 standard library only (`json`, `sqlite3`, `secrets`, `http.server`, etc.) + +### Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `CHECKIN_API_SECRET` | Yes | Bearer secret; must match the bot's `[CheckIn]` `api_key` (or `CHECKIN_API_KEY` env). | +| `CHECKIN_PORT` | No | Port to bind (default `9999`). | +| `CHECKIN_DB_PATH` | No | SQLite file path (default `./checkins.db`). Parent directory is created if missing. | + +Use HTTPS in production so the key is not sent in the clear. Run the script on `127.0.0.1` and put nginx in front for TLS termination. + +### Nginx (minimal) + +Proxy a location to the script's port (TLS and server name are configured elsewhere): + +```nginx +location /checkins { + limit_except POST { deny all; } + client_max_body_size 4k; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_pass http://127.0.0.1:9999; +} +``` + +Use the same port as `CHECKIN_PORT` (e.g. 9999). The script accepts both `/` and `/checkins` for POST; GET to `/` or `/checkins` returns `{"status":"ok"}` for health checks. + +### Systemd + +Example unit `/etc/systemd/system/checkin-receiver.service`: + +```ini +[Unit] +Description=Check-in API receiver (meshcore-bot) +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /path/to/meshcore-bot/scripts/checkin_receiver.py +Environment=CHECKIN_API_SECRET=your_secret_here +Environment=CHECKIN_PORT=9999 +Environment=CHECKIN_DB_PATH=/var/lib/checkin/checkins.db +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +Create the database directory and set permissions as needed: + +```bash +sudo mkdir -p /var/lib/checkin +sudo chown www-data:www-data /var/lib/checkin # or the user running the script +``` + +Reload and enable: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now checkin-receiver +``` + +Configure the bot's `[CheckIn]` `api_url` to your public URL (e.g. `https://yourdomain.com/checkins`) and set `api_key` (or `CHECKIN_API_KEY`) to the same value as `CHECKIN_API_SECRET` on the server. diff --git a/docs/local-plugins.md b/docs/local-plugins.md new file mode 100644 index 0000000..9656596 --- /dev/null +++ b/docs/local-plugins.md @@ -0,0 +1,92 @@ +# 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`. + +## Directories + +| Path | Purpose | +|------|---------| +| **local/commands/** | One Python file per command plugin (subclass of `BaseCommand`). | +| **local/service_plugins/** | One Python file per service plugin (subclass of `BaseServicePlugin`). | +| **local/config.ini** | Optional. Merged with main config; use it for your plugins’ sections. | + +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. + +## Minimal command plugin + +Create a file in **local/commands/** (e.g. `local/commands/hello_local.py`): + +```python +# local/commands/hello_local.py +from modules.commands.base_command import BaseCommand +from modules.models import MeshMessage + + +class HelloLocalCommand(BaseCommand): + name = "hellolocal" + keywords = ["hellolocal", "hi local"] + description = "A local greeting command" + + async def execute(self, message: MeshMessage) -> bool: + return await self.handle_keyword_match(message) +``` + +- The bot discovers all `.py` files in `local/commands/` (except `__init__.py`). +- Each file must define exactly one class that inherits from `BaseCommand` and is not the base class itself. +- Use `bot.config` for options; you can put your section in **local/config.ini** (e.g. `[HelloLocal_Command]`) and read with `self.get_config_value('HelloLocal_Command', 'enabled', fallback=True, value_type='bool')` or `self.bot.config.get(...)`. + +Restart the bot (or ensure the directory exists and the file is in place before starting). The command will be registered like any other. + +## Minimal service plugin + +Create a file in **local/service_plugins/** (e.g. `local/service_plugins/my_background_service.py`): + +```python +# local/service_plugins/my_background_service.py +from modules.service_plugins.base_service import BaseServicePlugin + + +class MyBackgroundService(BaseServicePlugin): + config_section = "MyBackground" + description = "A local background service" + + async def start(self) -> None: + self._running = True + self.logger.info("MyBackground service started") + + async def stop(self) -> None: + self._running = False + self.logger.info("MyBackground service stopped") +``` + +- The bot discovers all `.py` files in `local/service_plugins/` (excluding `__init__.py`, `base_service.py`, and `*_utils.py`). +- The class must inherit from `BaseServicePlugin` and implement `start()` and `stop()`. +- To enable it, add a section in **local/config.ini** (or main config) with `enabled = true`: + +```ini +[MyBackground] +enabled = true +``` + +Restart the bot so the service is loaded and started. + +## Configuration + +- **Main config** is read first, then **local/config.ini** if it exists. So `bot.config` contains both; later file wins on overlapping sections/keys. +- Put options for your local plugins in **local/config.ini** to keep main `config.ini` clean. Use the same section naming as built-in plugins (e.g. `[MyCommand_Command]` for a command, or a `config_section` for a service). +- After a **config reload** (e.g. via the `reload` command), both main config and `local/config.ini` are re-read, so on-demand config in your plugins will see updates. Plugin/service instances are not reloaded; only config values. + +## Duplicate names + +If a local command or service has the same **name** as an already-loaded plugin or service (e.g. you add `local/commands/ping.py` with `name = "ping"`), the local one is **skipped** and a warning is logged. Choose a different name (e.g. `pinglocal`) to avoid the conflict. + +## References + +- [Service plugins](service-plugins.md) — built-in services and how they are enabled. +- [Check-in API](checkin-api.md) — contract for the optional check-in submission API (local check-in service). +- Built-in command plugins live in **modules/commands/** and **modules/commands/alternatives/**; you can use them as examples for `BaseCommand`, `get_config_value`, `handle_keyword_match`, etc. +- Base classes: **modules/commands/base_command.py** (`BaseCommand`), **modules/service_plugins/base_service.py** (`BaseServicePlugin`). + +## Check-in service (local) + +The repo includes a local service plugin **`local/service_plugins/checkin_service.py`** that collects check-ins from a channel (default `#meshmonday`) on a chosen day (Monday only or daily). You can require a specific phrase (e.g. "check in") or count any message. Optionally it submits check-in data (packet hash, username, message) to a web API secured with an API key. Configuration belongs in **local/config.ini** under `[CheckIn]`. See **config.ini.example** for a commented `[CheckIn]` block and **[Check-in API](checkin-api.md)** for the API contract if you run or build a server to receive submissions. diff --git a/local/README.md b/local/README.md new file mode 100644 index 0000000..4711b13 --- /dev/null +++ b/local/README.md @@ -0,0 +1,9 @@ +# Local plugins and services + +Place your own command plugins and service plugins here so they stay separate from bot-provided code. + +- **local/commands/** — Python files each defining a command (subclass of `BaseCommand`). Loaded after built-in commands; duplicate names are skipped. +- **local/service_plugins/** — Python files each defining a background service (subclass of `BaseServicePlugin`). Loaded after built-in services; duplicate names are skipped. +- **local/config.ini** — Optional. Merged with main `config.ini`; use it for sections and options for your local plugins and services. + +See **docs/local-plugins.md** (or the "Local plugins and services" section in the docs) for how to write a minimal plugin and configure it. diff --git a/local/__init__.py b/local/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local/commands/.gitkeep b/local/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/local/service_plugins/.gitkeep b/local/service_plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/mkdocs.yml b/mkdocs.yml index aab72d8..56b47fe 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,6 +57,8 @@ nav: - Configuration: - Overview: configuration.md - Data retention: data-retention.md + - Local plugins and services: local-plugins.md + - Check-in API: checkin-api.md - Path Command: path-command-config.md - Config validation: config-validation.md - Web Viewer: web-viewer.md diff --git a/modules/command_manager.py b/modules/command_manager.py index 6d99ef0..b86eee7 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -89,7 +89,9 @@ class CommandManager: self.command_prefix = self.load_command_prefix() # Initialize plugin loader and load all plugins - self.plugin_loader = PluginLoader(bot) + self.plugin_loader = PluginLoader( + bot, local_commands_dir=str(bot.bot_root / "local" / "commands") + ) self.commands = self.plugin_loader.load_all_plugins() # Cache for internet connectivity status to avoid checking on every command diff --git a/modules/core.py b/modules/core.py index f99fad7..724b491 100644 --- a/modules/core.py +++ b/modules/core.py @@ -213,7 +213,9 @@ class MeshCoreBot: # Initialize service plugin loader and load all services self.logger.info("Initializing service plugin loader") try: - self.service_loader = ServicePluginLoader(self) + self.service_loader = ServicePluginLoader( + self, local_services_dir=str(self.bot_root / "local" / "service_plugins") + ) self.services = self.service_loader.load_all_services() self.logger.info(f"Service plugin loader initialized with {len(self.services)} service(s)") except (OSError, ImportError, AttributeError, ValueError) as e: @@ -267,7 +269,11 @@ 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") - + # Merge local config if present (user plugins/services settings) + local_config = self.bot_root / "local" / "config.ini" + if local_config.exists(): + self.config.read(local_config, encoding="utf-8") + def _get_radio_settings(self) -> Dict[str, Any]: """Get current radio/connection settings from config. @@ -329,6 +335,9 @@ class MeshCoreBot: # Reload the config self.config.read(self.config_file, encoding="utf-8") + local_config = self.bot_root / "local" / "config.ini" + if local_config.exists(): + self.config.read(local_config, encoding="utf-8") # Update rate limiters new_rate_limit = self.config.getint('Bot', 'rate_limit_seconds', fallback=10) diff --git a/modules/plugin_loader.py b/modules/plugin_loader.py index d1b8ba5..14ab89c 100644 --- a/modules/plugin_loader.py +++ b/modules/plugin_loader.py @@ -19,11 +19,20 @@ from .commands.base_command import BaseCommand class PluginLoader: """Handles dynamic loading and discovery of command plugins""" - def __init__(self, bot, commands_dir: str = None): + def __init__(self, bot, commands_dir: str = None, local_commands_dir: Optional[str] = None): self.bot = bot self.logger = bot.logger self.commands_dir = commands_dir or os.path.join(os.path.dirname(__file__), 'commands') self.alternatives_dir = os.path.join(self.commands_dir, 'alternatives') + if local_commands_dir is not None: + self.local_commands_dir = local_commands_dir + else: + bot_root = getattr(bot, 'bot_root', None) + if bot_root is not None: + path = Path(bot_root) / "local" / "commands" + self.local_commands_dir = str(path) if path.exists() else None + else: + self.local_commands_dir = None self.loaded_plugins: Dict[str, BaseCommand] = {} self.plugin_metadata: Dict[str, Dict[str, Any]] = {} self.keyword_mappings: Dict[str, str] = {} # keyword -> plugin_name @@ -86,6 +95,21 @@ class PluginLoader: self.logger.info(f"Discovered {len(plugin_files)} alternative plugin files: {plugin_files}") return plugin_files + def discover_local_plugins(self) -> List[str]: + """Discover Python files in local/commands (stems only). Skip __init__.py.""" + if not self.local_commands_dir: + return [] + path = Path(self.local_commands_dir) + if not path.exists(): + return [] + stems = [] + for file_path in path.glob("*.py"): + if file_path.name != "__init__.py": + stems.append(file_path.stem) + if stems: + self.logger.info(f"Discovered {len(stems)} local plugin file(s): {stems}") + return stems + def _validate_plugin(self, plugin_class: Type[BaseCommand]) -> List[str]: """ Validate a plugin class has required attributes before instantiation. @@ -221,6 +245,56 @@ class PluginLoader: self._failed_plugins[plugin_name] = error_msg return None + def load_plugin_from_path(self, file_path: Path) -> Optional[BaseCommand]: + """Load a single plugin from a file path (e.g. local/commands/my_command.py).""" + stem = file_path.stem + module_name = f"local_plugins.{stem}" + try: + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + self.logger.warning(f"Could not create spec for {file_path}") + return None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + command_class = None + for _name, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, BaseCommand) + and obj != BaseCommand + and obj.__module__ == module_name + ): + command_class = obj + break + if not command_class: + error_msg = f"No valid command class found in {stem}" + self.logger.warning(error_msg) + self._failed_plugins[stem] = error_msg + return None + validation_errors = self._validate_plugin(command_class) + if validation_errors: + error_msg = f"Plugin validation failed: {', '.join(validation_errors)}" + self.logger.error(f"Failed to load local plugin '{stem}': {error_msg}") + self._failed_plugins[stem] = error_msg + return None + plugin_instance = command_class(self.bot) + if not hasattr(plugin_instance, 'name') or not plugin_instance.name: + derived_name = command_class.__name__.lower().replace('command', '') + plugin_instance.name = derived_name + instance_validation_errors = self._validate_plugin_instance(plugin_instance, stem) + if instance_validation_errors: + error_msg = f"Plugin instance validation failed: {', '.join(instance_validation_errors)}" + self.logger.error(f"Failed to load local plugin '{stem}': {error_msg}") + self._failed_plugins[stem] = error_msg + return None + self.logger.info(f"Successfully loaded local plugin: {plugin_instance.get_metadata()['name']} from {stem}") + return plugin_instance + except Exception as e: + error_msg = str(e) + self.logger.error(f"Failed to load local plugin '{stem}': {error_msg}") + self._failed_plugins[stem] = error_msg + return None + def load_all_plugins(self) -> Dict[str, BaseCommand]: """Load all discovered plugins, with alternative plugins taking priority when configured""" # First, discover all default and alternative plugins @@ -318,6 +392,25 @@ class PluginLoader: loaded_plugins[alt_plugin_name] = alt_instance self.plugin_metadata[alt_plugin_name] = alt_metadata + # Fourth pass: Load local plugins from local/commands (additive; duplicate names skipped) + if self.local_commands_dir: + local_path = Path(self.local_commands_dir) + for stem in self.discover_local_plugins(): + file_path = local_path / f"{stem}.py" + if not file_path.is_file(): + continue + plugin_instance = self.load_plugin_from_path(file_path) + if plugin_instance: + metadata = plugin_instance.get_metadata() + plugin_name = metadata['name'] + if plugin_name in loaded_plugins: + self.logger.warning( + f"Local plugin '{stem}' has name '{plugin_name}' which is already loaded; skipping" + ) + continue + loaded_plugins[plugin_name] = plugin_instance + self.plugin_metadata[plugin_name] = metadata + # Build keyword mappings for all loaded plugins for plugin_name, plugin_instance in loaded_plugins.items(): metadata = self.plugin_metadata[plugin_name] diff --git a/modules/service_plugin_loader.py b/modules/service_plugin_loader.py index 1e746ff..2d5bf4f 100644 --- a/modules/service_plugin_loader.py +++ b/modules/service_plugin_loader.py @@ -7,6 +7,7 @@ Handles scanning, loading, and registering service plugins import os import sys import importlib +import importlib.util import inspect from pathlib import Path from typing import Dict, List, Any, Optional, Type @@ -17,12 +18,21 @@ from .service_plugins.base_service import BaseServicePlugin class ServicePluginLoader: """Handles dynamic loading and discovery of service plugins""" - def __init__(self, bot, services_dir: str = None): + def __init__(self, bot, services_dir: str = None, local_services_dir: Optional[str] = None): self.bot = bot self.logger = bot.logger self.services_dir = services_dir or os.path.join( os.path.dirname(__file__), 'service_plugins' ) + if local_services_dir is not None: + self.local_services_dir = local_services_dir + else: + bot_root = getattr(bot, 'bot_root', None) + if bot_root is not None: + path = Path(bot_root) / "local" / "service_plugins" + self.local_services_dir = str(path) if path.exists() else None + else: + self.local_services_dir = None self.loaded_services: Dict[str, BaseServicePlugin] = {} self.service_metadata: Dict[str, Dict[str, Any]] = {} self.service_overrides: Dict[str, str] = {} @@ -59,6 +69,22 @@ class ServicePluginLoader: self.logger.info(f"Discovered {len(service_files)} potential service files: {service_files}") return service_files + def discover_local_services(self) -> List[str]: + """Discover Python files in local/service_plugins (stems). Same exclusions as built-in.""" + if not self.local_services_dir: + return [] + path = Path(self.local_services_dir) + if not path.exists(): + return [] + excluded = ["__init__.py", "base_service.py"] + stems = [] + for file_path in path.glob("*.py"): + if file_path.name not in excluded and not file_path.name.endswith("_utils.py"): + stems.append(file_path.stem) + if stems: + self.logger.info(f"Discovered {len(stems)} local service file(s): {stems}") + return stems + def load_service(self, service_name: str) -> Optional[BaseServicePlugin]: """Load a single service plugin by name""" try: @@ -115,6 +141,54 @@ class ServicePluginLoader: self.logger.debug(traceback.format_exc()) return None + def load_service_from_path(self, file_path: Path) -> Optional[BaseServicePlugin]: + """Load a single service plugin from a file path (e.g. local/service_plugins/my_service.py).""" + stem = file_path.stem + module_name = f"local_services.{stem}" + try: + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + self.logger.warning(f"Could not create spec for {file_path}") + return None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + service_class = None + for _name, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, BaseServicePlugin) + and obj != BaseServicePlugin + and obj.__module__ == module_name + ): + service_class = obj + break + if not service_class: + self.logger.warning(f"No valid service class found in {stem}") + return None + config_section = self._get_config_section_for_service(service_class) + if config_section and self.bot.config.has_section(config_section): + enabled = self.bot.config.getboolean(config_section, 'enabled', fallback=False) + if not enabled: + self.logger.info(f"Local service {stem} is disabled in config (section: {config_section})") + return None + elif config_section: + self.logger.info( + f"Local service {stem} config section '{config_section}' exists but 'enabled' not set, skipping" + ) + return None + service_instance = service_class(self.bot) + metadata = service_instance.get_metadata() + if not metadata.get('name'): + metadata['name'] = service_class.__name__.lower().replace('service', '') + service_instance.name = metadata['name'] + self.logger.info(f"Successfully loaded local service: {metadata['name']} from {stem}") + return service_instance + except Exception as e: + self.logger.error(f"Failed to load local service {stem}: {e}") + import traceback + self.logger.debug(traceback.format_exc()) + return None + def _get_config_section_for_service(self, service_class) -> Optional[str]: """Get config section name for a service class @@ -173,6 +247,25 @@ class ServicePluginLoader: loaded_services[service_name] = override_instance self.service_metadata[service_name] = override_metadata + # Load local services from local/service_plugins (additive; duplicate names skipped) + if self.local_services_dir: + local_path = Path(self.local_services_dir) + for stem in self.discover_local_services(): + file_path = local_path / f"{stem}.py" + if not file_path.is_file(): + continue + service_instance = self.load_service_from_path(file_path) + if service_instance: + metadata = service_instance.get_metadata() + service_name = metadata['name'] + if service_name in loaded_services: + self.logger.warning( + f"Local service '{stem}' has name '{service_name}' which is already loaded; skipping" + ) + continue + loaded_services[service_name] = service_instance + self.service_metadata[service_name] = metadata + self.loaded_services = loaded_services self.logger.info(f"Loaded {len(loaded_services)} service(s): {list(loaded_services.keys())}") return loaded_services diff --git a/scripts/checkin_receiver.py b/scripts/checkin_receiver.py new file mode 100644 index 0000000..6733494 --- /dev/null +++ b/scripts/checkin_receiver.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Check-in API receiver — stdlib-only HTTP server for the meshcore-bot Check-in API. + +Implements the contract in docs/checkin-api.md: POST JSON with Bearer auth, +upsert into SQLite by packet_hash. Run behind nginx with TLS. + +Environment: + CHECKIN_API_SECRET Required. Bearer token; must match bot [CheckIn] api_key. + CHECKIN_PORT Port to bind (default 9999). + CHECKIN_DB_PATH SQLite file path (default ./checkins.db). Parent dir created if missing. +""" + +import json +import logging +import os +import secrets +import sqlite3 +import sys +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Optional +from urllib.parse import urlparse + +REQUIRED_FIELDS = ("packet_hash", "username", "message", "channel", "timestamp") +DEFAULT_PORT = 9999 +DEFAULT_DB = "checkins.db" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS checkins ( + packet_hash TEXT PRIMARY KEY, + username TEXT NOT NULL, + message TEXT NOT NULL, + channel TEXT NOT NULL, + timestamp TEXT NOT NULL, + source_bot TEXT, + updated_at TEXT NOT NULL +); +""" + + +def get_env(key: str, default: str = "") -> str: + return os.environ.get(key, default).strip() + + +def init_db(db_path: str) -> None: + parent = os.path.dirname(db_path) + if parent: + os.makedirs(parent, exist_ok=True) + with sqlite3.connect(db_path) as conn: + conn.executescript(SCHEMA) + conn.commit() + + +def upsert_checkin(db_path: str, data: dict) -> None: + now = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + INSERT INTO checkins ( + packet_hash, username, message, channel, timestamp, source_bot, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(packet_hash) DO UPDATE SET + username = excluded.username, + message = excluded.message, + channel = excluded.channel, + timestamp = excluded.timestamp, + source_bot = excluded.source_bot, + updated_at = excluded.updated_at + """, + ( + data["packet_hash"], + data["username"], + data["message"], + data["channel"], + data["timestamp"], + data.get("source_bot") or "", + now, + ), + ) + conn.commit() + + +class CheckinHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _secret(self) -> str: + return get_env("CHECKIN_API_SECRET") + + def _db_path(self) -> str: + return get_env("CHECKIN_DB_PATH") or DEFAULT_DB + + def _send(self, code: int, body: str = "", content_type: str = "application/json") -> None: + self.send_response(code) + if body: + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body.encode("utf-8")))) + self.end_headers() + if body: + self.wfile.write(body.encode("utf-8")) + + def _bearer_token(self) -> Optional[str]: + auth = self.headers.get("Authorization") or "" + if auth.startswith("Bearer "): + return auth[7:].strip() + return None + + def _read_body(self) -> bytes: + length = self.headers.get("Content-Length") + if length is None: + return b"" + try: + n = int(length, 10) + except ValueError: + return b"" + if n <= 0 or n > 4096: + return b"" + return self.rfile.read(n) + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path in ("/", "/checkins"): + self._send(200, '{"status":"ok"}') + else: + self._send(404, '{"error":"not found"}') + + def do_POST(self) -> None: + parsed = urlparse(self.path) + if parsed.path != "/" and parsed.path != "/checkins": + self._send(404, '{"error":"not found"}') + return + + secret = self._secret() + if not secret: + self._send(500, '{"error":"server misconfiguration: CHECKIN_API_SECRET not set"}') + return + + token = self._bearer_token() + if token is None or not secrets.compare_digest(secret, token): + self._send(401, '{"error":"unauthorized"}') + return + + raw = self._read_body() + try: + data = json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logging.warning("CheckinReceiver: invalid JSON: %s", e) + self._send(400, '{"error":"invalid json"}') + return + + if not isinstance(data, dict): + self._send(400, '{"error":"body must be a json object"}') + return + + missing = [f for f in REQUIRED_FIELDS if not data.get(f)] + if missing: + self._send(400, json.dumps({"error": "missing fields", "fields": missing})) + return + + db_path = self._db_path() + try: + init_db(db_path) + upsert_checkin(db_path, data) + except Exception as e: + logging.exception("CheckinReceiver: db error: %s", e) + self._send(500, '{"error":"internal error"}') + return + + self._send(201, "{}") + + def log_message(self, format: str, *args: object) -> None: + logging.info("%s - %s", self.address_string(), format % args) + + +def main() -> int: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + stream=sys.stderr, + ) + port = DEFAULT_PORT + try: + p = get_env("CHECKIN_PORT") + if p: + port = int(p, 10) + except ValueError: + pass + + server_address = ("127.0.0.1", port) + httpd = HTTPServer(server_address, CheckinHandler) + logging.info("CheckinReceiver listening on http://%s:%s", server_address[0], server_address[1]) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_checkin_service.py b/tests/test_checkin_service.py new file mode 100644 index 0000000..ed0fa57 --- /dev/null +++ b/tests/test_checkin_service.py @@ -0,0 +1,135 @@ +"""Tests for local check-in service plugin.""" + +import configparser +import pytest +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +# Import from local plugin (repo root is on path when running tests) +import sys +_root = Path(__file__).resolve().parent.parent +if str(_root) not in sys.path: + sys.path.insert(0, str(_root)) +from local.service_plugins.checkin_service import CheckInService + + +def _make_bot(config_overrides=None): + """Build a mock bot with [CheckIn] and channel_manager.""" + bot = MagicMock() + bot.logger = Mock() + bot.logger.info = Mock() + bot.logger.warning = Mock() + bot.logger.error = Mock() + bot.logger.debug = Mock() + bot.config = configparser.ConfigParser() + bot.config.add_section("Connection") + bot.config.add_section("Bot") + bot.config.add_section("Channels") + bot.config.add_section("CheckIn") + bot.config.set("CheckIn", "enabled", "true") + bot.config.set("CheckIn", "channel", "#meshmonday") + bot.config.set("CheckIn", "check_in_days", "daily") + bot.config.set("CheckIn", "require_phrase", "check in") + bot.config.set("CheckIn", "any_message_counts", "false") + bot.config.set("CheckIn", "flush_time", "23:59") + bot.config.set("CheckIn", "api_url", "") + bot.config.set("CheckIn", "api_key", "") + if config_overrides: + for k, v in config_overrides.items(): + bot.config.set("CheckIn", k, str(v)) + channel_manager = MagicMock() + channel_manager.get_channel_name = Mock(return_value="#meshmonday") + bot.channel_manager = channel_manager + return bot + + +def _make_event(channel_idx=0, text="HOWL: check in", raw_hex=None): + event = MagicMock() + event.payload = { + "channel_idx": channel_idx, + "text": text, + } + if raw_hex is not None: + event.payload["raw_hex"] = raw_hex + return event + + +@pytest.mark.asyncio +async def test_message_wrong_channel_not_stored(): + """Message in a different channel is not stored.""" + bot = _make_bot() + bot.channel_manager.get_channel_name.return_value = "#other" + service = CheckInService(bot) + with patch("local.service_plugins.checkin_service.get_config_timezone") as gtz: + gtz.return_value = (MagicMock(), "America/Los_Angeles") + await service._on_channel_message(_make_event(), None) + assert not service._buckets + + +@pytest.mark.asyncio +async def test_message_correct_channel_with_phrase_stored(): + """Message in configured channel containing the phrase is stored.""" + bot = _make_bot() + service = CheckInService(bot) + with patch("local.service_plugins.checkin_service.get_config_timezone") as gtz: + tz = MagicMock() + gtz.return_value = (tz, "America/Los_Angeles") + with patch("local.service_plugins.checkin_service.datetime") as dt: + dt.now.return_value = datetime(2025, 3, 3, 12, 0, 0) # Monday + await service._on_channel_message(_make_event(text="HOWL: check in"), None) + assert len(service._buckets) == 1 + date_str = "2025-03-03" + assert date_str in service._buckets + records = service._buckets[date_str] + assert len(records) == 1 + rec = next(iter(records.values())) + assert rec["username"] == "HOWL" + assert rec["message"] == "check in" + assert "packet_hash" in rec + + +@pytest.mark.asyncio +async def test_message_without_phrase_when_required_not_stored(): + """When require_phrase is set and any_message_counts is false, message without phrase is not stored.""" + bot = _make_bot({"require_phrase": "check in", "any_message_counts": "false"}) + service = CheckInService(bot) + with patch("local.service_plugins.checkin_service.get_config_timezone") as gtz: + gtz.return_value = (MagicMock(), "America/Los_Angeles") + with patch("local.service_plugins.checkin_service.datetime") as dt: + dt.now.return_value = datetime(2025, 3, 3, 12, 0, 0) + await service._on_channel_message(_make_event(text="HOWL: hello world"), None) + assert not service._buckets + + +@pytest.mark.asyncio +async def test_any_message_counts_stored(): + """When any_message_counts is true, any message in the channel is stored.""" + bot = _make_bot({"any_message_counts": "true", "require_phrase": ""}) + service = CheckInService(bot) + with patch("local.service_plugins.checkin_service.get_config_timezone") as gtz: + gtz.return_value = (MagicMock(), "America/Los_Angeles") + with patch("local.service_plugins.checkin_service.datetime") as dt: + dt.now.return_value = datetime(2025, 3, 4, 14, 0, 0) + await service._on_channel_message(_make_event(text="ALICE: random message"), None) + assert len(service._buckets) == 1 + assert "2025-03-04" in service._buckets + records = service._buckets["2025-03-04"] + assert len(records) == 1 + rec = next(iter(records.values())) + assert rec["username"] == "ALICE" + assert rec["message"] == "random message" + + +@pytest.mark.asyncio +async def test_monday_only_skips_tuesday(): + """When check_in_days is monday, message on Tuesday is not stored.""" + bot = _make_bot({"check_in_days": "monday"}) + service = CheckInService(bot) + with patch("local.service_plugins.checkin_service.get_config_timezone") as gtz: + gtz.return_value = (MagicMock(), "America/Los_Angeles") + with patch("local.service_plugins.checkin_service.datetime") as dt: + # Tuesday 2025-03-04 + dt.now.return_value = datetime(2025, 3, 4, 12, 0, 0) + await service._on_channel_message(_make_event(text="HOWL: check in"), None) + assert not service._buckets diff --git a/tests/test_config_merge.py b/tests/test_config_merge.py new file mode 100644 index 0000000..50782e9 --- /dev/null +++ b/tests/test_config_merge.py @@ -0,0 +1,80 @@ +"""Tests for config loading and merging of local/config.ini.""" + +import pytest +from pathlib import Path + +from modules.core import MeshCoreBot + + +def _minimal_main_config(bot_root: Path, db_path: Path) -> str: + return f"""[Connection] +connection_type = ble + +[Bot] +db_path = {db_path.as_posix()} + +[Channels] +monitor_channels = #general +""" + + +class TestLoadConfigMerge: + """Test that load_config() merges local/config.ini when present.""" + + def test_load_config_merges_local_config_ini(self, tmp_path): + db_path = tmp_path / "bot.db" + main_config = tmp_path / "config.ini" + main_config.write_text( + _minimal_main_config(tmp_path, db_path), + encoding="utf-8", + ) + local_dir = tmp_path / "local" + local_dir.mkdir(parents=True) + local_config = local_dir / "config.ini" + local_config.write_text( + "[LocalExtra]\nmy_option = from_local\n", + encoding="utf-8", + ) + bot = MeshCoreBot(config_file=str(main_config)) + assert bot.config.has_section("LocalExtra") + assert bot.config.get("LocalExtra", "my_option") == "from_local" + + def test_load_config_no_local_file(self, tmp_path): + db_path = tmp_path / "bot.db" + main_config = tmp_path / "config.ini" + main_config.write_text( + _minimal_main_config(tmp_path, db_path), + encoding="utf-8", + ) + # No local/config.ini + bot = MeshCoreBot(config_file=str(main_config)) + assert not bot.config.has_section("LocalExtra") + + +class TestReloadConfigMerge: + """Test that reload_config() re-reads and merges local/config.ini.""" + + def test_reload_config_merges_local_config_ini(self, tmp_path): + db_path = tmp_path / "bot.db" + main_config = tmp_path / "config.ini" + main_config.write_text( + _minimal_main_config(tmp_path, db_path), + encoding="utf-8", + ) + local_dir = tmp_path / "local" + local_dir.mkdir(parents=True) + local_config = local_dir / "config.ini" + local_config.write_text( + "[LocalExtra]\nmy_option = from_local\n", + encoding="utf-8", + ) + bot = MeshCoreBot(config_file=str(main_config)) + assert bot.config.get("LocalExtra", "my_option") == "from_local" + # Update local config and reload + local_config.write_text( + "[LocalExtra]\nmy_option = updated_after_reload\n", + encoding="utf-8", + ) + success, _ = bot.reload_config() + assert success + assert bot.config.get("LocalExtra", "my_option") == "updated_after_reload" diff --git a/tests/test_plugin_loader.py b/tests/test_plugin_loader.py index 4c8de4d..d1253cf 100644 --- a/tests/test_plugin_loader.py +++ b/tests/test_plugin_loader.py @@ -1,6 +1,7 @@ """Tests for modules.plugin_loader.""" import pytest +from pathlib import Path from unittest.mock import Mock, MagicMock, AsyncMock from modules.plugin_loader import PluginLoader @@ -20,6 +21,7 @@ def loader_bot(mock_logger, minimal_config): bot.command_manager.monitor_channels = ["general"] bot.command_manager.send_response = AsyncMock(return_value=True) bot.meshcore = None + bot.bot_root = Path(__file__).resolve().parent.parent # repo root return bot @@ -159,3 +161,103 @@ class TestCategoryAndFailed: # Mutating the return should not affect internal state failed.clear() assert len(loader.get_failed_plugins()) > 0 + + +# Minimal local plugin source (valid BaseCommand subclass) +_LOCAL_PLUGIN_SOURCE = ''' +from modules.commands.base_command import BaseCommand +from modules.models import MeshMessage + + +class HelloLocalCommand(BaseCommand): + name = "hellolocal" + keywords = ["hellolocal", "hi local"] + description = "Local test command" + + async def execute(self, message: MeshMessage) -> bool: + return await self.handle_keyword_match(message) +''' + + +class TestLocalPlugins: + """Tests for local/commands discovery and loading.""" + + def test_discover_local_plugins_empty_when_no_dir(self, loader_bot): + loader = PluginLoader(loader_bot, local_commands_dir=None) + assert loader.discover_local_plugins() == [] + + def test_discover_local_plugins_empty_when_dir_missing(self, loader_bot, tmp_path): + missing = tmp_path / "local" / "commands" + loader = PluginLoader(loader_bot, local_commands_dir=str(missing)) + assert loader.discover_local_plugins() == [] + + def test_discover_local_plugins_finds_py_files(self, loader_bot, tmp_path): + local_dir = tmp_path / "local" / "commands" + local_dir.mkdir(parents=True) + (local_dir / "my_cmd.py").write_text("# test") + (local_dir / "other.py").write_text("# test") + (local_dir / "__init__.py").write_text("# init") + loader = PluginLoader(loader_bot, local_commands_dir=str(local_dir)) + stems = loader.discover_local_plugins() + assert set(stems) == {"my_cmd", "other"} + assert "__init__" not in stems + + def test_load_plugin_from_path_loads_valid_plugin(self, loader_bot, tmp_path): + local_dir = tmp_path / "local" / "commands" + local_dir.mkdir(parents=True) + plugin_file = local_dir / "hello_local.py" + plugin_file.write_text(_LOCAL_PLUGIN_SOURCE) + loader = PluginLoader(loader_bot, local_commands_dir=str(local_dir)) + instance = loader.load_plugin_from_path(plugin_file) + assert instance is not None + assert isinstance(instance, BaseCommand) + assert instance.name == "hellolocal" + assert "hellolocal" in instance.keywords + + def test_load_plugin_from_path_returns_none_for_invalid_file(self, loader_bot, tmp_path): + local_dir = tmp_path / "local" / "commands" + local_dir.mkdir(parents=True) + plugin_file = local_dir / "not_a_plugin.py" + plugin_file.write_text("print('no command class here')\n") + loader = PluginLoader(loader_bot, local_commands_dir=str(local_dir)) + instance = loader.load_plugin_from_path(plugin_file) + assert instance is None + assert "not_a_plugin" in loader._failed_plugins + + def test_load_all_plugins_includes_local_plugin(self, loader_bot, tmp_path): + # Use nonexistent commands_dir so no built-in plugins; only local + local_dir = tmp_path / "local" / "commands" + local_dir.mkdir(parents=True) + (local_dir / "hello_local.py").write_text(_LOCAL_PLUGIN_SOURCE) + loader = PluginLoader( + loader_bot, + commands_dir=str(tmp_path / "nonexistent_commands"), + local_commands_dir=str(local_dir), + ) + loaded = loader.load_all_plugins() + assert "hellolocal" in loaded + assert loader.get_plugin_by_keyword("hellolocal") is not None + assert loader.get_plugin_by_name("hellolocal") is not None + + def test_load_all_plugins_skips_local_plugin_when_name_collision(self, loader_bot, tmp_path): + # Local plugin with name "ping" should be skipped when built-in ping is loaded + local_dir = tmp_path / "local" / "commands" + local_dir.mkdir(parents=True) + ping_local_src = _LOCAL_PLUGIN_SOURCE.replace( + "HelloLocalCommand", "PingLocalCommand" + ).replace('name = "hellolocal"', 'name = "ping"').replace( + 'keywords = ["hellolocal", "hi local"]', 'keywords = ["pinglocal"]' + ) + (local_dir / "ping_local.py").write_text(ping_local_src) + loader = PluginLoader( + loader_bot, + local_commands_dir=str(local_dir), + ) + loaded = loader.load_all_plugins() + # Built-in ping should be present; local "ping" duplicate should be skipped + assert "ping" in loaded + assert loaded["ping"].__class__.__name__ == "PingCommand" + loader_bot.logger.warning.assert_called() + warning_calls = [str(c) for c in loader_bot.logger.warning.call_args_list] + assert any("already loaded" in str(c) and "ping" in str(c) for c in warning_calls) + diff --git a/tests/test_service_plugin_loader.py b/tests/test_service_plugin_loader.py new file mode 100644 index 0000000..b46ea4f --- /dev/null +++ b/tests/test_service_plugin_loader.py @@ -0,0 +1,160 @@ +"""Tests for modules.service_plugin_loader.""" + +import pytest +import configparser +from pathlib import Path +from unittest.mock import Mock, MagicMock + +from modules.service_plugin_loader import ServicePluginLoader +from modules.service_plugins.base_service import BaseServicePlugin + + +# Minimal local service source (valid BaseServicePlugin subclass) +_LOCAL_SERVICE_SOURCE = ''' +from modules.service_plugins.base_service import BaseServicePlugin + + +class MyLocalService(BaseServicePlugin): + config_section = "MyLocalService" + description = "Local test service" + + async def start(self) -> None: + self._running = True + + async def stop(self) -> None: + self._running = False +''' + + +@pytest.fixture +def service_loader_bot(tmp_path): + """Mock bot for ServicePluginLoader tests.""" + bot = MagicMock() + bot.logger = Mock() + bot.logger.info = Mock() + bot.logger.warning = Mock() + bot.logger.error = Mock() + bot.logger.debug = Mock() + bot.config = configparser.ConfigParser() + bot.config.add_section("Connection") + bot.config.add_section("Bot") + bot.config.add_section("Channels") + bot.bot_root = tmp_path + return bot + + +class TestDiscoverLocalServices: + """Tests for local/service_plugins discovery.""" + + def test_discover_local_services_empty_when_no_dir(self, service_loader_bot): + loader = ServicePluginLoader(service_loader_bot, local_services_dir=None) + assert loader.discover_local_services() == [] + + def test_discover_local_services_empty_when_dir_missing(self, service_loader_bot, tmp_path): + missing = tmp_path / "local" / "service_plugins" + loader = ServicePluginLoader(service_loader_bot, local_services_dir=str(missing)) + assert loader.discover_local_services() == [] + + def test_discover_local_services_finds_py_files(self, service_loader_bot, tmp_path): + local_dir = tmp_path / "local" / "service_plugins" + local_dir.mkdir(parents=True) + (local_dir / "my_svc.py").write_text("# test") + (local_dir / "other.py").write_text("# test") + (local_dir / "__init__.py").write_text("# init") + (local_dir / "base_service.py").write_text("# base") + loader = ServicePluginLoader(service_loader_bot, local_services_dir=str(local_dir)) + stems = loader.discover_local_services() + assert set(stems) == {"my_svc", "other"} + assert "__init__" not in stems + assert "base_service" not in stems + + +class TestLoadServiceFromPath: + """Tests for load_service_from_path.""" + + def test_load_service_from_path_loads_when_enabled(self, service_loader_bot, tmp_path): + service_loader_bot.config.add_section("MyLocalService") + service_loader_bot.config.set("MyLocalService", "enabled", "true") + local_dir = tmp_path / "local" / "service_plugins" + local_dir.mkdir(parents=True) + (local_dir / "my_local_service.py").write_text(_LOCAL_SERVICE_SOURCE) + loader = ServicePluginLoader(service_loader_bot, local_services_dir=str(local_dir)) + instance = loader.load_service_from_path(local_dir / "my_local_service.py") + assert instance is not None + assert isinstance(instance, BaseServicePlugin) + assert instance.get_metadata()["name"] == "mylocal" + + def test_load_service_from_path_returns_none_when_disabled(self, service_loader_bot, tmp_path): + service_loader_bot.config.add_section("MyLocalService") + service_loader_bot.config.set("MyLocalService", "enabled", "false") + local_dir = tmp_path / "local" / "service_plugins" + local_dir.mkdir(parents=True) + (local_dir / "my_local_service.py").write_text(_LOCAL_SERVICE_SOURCE) + loader = ServicePluginLoader(service_loader_bot, local_services_dir=str(local_dir)) + instance = loader.load_service_from_path(local_dir / "my_local_service.py") + assert instance is None + + def test_load_service_from_path_returns_none_for_invalid_file(self, service_loader_bot, tmp_path): + local_dir = tmp_path / "local" / "service_plugins" + local_dir.mkdir(parents=True) + (local_dir / "not_a_service.py").write_text("print('no service class')\n") + loader = ServicePluginLoader(service_loader_bot, local_services_dir=str(local_dir)) + instance = loader.load_service_from_path(local_dir / "not_a_service.py") + assert instance is None + + def test_load_service_from_path_returns_none_when_section_exists_but_enabled_not_set( + self, service_loader_bot, tmp_path + ): + service_loader_bot.config.add_section("MyLocalService") + # do not set enabled + local_dir = tmp_path / "local" / "service_plugins" + local_dir.mkdir(parents=True) + (local_dir / "my_local_service.py").write_text(_LOCAL_SERVICE_SOURCE) + loader = ServicePluginLoader(service_loader_bot, local_services_dir=str(local_dir)) + instance = loader.load_service_from_path(local_dir / "my_local_service.py") + assert instance is None + + +class TestLoadAllServicesWithLocal: + """Tests for load_all_services with local/service_plugins.""" + + def test_load_all_services_includes_local_service(self, service_loader_bot, tmp_path): + service_loader_bot.config.add_section("MyLocalService") + service_loader_bot.config.set("MyLocalService", "enabled", "true") + local_dir = tmp_path / "local" / "service_plugins" + local_dir.mkdir(parents=True) + (local_dir / "my_local_service.py").write_text(_LOCAL_SERVICE_SOURCE) + # Use nonexistent services_dir so no built-in services load + loader = ServicePluginLoader( + service_loader_bot, + services_dir=str(tmp_path / "nonexistent_services"), + local_services_dir=str(local_dir), + ) + loaded = loader.load_all_services() + assert "mylocal" in loaded + assert loader.get_service_by_name("mylocal") is not None + + def test_load_all_services_skips_local_when_name_collision(self, service_loader_bot, tmp_path): + # Local service with same derived name as another local: second one skipped + # We need two local services; one gets loaded first, second has same name -> skip + service_loader_bot.config.add_section("MyLocalService") + service_loader_bot.config.set("MyLocalService", "enabled", "true") + local_dir = tmp_path / "local" / "service_plugins" + local_dir.mkdir(parents=True) + (local_dir / "my_local_service.py").write_text(_LOCAL_SERVICE_SOURCE) + # Second file with same config_section/name + other_src = _LOCAL_SERVICE_SOURCE.replace("MyLocalService", "MyLocalService").replace( + "my_local_service", "my_local_service_dup" + ) + (local_dir / "my_local_service_dup.py").write_text(other_src) + loader = ServicePluginLoader( + service_loader_bot, + services_dir=str(tmp_path / "nonexistent_services"), + local_services_dir=str(local_dir), + ) + loaded = loader.load_all_services() + # Only one "mylocal" (first file wins; second is skipped with warning) + assert loaded.get("mylocal") is not None + service_loader_bot.logger.warning.assert_called() + warning_calls = [str(c) for c in service_loader_bot.logger.warning.call_args_list] + assert any("already loaded" in str(c) for c in warning_calls)