Add support for local plugins and services

- Updated `.gitignore` to include local configuration and plugin directories, allowing users to add custom commands and services without modifying core code.
- Enhanced `config.ini.example` with instructions for using local plugins and added sections for local service configurations.
- Refactored `PluginLoader` and `ServicePluginLoader` to support loading local commands and services from specified directories, improving extensibility.
- Updated `mkdocs.yml` to include documentation for local plugins and the check-in API.
- Added tests to verify the discovery and loading of local plugins, ensuring functionality and preventing name collisions with built-in plugins.
This commit is contained in:
agessaman
2026-03-04 18:33:45 -08:00
parent 1474174ba5
commit 4e5addd5df
18 changed files with 1151 additions and 5 deletions
+7
View File
@@ -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
+25
View File
@@ -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).
+137
View File
@@ -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 <api_key>`
- 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 <secret>` 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.
+92
View File
@@ -0,0 +1,92 @@
# Local plugins and services
You can add your own **command plugins** and **service plugins** without modifying the bots 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.
+9
View File
@@ -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.
View File
View File
View File
+2
View File
@@ -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
+3 -1
View File
@@ -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
+11 -2
View File
@@ -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)
+94 -1
View File
@@ -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]
+94 -1
View File
@@ -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
+200
View File
@@ -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())
+135
View File
@@ -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
+80
View File
@@ -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"
+102
View File
@@ -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)
+160
View File
@@ -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)