diff --git a/config.ini.example b/config.ini.example index 8116716..db90963 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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. diff --git a/docs/config-validation.md b/docs/config-validation.md index b4041d7..b3a10b7 100644 --- a/docs/config-validation.md +++ b/docs/config-validation.md @@ -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. diff --git a/docs/getting-started.md b/docs/getting-started.md index 695a2fd..0850f0e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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 diff --git a/meshcore_bot.py b/meshcore_bot.py index b3cda16..469721d 100644 --- a/meshcore_bot.py +++ b/meshcore_bot.py @@ -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, diff --git a/modules/commands/status_command.py b/modules/commands/status_command.py new file mode 100644 index 0000000..8c2fc8b --- /dev/null +++ b/modules/commands/status_command.py @@ -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 diff --git a/modules/config_snapshot.py b/modules/config_snapshot.py new file mode 100644 index 0000000..dd07b90 --- /dev/null +++ b/modules/config_snapshot.py @@ -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) diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 5cea087..5af7df0 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -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') diff --git a/modules/web_viewer/integration.py b/modules/web_viewer/integration.py index 80609ab..611b5c5 100644 --- a/modules/web_viewer/integration.py +++ b/modules/web_viewer/integration.py @@ -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}") diff --git a/pyproject.toml b/pyproject.toml index cd5b788..886e8c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_meshcore_bot_cli.py b/tests/test_meshcore_bot_cli.py new file mode 100644 index 0000000..c3b45a4 --- /dev/null +++ b/tests/test_meshcore_bot_cli.py @@ -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 diff --git a/tests/test_status_command.py b/tests/test_status_command.py new file mode 100644 index 0000000..002db03 --- /dev/null +++ b/tests/test_status_command.py @@ -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 diff --git a/tests/test_web_viewer_app.py b/tests/test_web_viewer_app.py index e5f594d..b943f79 100644 --- a/tests/test_web_viewer_app.py +++ b/tests/test_web_viewer_app.py @@ -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. diff --git a/tests/test_web_viewer_integration.py b/tests/test_web_viewer_integration.py index 103e277..650e417 100644 --- a/tests/test_web_viewer_integration.py +++ b/tests/test_web_viewer_integration.py @@ -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