diff --git a/telegram_bot/.env.example b/telegram_bot/.env.example index fc0a05a1c..d729c986c 100644 --- a/telegram_bot/.env.example +++ b/telegram_bot/.env.example @@ -16,6 +16,12 @@ RUSTCHAIN_VERIFY_SSL=false # === Rate Limiting === # Maximum requests per minute per user (prevents API abuse) RATE_LIMIT_PER_MINUTE=10 +# Minimum seconds between requests from the same Telegram user +RATE_LIMIT_COOLDOWN_SECONDS=5 + +# === RTC Bounty Reference Rate === +# This is the bounty reference rate shown by /price, not a live market quote. +RTC_BOUNTY_REFERENCE_RATE_USD=0.10 # === Logging === # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL diff --git a/telegram_bot/README.md b/telegram_bot/README.md index 53ec7cb91..88c7bfe92 100644 --- a/telegram_bot/README.md +++ b/telegram_bot/README.md @@ -26,6 +26,8 @@ This Telegram bot provides a simple interface to query the RustChain blockchain | `/health` | Check node health status | `/health` | | `/epoch` | Get current epoch information | `/epoch` | | `/balance` | Check wallet balance | `/balance Ivan-houzhiwen` | +| `/miners` | List active miners and status fields | `/miners` | +| `/price` | Show the RTC bounty reference rate, not a live market quote | `/price` | | `/stats` | Get network statistics | `/stats` | ## Quick Start @@ -77,6 +79,8 @@ All configuration is done via environment variables: | `RUSTCHAIN_API_URL` | `https://rustchain.org` | RustChain API endpoint | | `RUSTCHAIN_VERIFY_SSL` | `false` | Verify SSL certificates | | `RATE_LIMIT_PER_MINUTE` | `10` | Max requests per user per minute | +| `RATE_LIMIT_COOLDOWN_SECONDS` | `5` | Minimum seconds between requests from one user | +| `RTC_BOUNTY_REFERENCE_RATE_USD` | `0.10` | Bounty reference USD rate shown by `/price`; not a live market quote | | `LOG_LEVEL` | `INFO` | Logging level | ## Command Examples @@ -162,6 +166,48 @@ pytest tests/ -v pytest tests/ -v --cov=telegram_bot --cov-report=html ``` +## Deployment + +### Railway + +1. Create a new Railway service from this repository. +2. Set the service root to `telegram_bot` if your Railway project supports a root directory. +3. Add these environment variables: + - `TELEGRAM_BOT_TOKEN` + - `RUSTCHAIN_API_URL=https://50.28.86.131` + - `RUSTCHAIN_VERIFY_SSL=false` + - `RATE_LIMIT_COOLDOWN_SECONDS=5` +4. Use this start command: + +```bash +python rustchain_query_bot.py +``` + +### Fly.io + +Create a small Python app, copy this directory, set the same environment variables with `fly secrets set`, and use `python rustchain_query_bot.py` as the process command. + +### systemd + +```ini +[Unit] +Description=RustChain Telegram Query Bot +After=network-online.target + +[Service] +WorkingDirectory=/opt/rustchain/telegram_bot +Environment=TELEGRAM_BOT_TOKEN=replace-me +Environment=RUSTCHAIN_API_URL=https://50.28.86.131 +Environment=RUSTCHAIN_VERIFY_SSL=false +Environment=RATE_LIMIT_COOLDOWN_SECONDS=5 +ExecStart=/usr/bin/python3 /opt/rustchain/telegram_bot/rustchain_query_bot.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + ## Development ### Code Style diff --git a/telegram_bot/rustchain_query_bot.py b/telegram_bot/rustchain_query_bot.py index 9db141f98..897d22315 100644 --- a/telegram_bot/rustchain_query_bot.py +++ b/telegram_bot/rustchain_query_bot.py @@ -18,6 +18,7 @@ import os import sys import logging +import time from typing import Optional, Dict, Any import requests @@ -45,6 +46,16 @@ # Rate limiting (requests per minute per user) RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "10")) +RATE_LIMIT_COOLDOWN_SECONDS = int( + os.getenv( + "RATE_LIMIT_COOLDOWN_SECONDS", + os.getenv("RATE_LIMIT_WINDOW_SECONDS", "5"), + ) +) +RTC_BOUNTY_REFERENCE_RATE_USD = os.getenv( + "RTC_BOUNTY_REFERENCE_RATE_USD", + os.getenv("RTC_REFERENCE_RATE_USD", "0.10"), +) # Logging configuration LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() @@ -68,24 +79,36 @@ class RateLimiter: """Simple in-memory rate limiter per user.""" - def __init__(self, max_requests: int = RATE_LIMIT_PER_MINUTE): + def __init__( + self, + max_requests: int = RATE_LIMIT_PER_MINUTE, + window_seconds: int = 60, + min_interval_seconds: int = RATE_LIMIT_COOLDOWN_SECONDS, + ): self.max_requests = max_requests + self.window_seconds = window_seconds + self.min_interval_seconds = min_interval_seconds self.user_requests: Dict[int, list] = {} def is_allowed(self, user_id: int) -> bool: """Check if user is allowed to make a request.""" - import time current_time = time.time() - minute_ago = current_time - 60 + window_start = current_time - self.window_seconds if user_id not in self.user_requests: self.user_requests[user_id] = [] # Clean old requests self.user_requests[user_id] = [ - t for t in self.user_requests[user_id] if t > minute_ago + t for t in self.user_requests[user_id] if t > window_start ] + if ( + self.user_requests[user_id] + and current_time - self.user_requests[user_id][-1] < self.min_interval_seconds + ): + return False + # Check rate limit if len(self.user_requests[user_id]) >= self.max_requests: return False @@ -150,6 +173,47 @@ def miners(self) -> Dict[str, Any]: # Global API client instance api_client = RustChainClient() + +def _extract_miners(response: Any) -> list: + """Normalize the miner list from supported RustChain API shapes.""" + if isinstance(response, list): + return response + if isinstance(response, dict): + for key in ("miners", "active_miners", "data", "results"): + value = response.get(key) + if isinstance(value, list): + return value + return [] + + +def _miner_label(miner: Any) -> str: + """Format one miner for Telegram output.""" + if isinstance(miner, str): + return miner + if not isinstance(miner, dict): + return str(miner) + + wallet = ( + miner.get("miner_id") + or miner.get("wallet_name") + or miner.get("wallet") + or miner.get("miner") + or miner.get("id") + or "unknown" + ) + arch = miner.get("architecture") or miner.get("arch") or miner.get("device_arch") + last_seen = miner.get("last_attestation") or miner.get("last_seen") or miner.get("ts_ok") + status = miner.get("status") or ("online" if miner.get("online") else "") + + parts = [f"`{wallet}`"] + if arch: + parts.append(str(arch)) + if status: + parts.append(str(status)) + if last_seen: + parts.append(f"last: {last_seen}") + return " - ".join(parts) + # ============================================================================= # Bot Commands # ============================================================================= @@ -168,6 +232,8 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): /health - Check node health status /epoch - Get current epoch info /balance - Check wallet balance +/miners - List active miners +/price - Show RTC reference rate /stats - Get network statistics /help - Show this help message @@ -195,6 +261,12 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): Check RTC balance for a wallet/miner ID Example: /balance Ivan-houzhiwen +/miners + List active miners and their latest status + +/price + Show the RTC bounty reference rate + /stats Get network statistics (miner count) @@ -203,10 +275,11 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): **Notes:** - All queries are read-only and safe -- Rate limit: {rate_limit} requests/minute +- Rate limit: {rate_limit} requests/minute plus a {cooldown_seconds}s cooldown - API: `{api_url}` """.format( rate_limit=RATE_LIMIT_PER_MINUTE, + cooldown_seconds=RATE_LIMIT_COOLDOWN_SECONDS, api_url=RUSTCHAIN_API_URL ) await update.message.reply_text(help_text, parse_mode="Markdown") @@ -341,6 +414,70 @@ async def cmd_balance(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text(balance_text, parse_mode="Markdown") +async def cmd_miners(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /miners command - list active miners.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + "Rate limit exceeded. Please wait before making more requests." + ) + return + + logger.info(f"User {user.id} requested active miners") + await update.message.reply_text("Fetching active miners...") + + result = api_client.miners() + if isinstance(result, dict) and "error" in result: + await update.message.reply_text(f"Error: {result['error']}") + return + + miners = _extract_miners(result) + if not miners: + await update.message.reply_text("No active miners were returned by the node.") + return + + visible = miners[:10] + miner_lines = "\n".join( + f"{idx + 1}. {_miner_label(miner)}" + for idx, miner in enumerate(visible) + ) + suffix = "" + if len(miners) > len(visible): + suffix = f"\n\nShowing 10 of {len(miners)} miners." + + miners_text = f""" +**Active RustChain Miners** + +{miner_lines}{suffix} + +API: `{RUSTCHAIN_API_URL}` +""" + await update.message.reply_text(miners_text, parse_mode="Markdown") + + +async def cmd_price(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /price command - show RTC reference price.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + "Rate limit exceeded. Please wait before making more requests." + ) + return + + logger.info(f"User {user.id} requested RTC reference price") + price_text = f""" +**RTC Bounty Reference Rate** + +1 RTC = ${RTC_BOUNTY_REFERENCE_RATE_USD} USD bounty reference rate + +Source: RustChain bounty descriptions and maintainer reference wording. +This command does not fetch or report a live market price. +""" + await update.message.reply_text(price_text, parse_mode="Markdown") + + async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /stats command - get network statistics.""" user = update.effective_user @@ -360,8 +497,9 @@ async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): miners_result = api_client.miners() miner_count = "N/A" - if "error" not in miners_result and isinstance(miners_result, list): - miner_count = len(miners_result) + if not (isinstance(miners_result, dict) and "error" in miners_result): + miners = _extract_miners(miners_result) + miner_count = len(miners) if miners else "N/A" # Get epoch info for additional stats epoch_result = api_client.epoch() @@ -402,6 +540,8 @@ def set_bot_commands(application: Application): BotCommand("health", "Check node health"), BotCommand("epoch", "Get current epoch info"), BotCommand("balance", "Check wallet balance"), + BotCommand("miners", "List active miners"), + BotCommand("price", "Show RTC bounty reference rate"), BotCommand("stats", "Get network statistics"), ] return commands @@ -456,6 +596,8 @@ def main(): application.add_handler(CommandHandler("health", cmd_health)) application.add_handler(CommandHandler("epoch", cmd_epoch)) application.add_handler(CommandHandler("balance", cmd_balance)) + application.add_handler(CommandHandler("miners", cmd_miners)) + application.add_handler(CommandHandler("price", cmd_price)) application.add_handler(CommandHandler("stats", cmd_stats)) # Register error handler @@ -468,7 +610,7 @@ def main(): print("\n🛡️ RustChain Query Bot starting...") print(f" API: {RUSTCHAIN_API_URL}") print(f" Verify SSL: {RUSTCHAIN_VERIFY_SSL}") - print(f" Rate limit: {RATE_LIMIT_PER_MINUTE} req/min") + print(f" Rate limit: {RATE_LIMIT_PER_MINUTE} req/min, {RATE_LIMIT_COOLDOWN_SECONDS}s cooldown") print("\nPress Ctrl+C to stop\n") # Run polling diff --git a/telegram_bot/tests/test_bot_commands.py b/telegram_bot/tests/test_bot_commands.py index 64bd76c29..078f68012 100644 --- a/telegram_bot/tests/test_bot_commands.py +++ b/telegram_bot/tests/test_bot_commands.py @@ -14,6 +14,15 @@ class TestBotCommands: """Tests for bot command handlers.""" + @pytest.fixture(autouse=True) + def reset_rate_limiter(self): + """Use a no-delay rate limiter so command tests stay independent.""" + import rustchain_query_bot + + rustchain_query_bot.rate_limiter = rustchain_query_bot.RateLimiter( + min_interval_seconds=0 + ) + @pytest.fixture def mock_update(self): """Create a mock update object.""" @@ -152,6 +161,41 @@ async def test_cmd_stats_success(self, mock_client, mock_update, mock_context): call_args = mock_update.message.reply_text.call_args assert "2" in call_args[0][0] # miner count + @pytest.mark.asyncio + @patch('rustchain_query_bot.api_client') + async def test_cmd_miners_success(self, mock_client, mock_update, mock_context): + """Test /miners command success.""" + from rustchain_query_bot import cmd_miners + + mock_client.miners.return_value = { + "miners": [ + { + "miner_id": "wallet-one", + "architecture": "PowerPC G4", + "status": "online", + } + ] + } + + await cmd_miners(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "wallet-one" in call_args[0][0] + assert "PowerPC G4" in call_args[0][0] + + @pytest.mark.asyncio + async def test_cmd_price_success(self, mock_update, mock_context): + """Test /price command success.""" + from rustchain_query_bot import cmd_price + + await cmd_price(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "$0.10" in call_args[0][0] + assert "not fetch or report a live market price" in call_args[0][0] + class TestConfiguration: """Tests for configuration validation.""" @@ -191,7 +235,7 @@ def test_set_bot_commands(self): commands = set_bot_commands(None) - assert len(commands) == 6 + assert len(commands) == 8 # BotCommand uses 'command' attribute for the command name command_names = [c.command for c in commands] assert "start" in command_names @@ -199,4 +243,6 @@ def test_set_bot_commands(self): assert "health" in command_names assert "epoch" in command_names assert "balance" in command_names + assert "miners" in command_names + assert "price" in command_names assert "stats" in command_names diff --git a/telegram_bot/tests/test_rustchain_client.py b/telegram_bot/tests/test_rustchain_client.py index 0507521fe..cfb446718 100644 --- a/telegram_bot/tests/test_rustchain_client.py +++ b/telegram_bot/tests/test_rustchain_client.py @@ -144,6 +144,7 @@ def test_init_default_limit(self): """Test rate limiter initialization.""" limiter = RateLimiter() assert limiter.max_requests == 10 # Default from config + assert limiter.min_interval_seconds == 5 def test_init_custom_limit(self): """Test rate limiter with custom limit.""" @@ -157,7 +158,7 @@ def test_first_request_allowed(self): def test_requests_within_limit_allowed(self): """Test that requests within limit are allowed.""" - limiter = RateLimiter(max_requests=3) + limiter = RateLimiter(max_requests=3, min_interval_seconds=0) assert limiter.is_allowed(123) is True assert limiter.is_allowed(123) is True @@ -165,7 +166,7 @@ def test_requests_within_limit_allowed(self): def test_requests_exceeding_limit_blocked(self): """Test that requests exceeding limit are blocked.""" - limiter = RateLimiter(max_requests=2) + limiter = RateLimiter(max_requests=2, min_interval_seconds=0) assert limiter.is_allowed(123) is True assert limiter.is_allowed(123) is True @@ -173,7 +174,7 @@ def test_requests_exceeding_limit_blocked(self): def test_different_users_independent(self): """Test that rate limits are per-user.""" - limiter = RateLimiter(max_requests=1) + limiter = RateLimiter(max_requests=1, min_interval_seconds=0) assert limiter.is_allowed(123) is True assert limiter.is_allowed(123) is False @@ -184,7 +185,7 @@ def test_old_requests_expire(self, mock_time): """Test that old requests are cleaned up.""" mock_time.return_value = 1000.0 - limiter = RateLimiter(max_requests=2) + limiter = RateLimiter(max_requests=2, min_interval_seconds=0) limiter.is_allowed(123) # Request at t=1000 limiter.is_allowed(123) # Request at t=1000 @@ -196,3 +197,17 @@ def test_old_requests_expire(self, mock_time): # Now the request should be allowed again assert limiter.is_allowed(123) is True + + @patch('time.time') + def test_minimum_interval_blocks_rapid_repeat(self, mock_time): + """Test 1-request-per-5-seconds throttle.""" + limiter = RateLimiter(max_requests=10, min_interval_seconds=5) + + mock_time.return_value = 1000.0 + assert limiter.is_allowed(123) is True + + mock_time.return_value = 1004.0 + assert limiter.is_allowed(123) is False + + mock_time.return_value = 1005.0 + assert limiter.is_allowed(123) is True