Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions submissions/notification-bot/README.md
Original file line number Diff line number Diff line change
@@ -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
239 changes: 239 additions & 0 deletions submissions/notification-bot/bot.py
Original file line number Diff line number Diff line change
@@ -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()
35 changes: 35 additions & 0 deletions submissions/notification-bot/config.example.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
1 change: 1 addition & 0 deletions submissions/notification-bot/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
requests>=2.28.0