mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-06 18:59:37 +00:00
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.
This commit is contained in:
@@ -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`
|
||||
|
||||
+7
-3
@@ -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 <command>'"
|
||||
# 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 <command>'"
|
||||
|
||||
# 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 =
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 <command>'"
|
||||
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 <command>'"
|
||||
|
||||
[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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
+15
-2
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user