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:
agessaman
2026-04-05 20:00:01 -07:00
parent fbf39958f1
commit 883b67daf9
12 changed files with 365 additions and 91 deletions
+2 -2
View File
@@ -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
View File
@@ -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 =
+9 -1
View File
@@ -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')
+8 -1
View File
@@ -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
+2 -1
View File
@@ -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):
+51
View File
@@ -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
View File
@@ -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).
+123
View File
@@ -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,
}
+9 -50
View File
@@ -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"""
+60
View File
@@ -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"
+73
View File
@@ -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"