mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-26 19:05:17 +00:00
feat(config): add configurable timeouts for web viewer integration
Introduced optional timeout settings in the configuration for various web viewer operations, including edge and node post timeouts, SQLite connection timeout, and requeue timeout. Updated the web viewer integration to utilize these settings, enhancing flexibility and reliability. Added commands to inspect the resolved configuration with sensitive keys redacted, and updated documentation accordingly.
This commit is contained in:
@@ -1392,6 +1392,17 @@ debug = false
|
||||
# false: Start web viewer manually (recommended)
|
||||
auto_start = false
|
||||
|
||||
# Optional timeout overrides for web viewer integration (seconds)
|
||||
# edge_post_timeout_sec = 1.0
|
||||
# node_post_timeout_sec = 0.5
|
||||
# sqlite_connect_timeout_sec = 60.0
|
||||
# requeue_put_timeout_sec = 60.0
|
||||
# integration_shutdown_join_timeout_sec = 5.0
|
||||
# viewer_stop_grace_timeout_sec = 5.0
|
||||
# viewer_stop_force_timeout_sec = 2.0
|
||||
# port_cleanup_lsof_timeout_sec = 5.0
|
||||
# port_cleanup_kill_timeout_sec = 2.0
|
||||
|
||||
# Optional: database path for web viewer. If unset, the viewer uses [Bot] db_path (recommended).
|
||||
# Only set this if you use a separate database for the viewer; you will see a startup warning.
|
||||
# See docs/web-viewer.md for migrating from a separate database.
|
||||
|
||||
@@ -16,6 +16,13 @@ python validate_config.py [--config config.ini]
|
||||
python meshcore_bot.py --validate-config [--config config.ini]
|
||||
```
|
||||
|
||||
**Inspect resolved config** (redacted, then exit):
|
||||
|
||||
```bash
|
||||
python meshcore_bot.py --show-config [--config config.ini]
|
||||
python meshcore_bot.py --show-config-json [--config config.ini]
|
||||
```
|
||||
|
||||
- **Exit 0** – No errors (warnings and info may still be printed).
|
||||
- **Exit 1** – One or more errors; fix them before starting the bot.
|
||||
|
||||
|
||||
@@ -39,6 +39,17 @@ Get meshcore-bot running on your machine in a few minutes.
|
||||
python3 meshcore_bot.py
|
||||
```
|
||||
|
||||
## Inspect effective config safely
|
||||
|
||||
Use these commands to inspect the resolved config with sensitive keys redacted:
|
||||
|
||||
```bash
|
||||
python3 meshcore_bot.py --show-config --config config.ini
|
||||
python3 meshcore_bot.py --show-config-json --config config.ini
|
||||
```
|
||||
|
||||
Also available in the web UI at `/admin/config`.
|
||||
|
||||
## Production deployment
|
||||
|
||||
### Systemd service
|
||||
|
||||
@@ -6,9 +6,13 @@ Uses a modular structure for command creation and organization
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import configparser
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from modules.config_snapshot import config_to_redacted_sections, redacted_sections_to_ini_text
|
||||
|
||||
|
||||
def _configure_unix_signal_handlers(loop, bot, shutdown_event: asyncio.Event) -> None:
|
||||
"""Register Unix signal handlers for shutdown and config reload."""
|
||||
@@ -55,9 +59,42 @@ def main():
|
||||
action="store_true",
|
||||
help="Validate config section names and exit before starting the bot (exit 1 on errors)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-config",
|
||||
action="store_true",
|
||||
help="Print resolved config.ini with sensitive keys redacted and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-config-json",
|
||||
action="store_true",
|
||||
help="Print resolved config.ini as redacted JSON and exit",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.show_config and args.show_config_json:
|
||||
print("Error: --show-config and --show-config-json are mutually exclusive", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.show_config or args.show_config_json:
|
||||
cfg = configparser.ConfigParser()
|
||||
try:
|
||||
loaded_paths = cfg.read(args.config, encoding="utf-8")
|
||||
except configparser.Error as exc:
|
||||
print(f"Error: Invalid config file '{args.config}': {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not loaded_paths:
|
||||
print(f"Error: Config file not found: {args.config}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sections = config_to_redacted_sections(cfg)
|
||||
if args.show_config_json:
|
||||
print(json.dumps(sections, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(redacted_sections_to_ini_text(sections))
|
||||
sys.exit(0)
|
||||
|
||||
if args.validate_config:
|
||||
from modules.config_validation import (
|
||||
SEVERITY_ERROR,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Status command
|
||||
DM-only admin command that reports bot runtime status in a single response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..models import MeshMessage
|
||||
from .base_command import BaseCommand
|
||||
|
||||
|
||||
class StatusCommand(BaseCommand):
|
||||
"""Report high-level runtime status for operators."""
|
||||
|
||||
name = "status"
|
||||
keywords = ["status"]
|
||||
description = "Show runtime status (DM only, admin only)"
|
||||
requires_dm = True
|
||||
cooldown_seconds = 2
|
||||
category = "admin"
|
||||
|
||||
short_description = "Show runtime status for operators"
|
||||
usage = "status"
|
||||
examples = ["status", "!status"]
|
||||
parameters: list[dict[str, str]] = []
|
||||
|
||||
def __init__(self, bot: Any):
|
||||
super().__init__(bot)
|
||||
self.status_enabled = self.get_config_value(
|
||||
"Status_Command",
|
||||
"enabled",
|
||||
fallback=True,
|
||||
value_type="bool",
|
||||
)
|
||||
|
||||
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
|
||||
if not self.status_enabled:
|
||||
return False
|
||||
if not self.requires_admin_access():
|
||||
return False
|
||||
return super().can_execute(message, skip_channel_check=skip_channel_check)
|
||||
|
||||
def requires_admin_access(self) -> bool:
|
||||
return True
|
||||
|
||||
async def execute(self, message: MeshMessage) -> bool:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
connected = bool(getattr(self.bot, "connected", False))
|
||||
radio_zombie = bool(getattr(self.bot, "is_radio_zombie", False))
|
||||
radio_offline = bool(getattr(self.bot, "is_radio_offline", False))
|
||||
|
||||
web_running = False
|
||||
integration = getattr(self.bot, "web_viewer_integration", None)
|
||||
if integration is not None:
|
||||
web_running = bool(getattr(integration, "running", False))
|
||||
|
||||
paused = not bool(getattr(self.bot, "channel_responses_enabled", True))
|
||||
|
||||
status_text = (
|
||||
"Bot Status\n"
|
||||
f"- time: {now}\n"
|
||||
f"- connected: {connected}\n"
|
||||
f"- radio_zombie: {radio_zombie}\n"
|
||||
f"- radio_offline: {radio_offline}\n"
|
||||
f"- channel_responses_paused: {paused}\n"
|
||||
f"- web_viewer_running: {web_running}"
|
||||
)
|
||||
await self.send_response(message, status_text)
|
||||
return True
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Helpers for rendering resolved config snapshots with redaction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from configparser import ConfigParser
|
||||
|
||||
_REDACT_KEY_PARTS: tuple[str, ...] = (
|
||||
"password",
|
||||
"smtp_password",
|
||||
"api_key",
|
||||
"token",
|
||||
"secret",
|
||||
"smtp_user",
|
||||
)
|
||||
|
||||
|
||||
def is_sensitive_key(key: str) -> bool:
|
||||
"""Return True when a config key should be redacted."""
|
||||
key_lower = key.lower()
|
||||
return any(part in key_lower for part in _REDACT_KEY_PARTS)
|
||||
|
||||
|
||||
def config_to_redacted_sections(config: ConfigParser) -> dict[str, dict[str, str]]:
|
||||
"""Return config sections as key/value maps with sensitive keys redacted."""
|
||||
sections: dict[str, dict[str, str]] = {}
|
||||
for section in config.sections():
|
||||
sections[section] = {
|
||||
key: "●●●●●●" if is_sensitive_key(key) else value
|
||||
for key, value in config.items(section, raw=True)
|
||||
}
|
||||
return sections
|
||||
|
||||
|
||||
def redacted_sections_to_ini_text(sections: dict[str, dict[str, str]]) -> str:
|
||||
"""Render redacted config sections as human-readable INI text."""
|
||||
lines: list[str] = []
|
||||
for idx, (section_name, options) in enumerate(sections.items()):
|
||||
if idx > 0:
|
||||
lines.append("")
|
||||
lines.append(f"[{section_name}]")
|
||||
for key, value in options.items():
|
||||
lines.append(f"{key} = {value}")
|
||||
return "\n".join(lines)
|
||||
@@ -94,6 +94,7 @@ from modules.feed_manager import FeedManager
|
||||
from modules.repeater_manager import RepeaterManager
|
||||
from modules.url_shortener import _coerce_url_string
|
||||
from modules.utils import calculate_distance, resolve_path
|
||||
from modules.config_snapshot import config_to_redacted_sections
|
||||
from modules.web_viewer.config_panels import CONFIG_PANELS, PANEL_CATEGORIES
|
||||
from modules.web_viewer.integration import normalized_web_viewer_password
|
||||
|
||||
@@ -2028,13 +2029,7 @@ class BotDataViewer:
|
||||
@self.app.route('/admin/config')
|
||||
def admin_config():
|
||||
"""Resolved config viewer — shows effective config.ini values with sensitive fields redacted."""
|
||||
_REDACT_KEYS = {'password', 'smtp_password', 'api_key', 'token', 'secret', 'smtp_user'}
|
||||
sections = {}
|
||||
for section in self.config.sections():
|
||||
sections[section] = {
|
||||
k: '●●●●●●' if any(r in k for r in _REDACT_KEYS) else v
|
||||
for k, v in self.config.items(section, raw=True)
|
||||
}
|
||||
sections = config_to_redacted_sections(self.config)
|
||||
return render_template('admin_config.html', sections=sections, config_path=self.config_path)
|
||||
|
||||
@self.app.route('/mesh')
|
||||
|
||||
@@ -54,6 +54,11 @@ class BotIntegration:
|
||||
|
||||
# Bounded packet_stream write queue: producers block up to this long before drop/retry.
|
||||
_WRITE_QUEUE_PUT_TIMEOUT_SEC = 5.0
|
||||
EDGE_POST_TIMEOUT_SEC = 1.0
|
||||
NODE_POST_TIMEOUT_SEC = 0.5
|
||||
SQLITE_CONNECT_TIMEOUT_SEC = 60.0
|
||||
REQUEUE_PUT_TIMEOUT_SEC = 60.0
|
||||
SHUTDOWN_JOIN_TIMEOUT_SEC = 5.0
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
@@ -76,6 +81,7 @@ class BotIntegration:
|
||||
self._write_queue: queue.Queue = queue.Queue(maxsize=self._write_queue_maxsize)
|
||||
self._drain_stop = threading.Event()
|
||||
self._drain_thread: Optional[threading.Thread] = None
|
||||
self._load_timeouts_from_config()
|
||||
# Initialize HTTP session with connection pooling for efficient reuse
|
||||
self._init_http_session()
|
||||
# Generate a shared secret for authenticating internal /api/stream_data calls.
|
||||
@@ -90,6 +96,33 @@ class BotIntegration:
|
||||
# Start background drain thread after table is confirmed to exist
|
||||
self._start_drain_thread()
|
||||
|
||||
def _get_float_config(self, key: str, fallback: float) -> float:
|
||||
"""Read float from [Web_Viewer] config with sane fallback."""
|
||||
try:
|
||||
value = self.bot.config.getfloat("Web_Viewer", key, fallback=fallback)
|
||||
return value if value > 0 else fallback
|
||||
except (ValueError, TypeError, OSError):
|
||||
return fallback
|
||||
|
||||
def _load_timeouts_from_config(self) -> None:
|
||||
"""Load optional BotIntegration timeout settings from config."""
|
||||
self.edge_post_timeout_sec = self._get_float_config(
|
||||
"edge_post_timeout_sec", self.EDGE_POST_TIMEOUT_SEC
|
||||
)
|
||||
self.node_post_timeout_sec = self._get_float_config(
|
||||
"node_post_timeout_sec", self.NODE_POST_TIMEOUT_SEC
|
||||
)
|
||||
self.sqlite_connect_timeout_sec = self._get_float_config(
|
||||
"sqlite_connect_timeout_sec", self.SQLITE_CONNECT_TIMEOUT_SEC
|
||||
)
|
||||
self.requeue_put_timeout_sec = self._get_float_config(
|
||||
"requeue_put_timeout_sec", self.REQUEUE_PUT_TIMEOUT_SEC
|
||||
)
|
||||
self.shutdown_join_timeout_sec = self._get_float_config(
|
||||
"integration_shutdown_join_timeout_sec",
|
||||
self.SHUTDOWN_JOIN_TIMEOUT_SEC,
|
||||
)
|
||||
|
||||
def _init_http_session(self):
|
||||
"""Initialize a requests.Session with connection pooling and keep-alive"""
|
||||
try:
|
||||
@@ -188,7 +221,7 @@ class BotIntegration:
|
||||
import sqlite3
|
||||
|
||||
db_path = self._get_web_viewer_db_path()
|
||||
with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn:
|
||||
with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn:
|
||||
cur = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='packet_stream'"
|
||||
)
|
||||
@@ -247,7 +280,7 @@ class BotIntegration:
|
||||
"""Restore rows to the queue after a failed flush (FIFO). Logs if the queue stays full."""
|
||||
for i, row in enumerate(rows):
|
||||
try:
|
||||
self._write_queue.put(row, timeout=60.0)
|
||||
self._write_queue.put(row, timeout=self.requeue_put_timeout_sec)
|
||||
except queue.Full:
|
||||
remaining = len(rows) - i
|
||||
self.bot.logger.error(
|
||||
@@ -275,7 +308,7 @@ class BotIntegration:
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn:
|
||||
with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn:
|
||||
conn.executemany(
|
||||
'INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)',
|
||||
rows,
|
||||
@@ -451,7 +484,7 @@ class BotIntegration:
|
||||
cutoff_time = time.time() - (days_to_keep * 24 * 60 * 60)
|
||||
|
||||
db_path = self._get_web_viewer_db_path()
|
||||
with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn:
|
||||
with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Clean up old packet stream data
|
||||
@@ -513,14 +546,14 @@ class BotIntegration:
|
||||
}
|
||||
if self.http_session:
|
||||
try:
|
||||
self.http_session.post(url, json=payload, timeout=1.0)
|
||||
self.http_session.post(url, json=payload, timeout=self.edge_post_timeout_sec)
|
||||
self._record_web_viewer_result(True)
|
||||
except Exception:
|
||||
self._record_web_viewer_result(False)
|
||||
else:
|
||||
import requests
|
||||
try:
|
||||
requests.post(url, json=payload, timeout=1.0, headers=headers)
|
||||
requests.post(url, json=payload, timeout=self.edge_post_timeout_sec, headers=headers)
|
||||
self._record_web_viewer_result(True)
|
||||
except Exception:
|
||||
self._record_web_viewer_result(False)
|
||||
@@ -548,7 +581,7 @@ class BotIntegration:
|
||||
'X-Requested-With': 'BotIntegration',
|
||||
}
|
||||
try:
|
||||
requests.post(url, json=payload, timeout=0.5, headers=headers)
|
||||
requests.post(url, json=payload, timeout=self.node_post_timeout_sec, headers=headers)
|
||||
self._record_web_viewer_result(True)
|
||||
except Exception:
|
||||
self._record_web_viewer_result(False)
|
||||
@@ -561,7 +594,7 @@ class BotIntegration:
|
||||
# Stop drain thread and do a final flush of any queued rows
|
||||
self._drain_stop.set()
|
||||
if self._drain_thread and self._drain_thread.is_alive():
|
||||
self._drain_thread.join(timeout=5.0)
|
||||
self._drain_thread.join(timeout=self.shutdown_join_timeout_sec)
|
||||
self._flush_write_queue()
|
||||
# Close HTTP session to clean up connections
|
||||
if hasattr(self, 'http_session') and self.http_session:
|
||||
@@ -573,6 +606,10 @@ class WebViewerIntegration:
|
||||
|
||||
# Whitelist of allowed host bindings for security
|
||||
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0']
|
||||
VIEWER_STOP_GRACE_TIMEOUT_SEC = 5.0
|
||||
VIEWER_STOP_FORCE_TIMEOUT_SEC = 2.0
|
||||
PORT_CLEANUP_LSOF_TIMEOUT_SEC = 5.0
|
||||
PORT_CLEANUP_KILL_TIMEOUT_SEC = 2.0
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
@@ -591,6 +628,22 @@ class WebViewerIntegration:
|
||||
self.port = bot.config.getint('Web_Viewer', 'port', fallback=8080) # Web viewer uses 8080
|
||||
self.debug = bot.config.getboolean('Web_Viewer', 'debug', fallback=False)
|
||||
self.auto_start = bot.config.getboolean('Web_Viewer', 'auto_start', fallback=False)
|
||||
self.viewer_stop_grace_timeout_sec = self._get_float_config(
|
||||
"viewer_stop_grace_timeout_sec",
|
||||
self.VIEWER_STOP_GRACE_TIMEOUT_SEC,
|
||||
)
|
||||
self.viewer_stop_force_timeout_sec = self._get_float_config(
|
||||
"viewer_stop_force_timeout_sec",
|
||||
self.VIEWER_STOP_FORCE_TIMEOUT_SEC,
|
||||
)
|
||||
self.port_cleanup_lsof_timeout_sec = self._get_float_config(
|
||||
"port_cleanup_lsof_timeout_sec",
|
||||
self.PORT_CLEANUP_LSOF_TIMEOUT_SEC,
|
||||
)
|
||||
self.port_cleanup_kill_timeout_sec = self._get_float_config(
|
||||
"port_cleanup_kill_timeout_sec",
|
||||
self.PORT_CLEANUP_KILL_TIMEOUT_SEC,
|
||||
)
|
||||
|
||||
# Validate configuration for security
|
||||
self._validate_config()
|
||||
@@ -608,6 +661,14 @@ class WebViewerIntegration:
|
||||
if self.enabled and self.auto_start:
|
||||
self.start_viewer()
|
||||
|
||||
def _get_float_config(self, key: str, fallback: float) -> float:
|
||||
"""Read float timeout from [Web_Viewer] with positive-value guard."""
|
||||
try:
|
||||
value = self.bot.config.getfloat("Web_Viewer", key, fallback=fallback)
|
||||
return value if value > 0 else fallback
|
||||
except (ValueError, TypeError, OSError):
|
||||
return fallback
|
||||
|
||||
def _validate_config(self):
|
||||
"""Validate web viewer configuration for security"""
|
||||
# Validate host against whitelist
|
||||
@@ -667,13 +728,13 @@ class WebViewerIntegration:
|
||||
try:
|
||||
# First try graceful termination
|
||||
self.viewer_process.terminate()
|
||||
self.viewer_process.wait(timeout=5)
|
||||
self.viewer_process.wait(timeout=self.viewer_stop_grace_timeout_sec)
|
||||
self.logger.info("Web viewer stopped gracefully")
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning("Web viewer did not stop gracefully, forcing termination")
|
||||
try:
|
||||
self.viewer_process.kill()
|
||||
self.viewer_process.wait(timeout=2)
|
||||
self.viewer_process.wait(timeout=self.viewer_stop_force_timeout_sec)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Failed to kill web viewer process")
|
||||
except Exception as e:
|
||||
@@ -706,7 +767,7 @@ class WebViewerIntegration:
|
||||
# Additional cleanup: kill any remaining processes on the port
|
||||
try:
|
||||
result = subprocess.run(['lsof', '-ti', f':{self.port}'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
capture_output=True, text=True, timeout=self.port_cleanup_lsof_timeout_sec)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
pids = result.stdout.strip().split('\n')
|
||||
for pid in pids:
|
||||
@@ -726,7 +787,7 @@ class WebViewerIntegration:
|
||||
self.logger.warning(f"Refusing to kill system PID: {pid}")
|
||||
continue
|
||||
|
||||
subprocess.run(['kill', '-9', str(pid_int)], timeout=2)
|
||||
subprocess.run(['kill', '-9', str(pid_int)], timeout=self.port_cleanup_kill_timeout_sec)
|
||||
self.logger.info(f"Killed remaining process {pid} on port {self.port}")
|
||||
except (ValueError, subprocess.TimeoutExpired) as e:
|
||||
self.logger.warning(f"Failed to kill process {pid}: {e}")
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meshcore-bot"
|
||||
version = "0.1.0"
|
||||
description = "MeshCore Bot using the meshcore-cli and meshcore.py packages"
|
||||
version = "0.9.0"
|
||||
description = "MeshCore Bot with commands for mesh testing, utilities, and service integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for meshcore_bot.py CLI config-inspection flags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from meshcore_bot import main
|
||||
|
||||
|
||||
def _write_config(path: Path) -> None:
|
||||
path.write_text(
|
||||
"""[Connection]
|
||||
connection_type = serial
|
||||
serial_port = /dev/ttyUSB0
|
||||
|
||||
[Bot]
|
||||
db_path = /tmp/bot.db
|
||||
api_token = super-secret-token
|
||||
|
||||
[Notifications]
|
||||
smtp_user = alerts@example.com
|
||||
smtp_password = hunter2
|
||||
recipient = ops@example.com
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_show_config_prints_redacted_ini(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
||||
config_path = tmp_path / "config.ini"
|
||||
_write_config(config_path)
|
||||
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config", "--config", str(config_path)])
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main()
|
||||
assert exc.value.code == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "[Bot]" in out
|
||||
assert "db_path = /tmp/bot.db" in out
|
||||
assert "api_token = ●●●●●●" in out
|
||||
assert "smtp_user = ●●●●●●" in out
|
||||
assert "smtp_password = ●●●●●●" in out
|
||||
|
||||
|
||||
def test_show_config_json_prints_redacted_json(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.ini"
|
||||
_write_config(config_path)
|
||||
monkeypatch.setattr(
|
||||
sys, "argv", ["meshcore_bot.py", "--show-config-json", "--config", str(config_path)]
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main()
|
||||
assert exc.value.code == 0
|
||||
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["Bot"]["db_path"] == "/tmp/bot.db"
|
||||
assert payload["Bot"]["api_token"] == "●●●●●●"
|
||||
assert payload["Notifications"]["smtp_password"] == "●●●●●●"
|
||||
|
||||
|
||||
def test_show_config_missing_file_exits_1(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
missing_path = tmp_path / "missing.ini"
|
||||
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config", "--config", str(missing_path)])
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main()
|
||||
assert exc.value.code == 1
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "Config file not found" in err
|
||||
|
||||
|
||||
def test_show_config_invalid_ini_exits_1(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
bad_path = tmp_path / "bad.ini"
|
||||
bad_path.write_text("[Connection\nserial_port=/dev/ttyUSB0\n", encoding="utf-8")
|
||||
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config-json", "--config", str(bad_path)])
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main()
|
||||
assert exc.value.code == 1
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "Invalid config file" in err
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for modules.commands.status_command."""
|
||||
|
||||
import asyncio
|
||||
import configparser
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
from modules.commands.status_command import StatusCommand
|
||||
from tests.conftest import mock_message
|
||||
|
||||
|
||||
def _make_bot(*, enabled=True):
|
||||
bot = MagicMock()
|
||||
bot.logger = Mock()
|
||||
bot.connected = True
|
||||
bot.is_radio_zombie = False
|
||||
bot.is_radio_offline = False
|
||||
bot.channel_responses_enabled = True
|
||||
bot.web_viewer_integration = MagicMock()
|
||||
bot.web_viewer_integration.running = True
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.add_section("Bot")
|
||||
config.set("Bot", "bot_name", "TestBot")
|
||||
config.add_section("Channels")
|
||||
config.set("Channels", "monitor_channels", "general")
|
||||
config.add_section("Keywords")
|
||||
config.add_section("Status_Command")
|
||||
config.set("Status_Command", "enabled", "true" if enabled else "false")
|
||||
config.add_section("Admin_ACL")
|
||||
config.set("Admin_ACL", "admin_pubkeys", "a" * 64)
|
||||
config.set("Admin_ACL", "admin_commands", "status")
|
||||
bot.config = config
|
||||
|
||||
bot.translator = MagicMock()
|
||||
bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
|
||||
bot.translator.get_value = Mock(return_value=None)
|
||||
bot.command_manager = MagicMock()
|
||||
bot.command_manager.monitor_channels = ["general"]
|
||||
bot.command_manager.send_response = AsyncMock(return_value=True)
|
||||
return bot
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class TestStatusCommandPermissions:
|
||||
def test_dm_admin_allowed(self):
|
||||
cmd = StatusCommand(_make_bot(enabled=True))
|
||||
msg = mock_message(content="status", is_dm=True, sender_id="user1")
|
||||
msg.sender_pubkey = "a" * 64
|
||||
assert cmd.can_execute(msg) is True
|
||||
|
||||
def test_channel_disallowed(self):
|
||||
cmd = StatusCommand(_make_bot(enabled=True))
|
||||
msg = mock_message(content="status", is_dm=False, sender_id="user1")
|
||||
msg.sender_pubkey = "a" * 64
|
||||
assert cmd.can_execute(msg) is False
|
||||
|
||||
def test_non_admin_disallowed(self):
|
||||
cmd = StatusCommand(_make_bot(enabled=True))
|
||||
msg = mock_message(content="status", is_dm=True, sender_id="user1")
|
||||
msg.sender_pubkey = "b" * 64
|
||||
assert cmd.can_execute(msg) is False
|
||||
|
||||
def test_disabled_disallowed(self):
|
||||
cmd = StatusCommand(_make_bot(enabled=False))
|
||||
msg = mock_message(content="status", is_dm=True, sender_id="user1")
|
||||
msg.sender_pubkey = "a" * 64
|
||||
assert cmd.can_execute(msg) is False
|
||||
|
||||
|
||||
class TestStatusCommandExecute:
|
||||
def test_execute_sends_single_status_message(self):
|
||||
bot = _make_bot(enabled=True)
|
||||
cmd = StatusCommand(bot)
|
||||
msg = mock_message(content="status", is_dm=True, sender_id="user1")
|
||||
msg.sender_pubkey = "a" * 64
|
||||
|
||||
result = _run(cmd.execute(msg))
|
||||
|
||||
assert result is True
|
||||
bot.command_manager.send_response.assert_called_once()
|
||||
text = bot.command_manager.send_response.call_args[0][1]
|
||||
assert "Bot Status" in text
|
||||
assert "connected: True" in text
|
||||
assert "radio_zombie: False" in text
|
||||
assert "web_viewer_running: True" in text
|
||||
@@ -13,6 +13,31 @@ import pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_sqlite_connections(monkeypatch):
|
||||
"""Track and close SQLite connections opened during each test.
|
||||
|
||||
Some app code paths intentionally create ad-hoc connections for request-style
|
||||
operations; this fixture ensures any leaked handles are closed so Python 3.13
|
||||
ResourceWarning checks stay clean.
|
||||
"""
|
||||
tracked_connections = []
|
||||
original_connect = sqlite3.connect
|
||||
|
||||
def _tracked_connect(*args, **kwargs):
|
||||
conn = original_connect(*args, **kwargs)
|
||||
tracked_connections.append(conn)
|
||||
return conn
|
||||
|
||||
monkeypatch.setattr(sqlite3, "connect", _tracked_connect)
|
||||
yield
|
||||
for conn in tracked_connections:
|
||||
try:
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer_with_db(tmp_path):
|
||||
"""Create a BotDataViewer instance with a test database.
|
||||
|
||||
@@ -211,7 +211,7 @@ class TestInsertPacketStreamRow:
|
||||
def test_queue_exception_logged(self):
|
||||
bi = _make_bot_integration()
|
||||
bi._write_queue = Mock()
|
||||
bi._write_queue.put_nowait.side_effect = Exception("full")
|
||||
bi._write_queue.put.side_effect = Exception("full")
|
||||
# Should not raise
|
||||
bi._insert_packet_stream_row("{}", "packet")
|
||||
bi.bot.logger.warning.assert_called_once()
|
||||
@@ -491,3 +491,38 @@ class TestShutdown:
|
||||
bi._drain_thread.is_alive.return_value = True
|
||||
bi.shutdown()
|
||||
bi._drain_thread.join.assert_called_once()
|
||||
|
||||
|
||||
class TestIntegrationTimeoutConfig:
|
||||
def test_bot_integration_loads_custom_timeouts(self):
|
||||
bot = _make_bot()
|
||||
bot.config.set("Web_Viewer", "edge_post_timeout_sec", "2.5")
|
||||
bot.config.set("Web_Viewer", "node_post_timeout_sec", "1.25")
|
||||
bot.config.set("Web_Viewer", "sqlite_connect_timeout_sec", "42")
|
||||
bot.config.set("Web_Viewer", "requeue_put_timeout_sec", "7")
|
||||
bot.config.set("Web_Viewer", "integration_shutdown_join_timeout_sec", "3")
|
||||
|
||||
bi = _make_bot_integration(bot)
|
||||
assert bi.edge_post_timeout_sec == 2.5
|
||||
assert bi.node_post_timeout_sec == 1.25
|
||||
assert bi.sqlite_connect_timeout_sec == 42
|
||||
assert bi.requeue_put_timeout_sec == 7
|
||||
assert bi.shutdown_join_timeout_sec == 3
|
||||
|
||||
def test_web_viewer_integration_loads_custom_timeouts(self):
|
||||
from modules.web_viewer.integration import WebViewerIntegration
|
||||
|
||||
bot = _make_bot()
|
||||
bot.config.set("Web_Viewer", "viewer_stop_grace_timeout_sec", "9")
|
||||
bot.config.set("Web_Viewer", "viewer_stop_force_timeout_sec", "4")
|
||||
bot.config.set("Web_Viewer", "port_cleanup_lsof_timeout_sec", "8")
|
||||
bot.config.set("Web_Viewer", "port_cleanup_kill_timeout_sec", "1")
|
||||
with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \
|
||||
patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \
|
||||
patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"):
|
||||
wvi = WebViewerIntegration(bot)
|
||||
|
||||
assert wvi.viewer_stop_grace_timeout_sec == 9
|
||||
assert wvi.viewer_stop_force_timeout_sec == 4
|
||||
assert wvi.port_cleanup_lsof_timeout_sec == 8
|
||||
assert wvi.port_cleanup_kill_timeout_sec == 1
|
||||
|
||||
Reference in New Issue
Block a user