diff --git a/submissions/notification-bot/README.md b/submissions/notification-bot/README.md new file mode 100644 index 000000000..1a6f73bb1 --- /dev/null +++ b/submissions/notification-bot/README.md @@ -0,0 +1,71 @@ +# RustChain Notification Bot + +Real-time monitoring bot for RustChain — get alerts on balance changes and epoch transitions via Telegram or Discord. + +## Features + +- 🔍 **Balance Monitoring** — track wallet balance changes with configurable thresholds +- 🔄 **Epoch Tracking** — get notified on epoch transitions +- 📢 **Multi-channel** — Telegram bot + Discord webhook support +- ⚙️ **Configurable Rules** — set thresholds, enable/disable channels per rule +- 🔁 **Polling** — configurable poll interval with graceful error handling + +## Quick Start + +```bash +# Install dependencies +pip install -r requirements.txt + +# Copy and edit config +cp config.example.json config.json +# Edit config.json with your API URL, wallet addresses, and bot tokens + +# Run +python bot.py -c config.json + +# One-shot mode (single check, for testing) +python bot.py -c config.json --once +``` + +## Configuration + +Copy `config.example.json` to `config.json` and fill in: + +| Field | Description | +|---|---| +| `api.base_url` | RustChain REST API base URL | +| `api.timeout` | Request timeout in seconds (default: 15) | +| `poll_interval_seconds` | Seconds between check cycles (default: 30) | +| `wallets` | List of wallet addresses to monitor | +| `channels.telegram` | Telegram bot token + chat ID | +| `channels.discord` | Discord webhook URL | +| `rules.balance_change.threshold` | Minimum balance delta to trigger notification (0 = any change) | + +## Notification Rules + +### Balance Change +Triggers when a wallet's balance changes by at least the configured threshold. Set `threshold: 0` to alert on any change. + +### Epoch Change +Triggers on every epoch transition with full epoch metadata. + +### Error Alerts +Sends error notifications when API calls fail. Disable with `rules.errors.enabled: false`. + +## Architecture + +``` +bot.py +├── RustChainClient — API wrapper (balance, epoch, network info) +├── Notifier — Rule engine + multi-channel dispatch +├── run_monitor() — Main polling loop with state tracking +└── CLI — argparse entry point +``` + +## Dependencies + +- `requests` — HTTP client for API calls and webhook delivery + +## License + +MIT diff --git a/submissions/notification-bot/bot.py b/submissions/notification-bot/bot.py new file mode 100644 index 000000000..3d29c5740 --- /dev/null +++ b/submissions/notification-bot/bot.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +RustChain Notification Bot +Monitors balance changes, epoch transitions, and sends alerts via Telegram/Discord. +""" + +import json +import time +import logging +import argparse +from pathlib import Path +from datetime import datetime, timezone + +import requests + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +log = logging.getLogger("rc-notify") + + +# --------------------------------------------------------------------------- +# Config loader +# --------------------------------------------------------------------------- + +def load_config(path: str) -> dict: + cfg_path = Path(path) + if not cfg_path.exists(): + log.error("Config file not found: %s", path) + raise SystemExit(1) + with open(cfg_path) as f: + return json.load(f) + + +# --------------------------------------------------------------------------- +# RustChain API client +# --------------------------------------------------------------------------- + +class RustChainClient: + """Light-weight wrapper around RustChain REST API.""" + + def __init__(self, base_url: str, timeout: int = 15): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.session = requests.Session() + + def _get(self, endpoint: str, params: dict | None = None) -> dict: + url = f"{self.base_url}{endpoint}" + resp = self.session.get(url, params=params, timeout=self.timeout) + resp.raise_for_status() + return resp.json() + + def get_balance(self, address: str) -> int: + """Return balance (smallest unit) for *address*.""" + data = self._get(f"/accounts/{address}") + return int(data.get("balance", 0)) + + def get_epoch(self) -> dict: + """Return current epoch info.""" + return self._get("/epoch") + + def get_network_info(self) -> dict: + """Return general network info.""" + return self._get("/network/info") + + +# --------------------------------------------------------------------------- +# Notification senders +# --------------------------------------------------------------------------- + +def send_telegram(token: str, chat_id: str, message: str) -> bool: + url = f"https://api.telegram.org/bot{token}/sendMessage" + payload = {"chat_id": chat_id, "text": message, "parse_mode": "Markdown"} + try: + resp = requests.post(url, json=payload, timeout=10) + resp.raise_for_status() + return True + except Exception as exc: + log.error("Telegram send failed: %s", exc) + return False + + +def send_discord(webhook_url: str, message: str) -> bool: + payload = {"content": message} + try: + resp = requests.post(webhook_url, json=payload, timeout=10) + resp.raise_for_status() + return True + except Exception as exc: + log.error("Discord send failed: %s", exc) + return False + + +# --------------------------------------------------------------------------- +# Notifier — decides *what* to send and via which channel +# --------------------------------------------------------------------------- + +class Notifier: + def __init__(self, config: dict): + self.cfg = config + self.channels = config.get("channels", {}) + self.rules = config.get("rules", {}) + + def _send_all(self, message: str): + tg = self.channels.get("telegram") + if tg and tg.get("enabled"): + send_telegram(tg["bot_token"], tg["chat_id"], message) + dc = self.channels.get("discord") + if dc and dc.get("enabled"): + send_discord(dc["webhook_url"], message) + + def notify_balance_change(self, address: str, old: int, new: int): + rule = self.rules.get("balance_change", {}) + threshold = rule.get("threshold", 0) + diff = abs(new - old) + if diff < threshold: + return + direction = "⬆️ increased" if new > old else "⬇️ decreased" + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + msg = ( + f"💰 *Balance Change Detected*\n" + f"Address: `{address[:8]}…{address[-6:]}`\n" + f"Balance {direction}: `{old}` → `{new}` (Δ `{diff}`)\n" + f"_{ts}_" + ) + self._send_all(msg) + log.info("Balance change notification sent: %s → %s", old, new) + + def notify_epoch_change(self, old_epoch: int, new_epoch: int, epoch_data: dict): + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + msg = ( + f"🔄 *Epoch Transition*\n" + f"Epoch: `{old_epoch}` → `{new_epoch}`\n" + f"```json\n{json.dumps(epoch_data, indent=2)[:500]}\n```\n" + f"_{ts}_" + ) + self._send_all(msg) + log.info("Epoch change notification sent: %s → %s", old_epoch, new_epoch) + + def notify_error(self, error_msg: str): + rule = self.rules.get("errors", {}) + if not rule.get("enabled", True): + return + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + msg = f"🚨 *RustChain Monitor Error*\n`{error_msg}`\n_{ts}_" + self._send_all(msg) + + +# --------------------------------------------------------------------------- +# Monitor loop +# --------------------------------------------------------------------------- + +def run_monitor(config_path: str, once: bool = False): + cfg = load_config(config_path) + api = RustChainClient( + cfg["api"]["base_url"], + timeout=cfg["api"].get("timeout", 15), + ) + notifier = Notifier(cfg) + + wallets = cfg.get("wallets", []) + poll_interval = cfg.get("poll_interval_seconds", 30) + + # State + balances: dict[str, int] = {} + current_epoch: int | None = None + + # Initial fetch + for w in wallets: + try: + balances[w] = api.get_balance(w) + log.info("Initial balance for %s: %s", w[:8] + "…", balances[w]) + except Exception as exc: + log.error("Failed to fetch balance for %s: %s", w, exc) + try: + epoch_info = api.get_epoch() + current_epoch = epoch_info.get("epoch", epoch_info.get("epoch_number", 0)) + log.info("Initial epoch: %s", current_epoch) + except Exception as exc: + log.error("Failed to fetch epoch: %s", exc) + + if once: + return + + log.info("Monitoring started — polling every %ss", poll_interval) + while True: + try: + # Check balances + for w in wallets: + try: + new_bal = api.get_balance(w) + old_bal = balances.get(w) + if old_bal is not None and new_bal != old_bal: + notifier.notify_balance_change(w, old_bal, new_bal) + balances[w] = new_bal + except Exception as exc: + notifier.notify_error(str(exc)) + log.error("Balance check error for %s: %s", w, exc) + + # Check epoch + try: + epoch_info = api.get_epoch() + new_epoch = epoch_info.get("epoch", epoch_info.get("epoch_number", 0)) + if current_epoch is not None and new_epoch != current_epoch: + notifier.notify_epoch_change(current_epoch, new_epoch, epoch_info) + current_epoch = new_epoch + except Exception as exc: + notifier.notify_error(str(exc)) + log.error("Epoch check error: %s", exc) + + except Exception as exc: + log.critical("Unhandled error in monitor loop: %s", exc) + + time.sleep(poll_interval) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="RustChain Notification Bot") + parser.add_argument( + "-c", "--config", + default="config.json", + help="Path to config file (default: config.json)", + ) + parser.add_argument( + "--once", + action="store_true", + help="Run one check cycle and exit (useful for testing)", + ) + args = parser.parse_args() + run_monitor(args.config, once=args.once) + + +if __name__ == "__main__": + main() diff --git a/submissions/notification-bot/config.example.json b/submissions/notification-bot/config.example.json new file mode 100644 index 000000000..8fbddeab9 --- /dev/null +++ b/submissions/notification-bot/config.example.json @@ -0,0 +1,35 @@ +{ + "api": { + "base_url": "https://api.rustchain.example.com/v1", + "timeout": 15 + }, + "poll_interval_seconds": 30, + + "wallets": [ + "0xYOUR_WALLET_ADDRESS_HERE" + ], + + "channels": { + "telegram": { + "enabled": true, + "bot_token": "YOUR_TELEGRAM_BOT_TOKEN", + "chat_id": "YOUR_TELEGRAM_CHAT_ID" + }, + "discord": { + "enabled": true, + "webhook_url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN" + } + }, + + "rules": { + "balance_change": { + "threshold": 0 + }, + "epoch_change": { + "enabled": true + }, + "errors": { + "enabled": true + } + } +} diff --git a/submissions/notification-bot/requirements.txt b/submissions/notification-bot/requirements.txt new file mode 100644 index 000000000..a8608b2c6 --- /dev/null +++ b/submissions/notification-bot/requirements.txt @@ -0,0 +1 @@ +requests>=2.28.0