From 883b67daf9e6d0517c2ccf658910a2e14736fe7f Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 5 Apr 2026 20:00:01 -0700 Subject: [PATCH] Update per-user rate limit and implement version command support - Increased the per-user rate limit from 5 to 30 seconds across multiple configuration files to reduce response frequency. - Added the version command to the configuration examples and updated help text to include the new command. - Refactored version information retrieval in the bot and web viewer to utilize a shared runtime resolver for consistency. - Improved documentation in README.md to reflect changes in commands and configuration options. --- README.md | 4 +- config.ini.example | 10 +- config.ini.minimal-example | 10 +- config.ini.quickstart | 9 +- modules/command_manager.py | 3 +- modules/commands/version_command.py | 51 ++++++++ modules/core.py | 17 ++- .../service_plugins/packet_capture_service.py | 37 +----- modules/version_info.py | 123 ++++++++++++++++++ modules/web_viewer/app.py | 59 ++------- tests/commands/test_version_command.py | 60 +++++++++ tests/test_version_info.py | 73 +++++++++++ 12 files changed, 365 insertions(+), 91 deletions(-) create mode 100644 modules/commands/version_command.py create mode 100644 modules/version_info.py create mode 100644 tests/commands/test_version_command.py create mode 100644 tests/test_version_info.py diff --git a/README.md b/README.md index cbb1455..33889bf 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ bot_name = MeshCoreBot # Bot identification name enabled = true # Enable/disable bot rate_limit_seconds = 2 # Global: min seconds between any bot reply bot_tx_rate_limit_seconds = 1.0 # Min seconds between bot transmissions -per_user_rate_limit_seconds = 5 # Per-user: min seconds between replies to same user (pubkey or name) +per_user_rate_limit_seconds = 30 # Per-user: min seconds between replies to same user (pubkey or name) per_user_rate_limit_enabled = true startup_advert = flood # Send advert on startup ``` @@ -460,7 +460,7 @@ Or if installed as a package entry point: For a comprehensive list of all available commands with examples and detailed explanations, see [Command reference](docs/command-reference.md). Quick reference: -- **Basic:** `test`, `ping`, `help`, `hello`, `cmd` + - **Basic:** `test`, `ping`, `version`, `help`, `hello`, `cmd` - **Information:** `wx`, `gwx`, `aqi`, `sun`, `moon`, `solar`, `solarforecast`, `hfcond`, `satpass`, `channels` - **Emergency:** `alert` - **Gaming:** `dice`, `roll`, `magic8` diff --git a/config.ini.example b/config.ini.example index 5a08cd5..53d76fc 100644 --- a/config.ini.example +++ b/config.ini.example @@ -84,7 +84,7 @@ bot_tx_rate_limit_seconds = 1.0 # Per-user rate limit: minimum seconds between bot replies to the same user # User is identified by public key when available (DMs and channel when provided), else sender name # Channel senders are often matched by name only. Set to 0 or disable to effectively turn off per-user limiting -per_user_rate_limit_seconds = 5 +per_user_rate_limit_seconds = 30 # Enable or disable per-user rate limiting (true/false) per_user_rate_limit_enabled = true @@ -327,10 +327,10 @@ test = "ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timesta ping = "Pong!" pong = "Ping!" # Override 'help' command output -# help = "Bot Help: test (or t), ping, help, hello, cmd, advert, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | More: 'help '" +# help = "Bot Help: test (or t), ping, version, help, hello, cmd, advert, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | More: 'help '" # Override 'cmd' command output -# cmd = "Available commands: test (or t), ping, help, hello, cmd, advert, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats" +# cmd = "Available commands: test (or t), ping, version, help, hello, cmd, advert, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats" [RandomLine] # Configurable command to act on a trigger word and respond with a random line from its file @@ -1199,6 +1199,10 @@ enabled = true enabled = true # channels = +[Version_Command] +enabled = true +# channels = + [Moon_Command] enabled = true # channels = diff --git a/config.ini.minimal-example b/config.ini.minimal-example index f080eb1..27937ef 100644 --- a/config.ini.minimal-example +++ b/config.ini.minimal-example @@ -4,6 +4,7 @@ # # # This is a minimal configuration file that enables only the core testing commands: # # - ping: Simple connectivity test # +# - version (ver): Show running bot version # # - test (t): Connection info with optional phrase # # - path (p): Decode hex path data to show repeaters # # - prefix: Look up repeaters by two-character prefix # @@ -84,7 +85,7 @@ bot_tx_rate_limit_seconds = 1.0 # Per-user rate limit: minimum seconds between bot replies to the same user # User key: public key when available, else sender name -per_user_rate_limit_seconds = 5 +per_user_rate_limit_seconds = 30 per_user_rate_limit_enabled = true # Transmission delay in milliseconds before sending messages @@ -255,6 +256,7 @@ meshcore_log_level = INFO # # # This minimal configuration enables only the core testing commands: # # - ping: Simple connectivity test # +# - version (ver): Show running bot version # # - test (t): Connection info with optional phrase # # - path (p): Decode hex path data to show repeaters # # - prefix: Look up repeaters by two-character prefix # @@ -268,6 +270,12 @@ meshcore_log_level = INFO # false: Ping command is disabled enabled = true +[Version_Command] +# Enable or disable the version command +# true: Version command is available (responds to 'version' or 'ver') +# false: Version command is disabled +enabled = true + [Test_Command] # Enable or disable the test command # true: Test command is available (responds to 'test' or 't') diff --git a/config.ini.quickstart b/config.ini.quickstart index 70c0e3c..2dc1e0a 100644 --- a/config.ini.quickstart +++ b/config.ini.quickstart @@ -14,6 +14,10 @@ enabled = true bot_latitude = 40.7128 bot_longitude = -74.0060 db_path = meshcore_bot.db +rate_limit_seconds = 10 +bot_tx_rate_limit_seconds = 1.0 +per_user_rate_limit_seconds = 30 +per_user_rate_limit_enabled = true [Channels] monitor_channels = general,test,emergency @@ -32,7 +36,7 @@ admin_commands = repeater,webviewer,reload,channelpause test = "ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}" ping = "Pong!" pong = "Ping!" -help = "Bot Help: test (or t), ping, help, hello, cmd, advert, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | More: 'help '" +help = "Bot Help: test (or t), ping, version, help, hello, cmd, advert, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | More: 'help '" [Logging] log_level = INFO @@ -48,6 +52,9 @@ default_country = US [Path_Command] enable_p_shortcut = true +[Version_Command] +enabled = true + # Sports - teams shown when user says "sports" with no args [Sports_Command] teams = seahawks,mariners,sounders,kraken diff --git a/modules/command_manager.py b/modules/command_manager.py index 6e846ba..8f09f46 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -300,7 +300,8 @@ class CommandManager: if wait_time > 0.1: return False, f"Rate limited. Wait {wait_time:.1f} seconds" return False, "" - # Per-user rate limit when enabled and key present + # Per-user rate limit when enabled and key present. + # Admin ACL controls command authorization only; it does not bypass send rate limits. if getattr(self.bot, 'per_user_rate_limit_enabled', False) and rate_limit_key: per_user = getattr(self.bot, 'per_user_rate_limiter', None) if per_user and not per_user.can_send(rate_limit_key): diff --git a/modules/commands/version_command.py b/modules/commands/version_command.py new file mode 100644 index 0000000..25193c6 --- /dev/null +++ b/modules/commands/version_command.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Version command for the MeshCore Bot. +Returns the currently running bot version string. +""" + +from typing import Any + +from ..models import MeshMessage +from ..version_info import resolve_runtime_version +from .base_command import BaseCommand + + +class VersionCommand(BaseCommand): + """Handles the version/ver command.""" + + name = "version" + keywords = ["version", "ver"] + description = "Show the running bot version." + category = "basic" + + short_description = "Show running bot version" + usage = "version" + examples = ["version", "ver"] + + def __init__(self, bot: Any): + super().__init__(bot) + self.version_enabled = self.get_config_value( + "Version_Command", + "enabled", + fallback=True, + value_type="bool", + ) + + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: + if not self.version_enabled: + return False + return super().can_execute(message, skip_channel_check=skip_channel_check) + + def get_help_text(self) -> str: + return self.description + + async def execute(self, message: MeshMessage) -> bool: + version_value = getattr(self.bot, "bot_version", None) + if not version_value: + bot_root = getattr(self.bot, "bot_root", ".") + version_value = resolve_runtime_version(bot_root).get("display", "unknown") + sender = message.sender_id or "Unknown" + response = f"@[{sender}] Bot version: {version_value}" + return await self.send_response(message, response) + diff --git a/modules/core.py b/modules/core.py index f2823df..92a6d29 100644 --- a/modules/core.py +++ b/modules/core.py @@ -39,6 +39,7 @@ from .service_plugin_loader import ServicePluginLoader from .solar_conditions import set_config from .transmission_tracker import TransmissionTracker from .utils import resolve_path +from .version_info import resolve_runtime_version from .web_viewer.integration import WebViewerIntegration @@ -86,6 +87,9 @@ class MeshCoreBot: # Bot start time for uptime tracking self.start_time = time.time() + self.version_info = resolve_runtime_version(self.bot_root) + self.bot_version = self.version_info.get("display", "unknown") + self.logger.info(f"Bot version: {self.bot_version}") # Initialize database manager first (needed by plugins) db_path = self.config.get('Bot', 'db_path', fallback='meshcore_bot.db') @@ -150,7 +154,7 @@ class MeshCoreBot: 'Bot', 'per_user_rate_limit_enabled', fallback=True ) self.per_user_rate_limiter = PerUserRateLimiter( - seconds=self.config.getfloat('Bot', 'per_user_rate_limit_seconds', fallback=5.0), + seconds=self.config.getfloat('Bot', 'per_user_rate_limit_seconds', fallback=30.0), max_entries=1000 ) # Nominatim rate limiter: 1.1 seconds between requests (Nominatim policy: max 1 req/sec) @@ -397,7 +401,7 @@ class MeshCoreBot: self.per_user_rate_limit_enabled = self.config.getboolean( 'Bot', 'per_user_rate_limit_enabled', fallback=True ) - new_per_user_seconds = self.config.getfloat('Bot', 'per_user_rate_limit_seconds', fallback=5.0) + new_per_user_seconds = self.config.getfloat('Bot', 'per_user_rate_limit_seconds', fallback=30.0) self.per_user_rate_limiter = PerUserRateLimiter(seconds=new_per_user_seconds, max_entries=1000) new_nominatim_rate_limit = self.config.getfloat('Bot', 'nominatim_rate_limit_seconds', fallback=1.1) @@ -535,6 +539,15 @@ rate_limit_seconds = 2 # Prevents bot from overwhelming the mesh network bot_tx_rate_limit_seconds = 1.0 +# Per-user rate limiting in seconds between replies to the same user +# Helps reduce airtime use from rapid repeated responses to one sender +per_user_rate_limit_seconds = 30 + +# Enable or disable per-user rate limiting +# true: Enforce per-user spacing (recommended) +# false: Disable per-user limiter +per_user_rate_limit_enabled = true + # Transmission delay in milliseconds before sending messages # Helps prevent message collisions on the mesh network # Recommended: 100-500ms for busy networks, 0 for quiet networks diff --git a/modules/service_plugins/packet_capture_service.py b/modules/service_plugins/packet_capture_service.py index f53854a..9e1f277 100644 --- a/modules/service_plugins/packet_capture_service.py +++ b/modules/service_plugins/packet_capture_service.py @@ -19,6 +19,7 @@ from meshcore import EventType # Import bot's enums from ..enums import PayloadType, PayloadVersion, RouteType +from ..version_info import resolve_runtime_version # Import bot's utilities for packet hash from ..utils import calculate_packet_hash, decode_path_len_byte, parse_trace_payload_route_hashes @@ -1431,40 +1432,14 @@ class PacketCaptureService(BaseServicePlugin): return False def _load_client_version(self) -> str: - """Load client version (matches original script). - - Returns: - str: Version string (e.g., 'meshcore-bot/1.0.0-abcdef'). - """ + """Load client version from shared runtime resolver.""" try: - import os - import subprocess - script_dir = os.path.dirname(os.path.abspath(__file__)) - version_file = os.path.join(script_dir, '..', '..', '.version_info') - - # First try to load from .version_info file (created by installer) - if os.path.exists(version_file): - with open(version_file) as f: - version_data = json.load(f) - installer_ver = version_data.get('installer_version', 'unknown') - git_hash = version_data.get('git_hash', 'unknown') - return f"meshcore-bot/{installer_ver}-{git_hash}" - - # Fallback: try to get git information directly - try: - result = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'], - cwd=os.path.dirname(script_dir), capture_output=True, text=True, timeout=5) - if result.returncode == 0: - git_hash = result.stdout.strip() - return f"meshcore-bot/dev-{git_hash}" - except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError): - pass - + info = resolve_runtime_version(self.bot.bot_root) + display = info.get("display") or "unknown" + return f"meshcore-bot/{display}" except Exception as e: self.logger.debug(f"Could not load version info: {e}") - - # Final fallback - return "meshcore-bot/unknown" + return "meshcore-bot/unknown" async def get_firmware_info(self) -> dict[str, str]: """Get firmware information from meshcore device (matches original script). diff --git a/modules/version_info.py b/modules/version_info.py new file mode 100644 index 0000000..0c8cb60 --- /dev/null +++ b/modules/version_info.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Version resolution utilities for MeshCore Bot. + +Centralizes runtime version lookup so bot command output, web viewer, and +services all report consistent version information. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from pathlib import Path +from typing import Any, Optional + + +def _normalize_tag(value: Optional[str]) -> Optional[str]: + if not value: + return None + value = value.strip() + if not value: + return None + return value if value.startswith("v") else f"v{value}" + + +def _safe_git_run(repo_root: Path, args: list[str]) -> Optional[str]: + try: + result = subprocess.run( + ["git", "-C", str(repo_root)] + args, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + return None + out = (result.stdout or "").strip() + return out or None + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError, OSError): + return None + + +def _read_version_file(repo_root: Path) -> Optional[str]: + version_file = repo_root / ".version_info" + if not version_file.is_file(): + return None + try: + with open(version_file, encoding="utf-8") as fh: + data = json.load(fh) + version = data.get("installer_version") or data.get("tag") + return _normalize_tag(version) + except (OSError, json.JSONDecodeError, AttributeError): + return None + + +def _read_pyproject_version(repo_root: Path) -> Optional[str]: + pyproject_path = repo_root / "pyproject.toml" + if not pyproject_path.is_file(): + return None + try: + text = pyproject_path.read_text(encoding="utf-8") + except OSError: + return None + + in_project_section = False + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + in_project_section = line == "[project]" + continue + if not in_project_section: + continue + match = re.match(r'version\s*=\s*"([^"]+)"', line) + if match: + return _normalize_tag(match.group(1)) + return None + + +def resolve_runtime_version(repo_root: Path | str) -> dict[str, Optional[str]]: + """Resolve version metadata and a single runtime display value. + + Returns a dict with: + - baked: release-like version from env/.version_info/pyproject (v-prefixed) + - tag: same as baked for template compatibility + - branch, commit, date: git metadata when available + - display: final runtime version string + """ + root = Path(repo_root).resolve() + + env_version = _normalize_tag(os.environ.get("MESHCORE_BOT_VERSION", "").strip()) + file_version = _read_version_file(root) + pyproject_version = _read_pyproject_version(root) + baked = env_version or file_version or pyproject_version + + branch = _safe_git_run(root, ["rev-parse", "--abbrev-ref", "HEAD"]) + commit = _safe_git_run(root, ["rev-parse", "--short", "HEAD"]) + date_raw = _safe_git_run(root, ["show", "-s", "--format=%ci", "HEAD"]) + date = None + if date_raw: + # %ci format is "YYYY-MM-DD HH:MM:SS +TZ"; keep date only. + date = date_raw.split()[0] if " " in date_raw else date_raw + + display: Optional[str] + if branch and branch != "main" and commit: + display = f"{branch}-{commit}" + elif branch == "main" and baked: + display = baked + else: + # Fallbacks for non-git/runtime-constrained environments. + display = baked or (f"{branch}-{commit}" if branch and commit else None) or "unknown" + + return { + "baked": baked, + "tag": baked, + "branch": branch, + "commit": commit, + "date": date, + "display": display, + } + diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index c677bd2..c7b8c98 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -10,7 +10,6 @@ import logging import os import re import sqlite3 -import subprocess import sys import threading import time @@ -35,6 +34,7 @@ from flask import ( from flask_socketio import SocketIO, disconnect, emit from modules.security_utils import VALID_JOURNAL_MODES, validate_sql_identifier +from modules.version_info import resolve_runtime_version def _apply_werkzeug_websocket_fix() -> None: @@ -248,55 +248,14 @@ class BotDataViewer: return config def _get_version_info(self) -> dict[str, Optional[str]]: - """Get version info for footer: tag if on a tag, else branch, commit hash and date. - Checks MESHCORE_BOT_VERSION env (Docker/build), then .version_info, then git. Never raises.""" - out: dict[str, Optional[str]] = {"tag": None, "branch": None, "commit": None, "date": None} - # Docker / CI: version set at build time (e.g. ARG + ENV in Dockerfile) - env_version = os.environ.get("MESHCORE_BOT_VERSION", "").strip() - if env_version: - out["tag"] = env_version if env_version.startswith("v") else f"v{env_version}" - return out - version_file = self.bot_root / ".version_info" - try: - if version_file.is_file(): - with open(version_file) as f: - data = json.load(f) - # Installer/tag installs write installer_version (often the tag name) - tag = data.get("installer_version") or data.get("tag") - if tag: - out["tag"] = tag if tag.startswith("v") else f"v{tag}" - return out - except (OSError, json.JSONDecodeError, KeyError): - pass - try: - def run(cmd: list[str]) -> Optional[str]: - args = ["git", "-C", str(self.bot_root)] + cmd - result = subprocess.run( - args, capture_output=True, text=True, timeout=5 - ) - if result.returncode != 0: - return None - return (result.stdout or "").strip() or None - - # Check if HEAD is a tag - tag = run(["describe", "--exact-match", "HEAD"]) - if tag: - out["tag"] = tag if tag.startswith("v") else f"v{tag}" - return out - branch = run(["rev-parse", "--abbrev-ref", "HEAD"]) - commit = run(["rev-parse", "--short", "HEAD"]) - date_raw = run(["show", "-s", "--format=%ci", "HEAD"]) - out["branch"] = branch or None - out["commit"] = commit or None - if date_raw: - try: - # %ci is "YYYY-MM-DD HH:MM:SS +tz"; take date part only - out["date"] = date_raw.split()[0] - except IndexError: - out["date"] = date_raw - return out - except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError, OSError): - return out + """Get version info for footer from shared runtime resolver.""" + info = resolve_runtime_version(self.bot_root) + return { + "tag": info.get("tag"), + "branch": info.get("branch"), + "commit": info.get("commit"), + "date": info.get("date"), + } def _setup_template_context(self): """Setup template context processor to inject global variables""" diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py new file mode 100644 index 0000000..66a0d14 --- /dev/null +++ b/tests/commands/test_version_command.py @@ -0,0 +1,60 @@ +"""Tests for modules.commands.version_command.""" + +import pytest + +from modules.commands.version_command import VersionCommand +from tests.conftest import mock_message + + +class TestVersionCommand: + """Tests for VersionCommand.""" + + def test_can_execute_when_enabled(self, command_mock_bot): + command_mock_bot.config.add_section("Version_Command") + command_mock_bot.config.set("Version_Command", "enabled", "true") + cmd = VersionCommand(command_mock_bot) + msg = mock_message(content="version", is_dm=True) + assert cmd.can_execute(msg) is True + + def test_can_execute_when_disabled(self, command_mock_bot): + command_mock_bot.config.add_section("Version_Command") + command_mock_bot.config.set("Version_Command", "enabled", "false") + cmd = VersionCommand(command_mock_bot) + msg = mock_message(content="version", is_dm=True) + assert cmd.can_execute(msg) is False + + @pytest.mark.asyncio + async def test_execute_returns_bot_version(self, command_mock_bot): + command_mock_bot.config.add_section("Version_Command") + command_mock_bot.config.set("Version_Command", "enabled", "true") + command_mock_bot.bot_version = "dev-abc1234" + + cmd = VersionCommand(command_mock_bot) + msg = mock_message(content="version", is_dm=True) + result = await cmd.execute(msg) + + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + assert call_args is not None + assert call_args[0][1] == "@[TestUser] Bot version: dev-abc1234" + + @pytest.mark.asyncio + async def test_execute_falls_back_to_resolver(self, command_mock_bot, monkeypatch): + command_mock_bot.config.add_section("Version_Command") + command_mock_bot.config.set("Version_Command", "enabled", "true") + command_mock_bot.bot_version = None + command_mock_bot.bot_root = "." + monkeypatch.setattr( + "modules.commands.version_command.resolve_runtime_version", + lambda _root: {"display": "v0.9"}, + ) + + cmd = VersionCommand(command_mock_bot) + msg = mock_message(content="ver", is_dm=True) + result = await cmd.execute(msg) + + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + assert call_args is not None + assert call_args[0][1] == "@[TestUser] Bot version: v0.9" + diff --git a/tests/test_version_info.py b/tests/test_version_info.py new file mode 100644 index 0000000..b0826b7 --- /dev/null +++ b/tests/test_version_info.py @@ -0,0 +1,73 @@ +"""Tests for modules.version_info.""" + +import json + +from modules.version_info import resolve_runtime_version + + +def test_main_branch_uses_baked_env_version(tmp_path, monkeypatch): + (tmp_path / "pyproject.toml").write_text('[project]\nversion = "0.1.0"\n', encoding="utf-8") + monkeypatch.setenv("MESHCORE_BOT_VERSION", "0.9") + + def _fake_git_run(_root, args): + mapping = { + ("rev-parse", "--abbrev-ref", "HEAD"): "main", + ("rev-parse", "--short", "HEAD"): "abc1234", + ("show", "-s", "--format=%ci", "HEAD"): "2026-04-05 10:00:00 +0000", + } + return mapping.get(tuple(args)) + + monkeypatch.setattr("modules.version_info._safe_git_run", _fake_git_run) + info = resolve_runtime_version(tmp_path) + + assert info["baked"] == "v0.9" + assert info["display"] == "v0.9" + assert info["branch"] == "main" + assert info["commit"] == "abc1234" + assert info["date"] == "2026-04-05" + + +def test_non_main_branch_uses_branch_and_commit(tmp_path, monkeypatch): + (tmp_path / ".version_info").write_text( + json.dumps({"installer_version": "0.9.0"}), + encoding="utf-8", + ) + + def _fake_git_run(_root, args): + mapping = { + ("rev-parse", "--abbrev-ref", "HEAD"): "dev", + ("rev-parse", "--short", "HEAD"): "fedcba9", + ("show", "-s", "--format=%ci", "HEAD"): "2026-04-05 11:00:00 +0000", + } + return mapping.get(tuple(args)) + + monkeypatch.setattr("modules.version_info._safe_git_run", _fake_git_run) + info = resolve_runtime_version(tmp_path) + + assert info["baked"] == "v0.9.0" + assert info["display"] == "dev-fedcba9" + assert info["branch"] == "dev" + + +def test_baked_precedence_file_over_pyproject(tmp_path, monkeypatch): + (tmp_path / ".version_info").write_text( + json.dumps({"installer_version": "0.8.1"}), + encoding="utf-8", + ) + (tmp_path / "pyproject.toml").write_text('[project]\nversion = "0.1.0"\n', encoding="utf-8") + monkeypatch.delenv("MESHCORE_BOT_VERSION", raising=False) + monkeypatch.setattr("modules.version_info._safe_git_run", lambda *_args, **_kwargs: None) + + info = resolve_runtime_version(tmp_path) + assert info["baked"] == "v0.8.1" + assert info["display"] == "v0.8.1" + + +def test_fallback_to_unknown_without_baked_or_git(tmp_path, monkeypatch): + monkeypatch.delenv("MESHCORE_BOT_VERSION", raising=False) + monkeypatch.setattr("modules.version_info._safe_git_run", lambda *_args, **_kwargs: None) + + info = resolve_runtime_version(tmp_path) + assert info["baked"] is None + assert info["display"] == "unknown" +