mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 13:24:09 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user