From f940c8c9920197d2c79ac3a77acdc546d5fc7856 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 10:57:15 -0700 Subject: [PATCH 01/18] Fix .dockerignore to allow Dockerfile and docker-compose --- .dockerignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0ca8e5c..a275ed4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,8 +25,7 @@ docs/ shared/ # Docker -Dockerfile* -docker-compose*.yml +# Dockerfile and docker-compose are needed for building! # Scripts *.sh From f08f2ef2f77e9ee2e628f8f3673401786db68a30 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 11:04:15 -0700 Subject: [PATCH 02/18] Fix Pyth Network URL in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f5deaca..41d0f3a 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Each bot is independent - add more by adding tokens to .env. ``` CRYPTO_FEEDS=BTC:feed_id,ETH:feed_id,... ``` - Get feed IDs from https://pyth.network/docs/developers + Get feed IDs from https://insights.pyth.network/price-feeds?search=btc ## Running From 9475c0ceddfeacf11619225e7ee188d9aadf2332 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 11:05:01 -0700 Subject: [PATCH 03/18] Add curl example for health check --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 41d0f3a..ae63962 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ docker-compose down ### Health Check The health endpoint is available at localhost:8080/health +``` +curl http://localhost:8080/health +``` + ## Configuration ### Update Interval From 621eaf17fc73ba686807bd7bd782f5bc737f3a1c Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 11:06:18 -0700 Subject: [PATCH 04/18] Add Docker and OS to tech stack --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ae63962..fa7f781 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ The bot will automatically spawn new instances for each token. ## Tech Stack +- Docker with Docker Compose +- Debian (Docker base image) - Rust (edition 2021) - Serenity (Discord bot library) - SQLite (database) From a107b6b36dc56d65351635c130664f98f56b617f Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 11:22:04 -0700 Subject: [PATCH 05/18] Skip saving invalid prices (0 or negative) to database --- src/database.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/database.rs b/src/database.rs index 4758979..a21cdbf 100644 --- a/src/database.rs +++ b/src/database.rs @@ -56,6 +56,15 @@ impl PriceDatabase { /// Save a price record to the database pub fn save_price(&self, crypto_name: &str, price: f64) -> BotResult<()> { + // Skip invalid prices (0 or negative) + if price <= 0.0 { + debug!( + "Skipping save for {} - invalid price: {}", + crypto_name, price + ); + return Ok(()); + } + let conn = self.get_connection()?; let current_time = get_current_timestamp()?; From 55862c4432ca56a8655f927b694ceeadbe097a07 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 11:45:37 -0700 Subject: [PATCH 06/18] Replace println! with tracing in price_service.rs --- src/price_service.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/price_service.rs b/src/price_service.rs index b009b15..b447e80 100644 --- a/src/price_service.rs +++ b/src/price_service.rs @@ -74,7 +74,7 @@ async fn get_crypto_price(feed_id: &str) -> Result { if !response.status().is_success() { - println!("❌ HTTP request failed (attempt {}): {}", attempt, response.status()); + error!("HTTP request failed (attempt {}): {}", attempt, response.status()); if attempt < MAX_RETRIES { tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; continue; @@ -101,7 +101,7 @@ async fn get_crypto_price(feed_id: &str) -> Result { - println!("❌ JSON parsing failed (attempt {}): {}", attempt, e); + error!("JSON parsing failed (attempt {}): {}", attempt, e); if attempt < MAX_RETRIES { tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; continue; @@ -111,7 +111,7 @@ async fn get_crypto_price(feed_id: &str) -> Result { - println!("❌ Network request failed (attempt {}): {}", attempt, e); + error!("Network request failed (attempt {}): {}", attempt, e); if attempt < MAX_RETRIES { tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; continue; @@ -176,7 +176,7 @@ async fn fetch_yahoo_price(ticker: &str) -> Result { if !response.status().is_success() { - println!("❌ Yahoo API request failed for {} (attempt {}): {}", ticker, attempt, response.status()); + error!("Yahoo API request failed for {} (attempt {}): {}", ticker, attempt, response.status()); if attempt < MAX_RETRIES { tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; continue; @@ -209,13 +209,13 @@ async fn fetch_yahoo_price(ticker: &str) -> Result { - println!("❌ Yahoo JSON parsing failed: {}", e); + error!("Yahoo JSON parsing failed: {}", e); return Err(e.into()); } } } Err(e) => { - println!("❌ Yahoo Network request failed: {}", e); + error!("Yahoo Network request failed: {}", e); if attempt < MAX_RETRIES { tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; continue; @@ -241,10 +241,10 @@ async fn fetch_all_prices() -> Result { - println!("❌ Failed to fetch {} price: {}", crypto, e); + error!("Failed to fetch {} price: {}", crypto, e); // Use previous price or default let default_price = match crypto.as_str() { "BTC" => 45000.0, @@ -282,10 +282,10 @@ async fn fetch_all_prices() -> Result { prices.insert("DXY".to_string(), data.clone()); - println!("✅ Fetched DXY price: ${:.2}", data.price); + info!("Fetched DXY price: ${:.2}", data.price); } Err(e) => { - println!("❌ Failed to fetch DXY price: {}", e); + error!("Failed to fetch DXY price: {}", e); } } } @@ -309,7 +309,7 @@ async fn fetch_all_prices() -> Result Result<(), Box> { let json_string = serde_json::to_string_pretty(prices)?; fs::write(file_path, json_string)?; - println!("📝 Wrote prices to {}", file_path); + info!("Wrote prices to {}", file_path); Ok(()) } From 7b61fbabcea895008ddbfe2c52f456fa1810f910 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 11:47:15 -0700 Subject: [PATCH 07/18] Remove unused db_query.rs file --- src/db_query.rs | 232 ------------------------------------------------ 1 file changed, 232 deletions(-) delete mode 100644 src/db_query.rs diff --git a/src/db_query.rs b/src/db_query.rs deleted file mode 100644 index 54b4dc7..0000000 --- a/src/db_query.rs +++ /dev/null @@ -1,232 +0,0 @@ -use crate::config::DATABASE_PATH; -use rusqlite::{Connection, Result as SqliteResult}; -use std::env; - -#[derive(Debug)] -struct PriceRecord { - crypto_name: String, - price: f64, - timestamp: i64, - created_at: String, -} - -fn main() -> SqliteResult<()> { - let args: Vec = env::args().collect(); - - if args.len() < 2 { - println!("Usage: {} [crypto_name] [limit]", args[0]); - println!("Commands:"); - println!(" stats - Show database statistics"); - println!(" latest [crypto] - Show latest prices for all or specific crypto"); - println!(" history [crypto] [limit] - Show price history (default: 10 records)"); - println!(" cleanup - Manually trigger cleanup of old records"); - return Ok(()); - } - - let conn = Connection::open(DATABASE_PATH)?; - let command = &args[1]; - - match command.as_str() { - "stats" => show_stats(&conn)?, - "latest" => { - let crypto = args.get(2).cloned(); - show_latest(&conn, crypto)?; - } - "history" => { - let crypto = args.get(2).cloned(); - let limit = args - .get(3) - .and_then(|s| s.parse::().ok()) - .unwrap_or(10); - show_history(&conn, crypto, limit)?; - } - "cleanup" => cleanup_old_prices(&conn)?, - _ => { - println!("Unknown command: {}", command); - println!("Use 'stats', 'latest', 'history', or 'cleanup'"); - } - } - - Ok(()) -} - -fn show_stats(conn: &Connection) -> SqliteResult<()> { - println!("📊 Database Statistics"); - println!("====================="); - - // Total records - let total: i64 = conn.query_row("SELECT COUNT(*) FROM prices", [], |row| row.get(0))?; - println!("Total records: {}", total); - - // Records per crypto - let mut stmt = conn.prepare("SELECT crypto_name, COUNT(*) FROM prices GROUP BY crypto_name")?; - let rows = stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) - })?; - - println!("\nRecords per crypto:"); - for row in rows { - let (crypto, count) = row?; - println!(" {}: {} records", crypto, count); - } - - // Oldest and newest timestamps - let oldest: i64 = conn.query_row("SELECT MIN(timestamp) FROM prices", [], |row| row.get(0))?; - let newest: i64 = conn.query_row("SELECT MAX(timestamp) FROM prices", [], |row| row.get(0))?; - - if oldest > 0 && newest > 0 { - let oldest_date = chrono::DateTime::from_timestamp(oldest, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - let newest_date = chrono::DateTime::from_timestamp(newest, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - - println!("\nDate range:"); - println!(" Oldest: {} ({})", oldest_date, oldest); - println!(" Newest: {} ({})", newest_date, newest); - } - - Ok(()) -} - -fn show_latest(conn: &Connection, crypto: Option) -> SqliteResult<()> { - println!("📈 Latest Prices"); - println!("================"); - - if let Some(crypto_name) = crypto { - // Latest price for specific crypto - let mut stmt = conn.prepare( - "SELECT crypto_name, price, timestamp, created_at FROM prices - WHERE crypto_name = ? ORDER BY timestamp DESC LIMIT 1", - )?; - - let rows = stmt.query_map([&crypto_name], |row| { - Ok(PriceRecord { - crypto_name: row.get(0)?, - price: row.get(1)?, - timestamp: row.get(2)?, - created_at: row.get(3)?, - }) - })?; - - for row in rows { - let record = row?; - let date = chrono::DateTime::from_timestamp(record.timestamp, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - println!("{}: ${:.6} at {}", record.crypto_name, record.price, date); - } - } else { - // Latest price for each crypto - let mut stmt = conn.prepare( - "SELECT p1.crypto_name, p1.price, p1.timestamp, p1.created_at - FROM prices p1 - INNER JOIN ( - SELECT crypto_name, MAX(timestamp) as max_timestamp - FROM prices GROUP BY crypto_name - ) p2 ON p1.crypto_name = p2.crypto_name AND p1.timestamp = p2.max_timestamp - ORDER BY p1.crypto_name", - )?; - - let rows = stmt.query_map([], |row| { - Ok(PriceRecord { - crypto_name: row.get(0)?, - price: row.get(1)?, - timestamp: row.get(2)?, - created_at: row.get(3)?, - }) - })?; - - for row in rows { - let record = row?; - let date = chrono::DateTime::from_timestamp(record.timestamp, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - println!("{}: ${:.6} at {}", record.crypto_name, record.price, date); - } - } - - Ok(()) -} - -fn show_history(conn: &Connection, crypto: Option, limit: i64) -> SqliteResult<()> { - if let Some(crypto_name) = crypto { - println!("📊 Price History for {}", crypto_name); - println!("{}", "=".repeat(30 + crypto_name.len())); - - let mut stmt = conn.prepare( - "SELECT crypto_name, price, timestamp, created_at FROM prices - WHERE crypto_name = ? ORDER BY timestamp DESC LIMIT ?", - )?; - - let rows = stmt.query_map([&crypto_name, &limit.to_string()], |row| { - Ok(PriceRecord { - crypto_name: row.get(0)?, - price: row.get(1)?, - timestamp: row.get(2)?, - created_at: row.get(3)?, - }) - })?; - - for row in rows { - let record = row?; - let date = chrono::DateTime::from_timestamp(record.timestamp, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - println!("${:.6} at {}", record.price, date); - } - } else { - println!("📊 Recent Price History (all cryptos)"); - println!("===================================="); - - let mut stmt = conn.prepare( - "SELECT crypto_name, price, timestamp, created_at FROM prices - ORDER BY timestamp DESC LIMIT ?", - )?; - - let rows = stmt.query_map([&limit.to_string()], |row| { - Ok(PriceRecord { - crypto_name: row.get(0)?, - price: row.get(1)?, - timestamp: row.get(2)?, - created_at: row.get(3)?, - }) - })?; - - for row in rows { - let record = row?; - let date = chrono::DateTime::from_timestamp(record.timestamp, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - println!("{}: ${:.6} at {}", record.crypto_name, record.price, date); - } - } - - Ok(()) -} - -fn cleanup_old_prices(conn: &Connection) -> SqliteResult<()> { - // Delete prices older than 30 days (30 * 24 * 60 * 60 = 2592000 seconds) - let thirty_days_ago = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| { - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_MISUSE), - Some(format!("System time error: {}", e)), - ) - })? - .as_secs() - - 2592000; - - let deleted = conn.execute( - "DELETE FROM prices WHERE timestamp < ?", - [&thirty_days_ago.to_string()], - )?; - - println!( - "🧹 Cleaned up {} old price records (older than 30 days)", - deleted - ); - Ok(()) -} From 60e068b1bef5fa8be0b1891e32afad4ad6e58c96 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 11:52:32 -0700 Subject: [PATCH 08/18] Fix default UPDATE_INTERVAL_SECONDS to match docker-compose (12s) --- src/config.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 4f65df4..9e715f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,7 +22,7 @@ impl BotConfig { let crypto_name = std::env::var("CRYPTO_NAME").unwrap_or_else(|_| "SOL".to_string()); let update_interval_secs = std::env::var("UPDATE_INTERVAL_SECONDS") - .unwrap_or_else(|_| "300".to_string()) + .unwrap_or_else(|_| UPDATE_INTERVAL_SECONDS.to_string()) .parse::() .map_err(|_| BotError::Parse("Invalid UPDATE_INTERVAL_SECONDS".into()))?; @@ -70,6 +70,9 @@ impl BotConfig { /// Constants for the application pub const DATABASE_PATH: &str = "shared/prices.db"; +/// Default update interval in seconds +pub const UPDATE_INTERVAL_SECONDS: u64 = 12; + /// Price history retention in days pub const PRICE_HISTORY_DAYS: u64 = 365; // Keep 1 year of history From 78993680232a66a67fbcac74e8dce76e88af0ea3 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 11:58:17 -0700 Subject: [PATCH 09/18] Clean up debug comments --- src/price_service.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/price_service.rs b/src/price_service.rs index b447e80..b658db2 100644 --- a/src/price_service.rs +++ b/src/price_service.rs @@ -132,8 +132,6 @@ pub async fn fetch_shanghai_history(range: &str, symbol: Option<&str>) -> Result if symbol_param.is_empty() { "".to_string() } else { format!("&symbol={}", symbol_param) } ); - // println!("Fetching history from: {}", url); // Debug - let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()?; @@ -450,18 +448,15 @@ pub async fn run(database: Arc) -> Result<(), Box { consecutive_failures += 1; From 4ecca29dad72fcb12432db92cbc77736db093f8b Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 12:13:01 -0700 Subject: [PATCH 10/18] Add unit tests for utils and database --- Cargo.toml | 3 ++ src/database_tests.rs | 46 +++++++++++++++++++++++++++++ src/main.rs | 2 ++ src/utils.rs | 67 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 src/database_tests.rs diff --git a/Cargo.toml b/Cargo.toml index ec10aed..699a5de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,3 +52,6 @@ image = "0.24" # HTML/Regex parsing regex = "1.0" + +[dev-dependencies] +tempfile = "3.0" diff --git a/src/database_tests.rs b/src/database_tests.rs new file mode 100644 index 0000000..063035e --- /dev/null +++ b/src/database_tests.rs @@ -0,0 +1,46 @@ +#[cfg(test)] +mod tests { + use super::PriceDatabase; + use tempfile::TempDir; + + fn setup_temp_db() -> (TempDir, PriceDatabase) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test.db"); + let db = PriceDatabase::new(db_path.to_str().unwrap()).expect("Failed to create database"); + (temp_dir, db) + } + + #[test] + fn test_database_initializes() { + let (_temp_dir, db) = setup_temp_db(); + // Just verify database can be created and queried + assert!(db.get_all_latest_prices().is_ok()); + } + + #[test] + fn test_save_and_retrieve_price() { + let (_temp_dir, db) = setup_temp_db(); + + // Save a price + let result = db.save_price("BTC", 50000.0); + assert!(result.is_ok()); + + // Retrieve it + let price = db.get_latest_price("BTC"); + assert!(price.is_ok()); + assert_eq!(price.unwrap(), 50000.0); + } + + #[test] + fn test_save_invalid_price_rejected() { + let (_temp_dir, db) = setup_temp_db(); + + // Zero price should be skipped (not saved) + let result = db.save_price("BTC", 0.0); + assert!(result.is_ok()); // Returns ok but doesn't save zero + + // Negative price should be skipped + let result = db.save_price("BTC", -100.0); + assert!(result.is_ok()); // Returns ok but doesn't save negative + } +} diff --git a/src/main.rs b/src/main.rs index c47b40c..455f534 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,8 @@ mod price_service; mod db_cleanup; mod charting; mod shanghai_price_service; +#[cfg(test)] +mod database_tests; use errors::BotResult; use config::BotConfig; diff --git a/src/utils.rs b/src/utils.rs index 3779e1e..529a4d7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -111,3 +111,70 @@ pub fn get_change_arrow(change_percent: f64) -> &'static str { "➡️" } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_crypto_name_valid() { + assert!(validate_crypto_name("BTC").is_ok()); + assert!(validate_crypto_name("SOL").is_ok()); + assert!(validate_crypto_name("ETH").is_ok()); + assert!(validate_crypto_name("1234567890").is_ok()); // 10 chars + } + + #[test] + fn test_validate_crypto_name_invalid() { + assert!(validate_crypto_name("").is_err()); + assert!(validate_crypto_name("12345678901").is_err()); // 11 chars + assert!(validate_crypto_name("BTC!").is_err()); + assert!(validate_crypto_name("BTC-USDT").is_err()); + } + + #[test] + fn test_validate_price_valid() { + assert!(validate_price(100.0).is_ok()); + assert!(validate_price(0.01).is_ok()); + assert!(validate_price(1000000.0).is_ok()); + } + + #[test] + fn test_validate_price_invalid() { + assert!(validate_price(0.0).is_err()); + assert!(validate_price(-10.0).is_err()); + assert!(validate_price(f64::NAN).is_err()); + assert!(validate_price(f64::INFINITY).is_err()); + } + + #[test] + fn test_format_price() { + assert_eq!(format_price(50000.0), "$50000"); + assert_eq!(format_price(500.0), "$500.00"); + assert_eq!(format_price(5.0), "$5.000"); + assert_eq!(format_price(0.5), "$0.5000"); + } + + #[test] + fn test_calculate_percentage_change() { + assert!((calculate_percentage_change(110.0, 100.0).unwrap() - 10.0).abs() < 0.01); + assert!((calculate_percentage_change(90.0, 100.0).unwrap() - (-10.0)).abs() < 0.01); + assert!(calculate_percentage_change(100.0, 0.0).is_err()); // divide by zero + } + + #[test] + fn test_get_change_arrow() { + assert_eq!(get_change_arrow(5.0), "📈"); + assert_eq!(get_change_arrow(-5.0), "📉"); + assert_eq!(get_change_arrow(0.0), "➡️"); + } + + #[test] + fn test_get_crypto_emoji() { + assert_eq!(get_crypto_emoji("BTC"), "🪙"); + assert_eq!(get_crypto_emoji("ETH"), "🪙"); + assert_eq!(get_crypto_emoji("DOGE"), "🐕"); + assert_eq!(get_crypto_emoji("DXY"), "🇺🇸"); + assert_eq!(get_crypto_emoji("UNKNOWN"), "🪙"); + } +} From 3dd5f720372559a4e63a3c2ae54872648bf80c79 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 12:14:16 -0700 Subject: [PATCH 11/18] Add GitHub Actions CI for running tests --- .github/workflows/test.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..83df11f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Tests + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev libfreetype6-dev libfontconfig1-dev + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --release + + - name: Run tests + run: cargo test From 42dd612da11249e9eedba8df314710c0392877ca Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 10 Mar 2026 12:18:25 -0700 Subject: [PATCH 12/18] Add CONTRIBUTING.md, LICENSE, and rustfmt.toml for professional open source --- CONTRIBUTING.md | 33 +++++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++++++++++++ rustfmt.toml | 3 +++ 3 files changed, 57 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 rustfmt.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..def45d2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing + +## Development + +### Prerequisites +- Rust (latest stable) +- Docker and Docker Compose + +### Running Tests +```bash +cargo test +``` + +### Building +```bash +cargo build --release +``` + +### Running Locally +```bash +docker-compose up -d --build +``` + +## Code Style +Run formatting before submitting: +```bash +cargo fmt +``` + +## Pull Requests +1. Create a feature branch from `dev` +2. Run tests and ensure they pass +3. Submit a PR to `dev` branch diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c13f991 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..7dc359a --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +max_width = 100 +tab_spaces = 4 +edition = "2021" From 3b477473ce11bd6ebfa4ac10ab1ebe16ccb95549 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Mon, 16 Mar 2026 21:41:40 -0700 Subject: [PATCH 13/18] Fix scan findings: add price_aggregates table, env var for shanghai interval, zero-div checks, remove duplicate comment --- src/bot.rs | 563 +++++++++++++++++++++------------- src/database.rs | 17 +- src/shanghai_price_service.rs | 46 ++- 3 files changed, 405 insertions(+), 221 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 4118b8d..21025dd 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -2,23 +2,21 @@ use crate::config::BotConfig; use crate::database::PriceDatabase; use crate::discord_api::DiscordApi; use crate::errors::{BotError, BotResult}; -use crate::health::{HealthState, HealthAggregator}; +use crate::health::{HealthAggregator, HealthState}; -use crate::price_service::PricesFile; -use crate::utils::{ - format_price, get_current_timestamp, validate_crypto_name, validate_price, -}; -use crate::charting::{generate_shanghai_chart, generate_price_chart}; +use crate::charting::{generate_price_chart, generate_shanghai_chart}; use crate::price_service::fetch_shanghai_history; +use crate::price_service::PricesFile; +use crate::utils::{format_price, get_current_timestamp, validate_crypto_name, validate_price}; use serenity::{ all::{ - ActivityData, Command, CommandDataOptionValue, CommandOptionType, CreateCommand, - CreateCommandOption, GatewayIntents, CreateAttachment, + ActivityData, Command, CommandDataOptionValue, CommandOptionType, CreateAttachment, + CreateCommand, CreateCommandOption, GatewayIntents, }, async_trait, builder::{CreateInteractionResponse, CreateInteractionResponseMessage}, http::Http, - model::{application::CommandInteraction, gateway::Ready, channel::Message}, + model::{application::CommandInteraction, channel::Message, gateway::Ready}, prelude::*, Client, }; @@ -56,7 +54,12 @@ pub struct Bot { impl Bot { /// Create a new bot instance with configuration, shared database, and health state - pub fn new(config: BotConfig, database: Arc, health: Arc, health_aggregator: Arc) -> BotResult { + pub fn new( + config: BotConfig, + database: Arc, + health: Arc, + health_aggregator: Arc, + ) -> BotResult { Ok(Self { config, health, @@ -68,7 +71,7 @@ impl Bot { /// Register slash commands with Discord async fn register_commands(&self, http: &Http) -> BotResult<()> { info!("Registering slash commands..."); - + let current_crypto = &self.config.crypto_name; let price_command = CreateCommand::new("price") .description(format!( @@ -87,8 +90,8 @@ impl Bot { let chart_command = CreateCommand::new("silverchart") .description("Get a 1-year historical chart for the current crypto"); - let status_command = CreateCommand::new("status") - .description("Get bot system status (BTC bot only)"); + let status_command = + CreateCommand::new("status").description("Get bot system status (BTC bot only)"); info!("Creating global command..."); @@ -98,7 +101,9 @@ impl Bot { Command::create_global_command(http, chart_command) .await - .map_err(|e| BotError::Discord(format!("Failed to register /silverchart command: {}", e)))?; + .map_err(|e| { + BotError::Discord(format!("Failed to register /silverchart command: {}", e)) + })?; Command::create_global_command(http, status_command) .await @@ -147,17 +152,25 @@ impl Bot { Ok(response) } - async fn handle_chart_command(&self, interaction: &CommandInteraction, ctx: &Context) -> BotResult<()> { + async fn handle_chart_command( + &self, + interaction: &CommandInteraction, + ctx: &Context, + ) -> BotResult<()> { // Defer response as charting might take a moment - interaction.defer(&ctx.http).await + interaction + .defer(&ctx.http) + .await .map_err(|e| BotError::Discord(format!("Failed to defer interaction: {}", e)))?; // silverchart command always uses SILVER let crypto_name = "SILVER"; - + // SILVER uses database history, GOLD uses Shanghai API if crypto_name == "SILVER" || crypto_name == "XAG" { - let history = self.database.get_price_history(crypto_name, 30) + let history = self + .database + .get_price_history(crypto_name, 30) .map_err(|e| BotError::Discord(format!("Failed to fetch history: {}", e)))?; if history.is_empty() { @@ -166,13 +179,18 @@ impl Bot { return Ok(()); } - let image_data = generate_price_chart(&history, crypto_name).map_err(|e| BotError::Discord(format!("Failed to generate chart: {}", e)))?; + let image_data = generate_price_chart(&history, crypto_name) + .map_err(|e| BotError::Discord(format!("Failed to generate chart: {}", e)))?; let attachment = CreateAttachment::bytes(image_data, "chart.png"); - interaction.edit_response(&ctx.http, serenity::builder::EditInteractionResponse::new() - .content(format!("📊 30-Day Chart for {}", crypto_name)) - .new_attachment(attachment) - ).await - .map_err(|e| BotError::Discord(format!("Failed to send chart response: {}", e)))?; + interaction + .edit_response( + &ctx.http, + serenity::builder::EditInteractionResponse::new() + .content(format!("📊 30-Day Chart for {}", crypto_name)) + .new_attachment(attachment), + ) + .await + .map_err(|e| BotError::Discord(format!("Failed to send chart response: {}", e)))?; return Ok(()); } @@ -182,25 +200,37 @@ impl Bot { _ => None, }; - let history = fetch_shanghai_history("1Y", api_symbol).await + let history = fetch_shanghai_history("1Y", api_symbol) + .await .map_err(|e| BotError::Discord(format!("Failed to fetch history: {}", e)))?; if history.is_empty() { - interaction.edit_response(&ctx.http, serenity::builder::EditInteractionResponse::new().content("❌ No historical data available")).await + interaction + .edit_response( + &ctx.http, + serenity::builder::EditInteractionResponse::new() + .content("❌ No historical data available"), + ) + .await .map_err(|e| BotError::Discord(format!("Failed to send empty response: {}", e)))?; - return Ok(()); + return Ok(()); } // Generate Chart - let image_data = generate_shanghai_chart(&history, crypto_name).map_err(|e| BotError::Discord(format!("Failed to generate chart: {}", e)))?; + let image_data = generate_shanghai_chart(&history, crypto_name) + .map_err(|e| BotError::Discord(format!("Failed to generate chart: {}", e)))?; // Send Response let attachment = CreateAttachment::bytes(image_data, "chart.png"); - interaction.edit_response(&ctx.http, serenity::builder::EditInteractionResponse::new() - .content(format!("📊 1-Year Chart for {}", crypto_name)) - .new_attachment(attachment) - ).await - .map_err(|e| BotError::Discord(format!("Failed to send chart response: {}", e)))?; + interaction + .edit_response( + &ctx.http, + serenity::builder::EditInteractionResponse::new() + .content(format!("📊 1-Year Chart for {}", crypto_name)) + .new_attachment(attachment), + ) + .await + .map_err(|e| BotError::Discord(format!("Failed to send chart response: {}", e)))?; Ok(()) } @@ -221,21 +251,29 @@ impl Bot { match generate_price_chart(&history, crypto_name) { Ok(image_data) => { let attachment = CreateAttachment::bytes(image_data, "chart.png"); - let _ = channel_id.send_message(&ctx.http, serenity::builder::CreateMessage::new() - .content(title) - .add_file(attachment) - ).await; + let _ = channel_id + .send_message( + &ctx.http, + serenity::builder::CreateMessage::new() + .content(title) + .add_file(attachment), + ) + .await; self.health.update_discord_timestamp(); - }, + } Err(e) => { error!("Failed to generate chart: {}", e); - let _ = channel_id.say(&ctx.http, format!("❌ Failed to generate chart: {}", e)).await; + let _ = channel_id + .say(&ctx.http, format!("❌ Failed to generate chart: {}", e)) + .await; } } - }, + } Err(e) => { error!("Failed to fetch history: {}", e); - let _ = channel_id.say(&ctx.http, format!("❌ Failed to fetch history: {}", e)).await; + let _ = channel_id + .say(&ctx.http, format!("❌ Failed to fetch history: {}", e)) + .await; } } Ok(()) @@ -263,38 +301,42 @@ impl Bot { }); // Build the main response - let mut response = format!( - "{}: {} {}", - crypto_name, formatted_price, change_info - ); + let mut response = format!("{}: {} {}", crypto_name, formatted_price, change_info); // Add prices in terms of BTC, ETH, and SOL (excluding the crypto's own price) let mut conversion_prices = Vec::new(); if crypto_name != "BTC" { if let Some(btc_price) = all_prices.get("BTC") { - let btc_conversion = current_price / btc_price; - conversion_prices.push(format!("{:.8} BTC", btc_conversion)); + if *btc_price > 0.0 { + let btc_conversion = current_price / btc_price; + conversion_prices.push(format!("{:.8} BTC", btc_conversion)); + } } } if crypto_name != "ETH" { if let Some(eth_price) = all_prices.get("ETH") { - let eth_conversion = current_price / eth_price; - conversion_prices.push(format!("{:.6} ETH", eth_conversion)); + if *eth_price > 0.0 { + let eth_conversion = current_price / eth_price; + conversion_prices.push(format!("{:.6} ETH", eth_conversion)); + } } } if crypto_name != "SOL" { if let Some(sol_price) = all_prices.get("SOL") { - let sol_conversion = current_price / sol_price; - conversion_prices.push(format!("{:.4} SOL", sol_conversion)); + if *sol_price > 0.0 { + let sol_conversion = current_price / sol_price; + conversion_prices.push(format!("{:.4} SOL", sol_conversion)); + } } } // Add Gold/Silver ratio if this is Silver if crypto_name == "SILVER" || crypto_name == "XAG" { - let gold_price = all_prices.get("GOLD") + let gold_price = all_prices + .get("GOLD") .or_else(|| all_prices.get("XAU")) .or_else(|| all_prices.get("PAXG")); @@ -318,26 +360,27 @@ impl Bot { } else { (0.0, 0.0) // For SHANGHAI, we'd need premium data from elsewhere }; - + response.push_str(&format!( - "\n🇨🇳 Shanghai Premium: ${:.2} (+{:.2}%)", - premium, premium_percent + "\n🇨🇳 Shanghai Premium: ${:.2} (+{:.2}%)", + premium, premium_percent )); } // Add conversion prices to response if available if !conversion_prices.is_empty() { - response.push_str(&format!( - "\n💱 Also: {}", - conversion_prices.join(" | ") - )); + response.push_str(&format!("\n💱 Also: {}", conversion_prices.join(" | "))); } Ok(response) } /// Handle price command for message-based commands (like !btc, !sol) - async fn handle_price_command_for_message(&self, channel_id: &serenity::model::id::ChannelId, ctx: &Context) -> BotResult<()> { + async fn handle_price_command_for_message( + &self, + channel_id: &serenity::model::id::ChannelId, + ctx: &Context, + ) -> BotResult<()> { let crypto_name = self.config.crypto_name.clone(); debug!("Message price command called for: {}", crypto_name); @@ -353,7 +396,9 @@ impl Bot { let response = self.build_price_response(&crypto_name, current_price, &all_prices)?; // Send the response to the channel - channel_id.say(&ctx.http, response).await + channel_id + .say(&ctx.http, response) + .await .map_err(|e| BotError::Discord(format!("Failed to send message: {}", e)))?; Ok(()) @@ -361,9 +406,15 @@ impl Bot { } /// Helper to start a bot instance (used by main.rs) -pub async fn start_bot(config: BotConfig, database: Arc, health: Arc, health_aggregator: Arc) -> BotResult<()> { +pub async fn start_bot( + config: BotConfig, + database: Arc, + health: Arc, + health_aggregator: Arc, +) -> BotResult<()> { let token = config.discord_token.clone(); - let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT; + let intents = + GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT; let bot = Bot::new(config, database, health, health_aggregator)?; @@ -385,7 +436,7 @@ impl EventHandler for Bot { info!("Bot is ready! Logged in as: {}", ready.user.name); info!("Bot ID: {}", ready.user.id); info!("Connected to {} guilds", ready.guilds.len()); - + // Update Discord timestamp to indicate successful connection self.health.update_discord_timestamp(); @@ -415,15 +466,14 @@ impl EventHandler for Bot { let http = ctx.http.clone(); let ctx_arc = Arc::new(ctx); - // Run the price update loop in a separate task so ready() returns // Run the price update loop in a separate task so ready() returns // Cloning Bot is expensive if it has deep state, but here it's Config + Health (Arc-like internals) + Arc // Use a wrapper or simply spawn the loop with cloned components - + let config = self.config.clone(); let health = self.health.clone(); let database = self.database.clone(); - + tokio::spawn(async move { price_update_loop(http, ctx_arc, config, health, database).await; }); @@ -474,10 +524,16 @@ impl EventHandler for Bot { "silverchart" => { debug!("Handling /silverchart command"); if let Err(e) = self.handle_chart_command(&command_interaction, &ctx).await { - error!("Chart command failed: {}", e); - let _ = command_interaction.create_response(&ctx.http, - CreateInteractionResponse::Message(CreateInteractionResponseMessage::new().content(format!("❌ Error: {}", e))) - ).await; + error!("Chart command failed: {}", e); + let _ = command_interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("❌ Error: {}", e)), + ), + ) + .await; } Ok(()) // Response handled inside function } @@ -486,44 +542,71 @@ impl EventHandler for Bot { // Only BTC bot responds to status if self.config.crypto_name == "BTC" { let status = self.health_aggregator.to_json(); - let total_bots = status.get("total_bots").and_then(|v| v.as_u64()).unwrap_or(0); - let healthy_bots = status.get("healthy_bots").and_then(|v| v.as_u64()).unwrap_or(0); - + let total_bots = status + .get("total_bots") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let healthy_bots = status + .get("healthy_bots") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let mut lines = vec![ "```".to_string(), - format!("{:14} {:>6} {:>8} {:>6} {:>6}", "Bot", "Status", "Uptime", "Fails", "GW"), - "----------------------------------------------------------------".to_string(), + format!( + "{:14} {:>6} {:>8} {:>6} {:>6}", + "Bot", "Status", "Uptime", "Fails", "GW" + ), + "----------------------------------------------------------------" + .to_string(), ]; - + if let Some(bots) = status.get("bots").and_then(|v| v.as_array()) { for bot in bots { - let name = bot.get("bot_name").and_then(|v| v.as_str()).unwrap_or("?"); - let healthy = bot.get("healthy").and_then(|v| v.as_bool()).unwrap_or(false); - let failures = bot.get("consecutive_failures").and_then(|v| v.as_u64()).unwrap_or(0); - let gateway_failures = bot.get("gateway_failures").and_then(|v| v.as_u64()).unwrap_or(0); - let uptime_secs = bot.get("uptime_seconds").and_then(|v| v.as_u64()).unwrap_or(0); + let name = + bot.get("bot_name").and_then(|v| v.as_str()).unwrap_or("?"); + let healthy = bot + .get("healthy") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let failures = bot + .get("consecutive_failures") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let gateway_failures = bot + .get("gateway_failures") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let uptime_secs = bot + .get("uptime_seconds") + .and_then(|v| v.as_u64()) + .unwrap_or(0); let uptime = format_uptime(uptime_secs); let status_str = if healthy { "OK" } else { "DOWN" }; - + lines.push(format!( - "{:14} {:>6} {:>8} {:>6} {:>6}", - name, - status_str, - uptime, - failures, - gateway_failures + "{:14} {:>6} {:>8} {:>6} {:>6}", + name, status_str, uptime, failures, gateway_failures )); } } - - lines.push("----------------------------------------------------------------".to_string()); + + lines.push( + "----------------------------------------------------------------" + .to_string(), + ); lines.push(format!("Total: {} | Healthy: {}", total_bots, healthy_bots)); lines.push("```".to_string()); - + let message = lines.join("\n"); - let _ = command_interaction.create_response(&ctx.http, - CreateInteractionResponse::Message(CreateInteractionResponseMessage::new().content(message)) - ).await; + let _ = command_interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content(message), + ), + ) + .await; } Ok(()) } @@ -551,9 +634,15 @@ impl EventHandler for Bot { } async fn message(&self, ctx: Context, msg: Message) { - info!("🔔 MESSAGE RECEIVED: '{}' from {} in channel {}", msg.content, msg.author.name, msg.channel_id); - debug!("Received message: '{}' from {}", msg.content, msg.author.name); - + info!( + "🔔 MESSAGE RECEIVED: '{}' from {} in channel {}", + msg.content, msg.author.name, msg.channel_id + ); + debug!( + "Received message: '{}' from {}", + msg.content, msg.author.name + ); + // Ignore messages from bots if msg.author.bot { debug!("Ignoring message from bot: {}", msg.author.name); @@ -563,34 +652,43 @@ impl EventHandler for Bot { // Check if message starts with ! followed by this bot's crypto name OR if bot is mentioned let command = format!("!{}", self.config.crypto_name.to_lowercase()); let content_lower = msg.content.to_lowercase(); - + // Handle !shanghai as alias for SHANGHAISILVER - let is_shanghai_alias = self.config.crypto_name == "SHANGHAISILVER" && content_lower == "!shanghai"; - - let is_command = content_lower == command || content_lower.starts_with(&format!("{} ", command)) || is_shanghai_alias; - + let is_shanghai_alias = + self.config.crypto_name == "SHANGHAISILVER" && content_lower == "!shanghai"; + + let is_command = content_lower == command + || content_lower.starts_with(&format!("{} ", command)) + || is_shanghai_alias; + // SILVER and SHANGHAISILVER bots respond to their respective chart commands let is_chart = self.config.crypto_name == "SILVER" && content_lower == "!silverchart"; - - let is_shanghai_chart = self.config.crypto_name == "SHANGHAISILVER" && content_lower == "!shanghaichart"; - + + let is_shanghai_chart = + self.config.crypto_name == "SHANGHAISILVER" && content_lower == "!shanghaichart"; + // !status command - only BTC bot responds let is_status = self.config.crypto_name == "BTC" && content_lower == "!status"; - + // Generic chart command: !chart (e.g., !solchart) let generic_chart_cmd = format!("!{}chart", self.config.crypto_name.to_lowercase()); let is_generic_chart = msg.content.to_lowercase() == generic_chart_cmd; - + let is_mentioned = msg.mentions_me(&ctx).await.unwrap_or(false); - - debug!("Looking for command: '{}' in message: '{}', is_command: {}, is_mentioned: {}", - command, msg.content, is_command, is_mentioned); - + + debug!( + "Looking for command: '{}' in message: '{}', is_command: {}, is_mentioned: {}", + command, msg.content, is_command, is_mentioned + ); + if is_command || is_mentioned { debug!("Received {} command from {}", command, msg.author.name); // Get the same price data as the slash command - match self.handle_price_command_for_message(&msg.channel_id, &ctx).await { + match self + .handle_price_command_for_message(&msg.channel_id, &ctx) + .await + { Ok(_) => { debug!("Successfully responded to {} command", command); self.health.update_discord_timestamp(); @@ -598,72 +696,108 @@ impl EventHandler for Bot { Err(e) => { error!("Failed to handle {} command: {}", command, e); // Try to send an error message - if let Err(send_err) = msg.channel_id.say(&ctx.http, format!("❌ Error: {}", e)).await { + if let Err(send_err) = msg + .channel_id + .say(&ctx.http, format!("❌ Error: {}", e)) + .await + { error!("Failed to send error message: {}", send_err); } } } } else if is_chart { - debug!("Received chart command from {}", msg.author.name); - - // silverchart always uses SILVER (database history) - let _ = self.send_chart_to_channel(&ctx, &msg.channel_id, "SILVER", "📊 30-Day Chart for SILVER").await; - + debug!("Received chart command from {}", msg.author.name); + + // silverchart always uses SILVER (database history) + let _ = self + .send_chart_to_channel( + &ctx, + &msg.channel_id, + "SILVER", + "📊 30-Day Chart for SILVER", + ) + .await; } else if is_shanghai_chart { - debug!("Received !shanghaichart command from {}", msg.author.name); - - // SHANGHAISILVER uses database history - let _ = self.send_chart_to_channel(&ctx, &msg.channel_id, "SHANGHAISILVER", "📊 30-Day Chart for Shanghai Silver").await; - + debug!("Received !shanghaichart command from {}", msg.author.name); + + // SHANGHAISILVER uses database history + let _ = self + .send_chart_to_channel( + &ctx, + &msg.channel_id, + "SHANGHAISILVER", + "📊 30-Day Chart for Shanghai Silver", + ) + .await; } else if is_generic_chart { - debug!("Received generic chart command from {}", msg.author.name); - let crypto_name = &self.config.crypto_name; - let title = format!("📊 30-Day History for {}", crypto_name); - let _ = self.send_chart_to_channel(&ctx, &msg.channel_id, crypto_name, &title).await; - + debug!("Received generic chart command from {}", msg.author.name); + let crypto_name = &self.config.crypto_name; + let title = format!("📊 30-Day History for {}", crypto_name); + let _ = self + .send_chart_to_channel(&ctx, &msg.channel_id, crypto_name, &title) + .await; } else if is_status { - debug!("Received !status command from {}", msg.author.name); - let status = self.health_aggregator.to_json(); - let total_bots = status.get("total_bots").and_then(|v| v.as_u64()).unwrap_or(0); - let healthy_bots = status.get("healthy_bots").and_then(|v| v.as_u64()).unwrap_or(0); - - let mut lines = vec![ - "```".to_string(), - format!("{:14} {:>6} {:>8} {:>6} {:>6}", "Bot", "Status", "Uptime", "Fails", "GW"), - "----------------------------------------------------------------".to_string(), - ]; - - if let Some(bots) = status.get("bots").and_then(|v| v.as_array()) { - for bot in bots { - let name = bot.get("bot_name").and_then(|v| v.as_str()).unwrap_or("?"); - let healthy = bot.get("healthy").and_then(|v| v.as_bool()).unwrap_or(false); - let failures = bot.get("consecutive_failures").and_then(|v| v.as_u64()).unwrap_or(0); - let gateway_failures = bot.get("gateway_failures").and_then(|v| v.as_u64()).unwrap_or(0); - let uptime_secs = bot.get("uptime_seconds").and_then(|v| v.as_u64()).unwrap_or(0); - let uptime = format_uptime(uptime_secs); - let status_str = if healthy { "OK" } else { "DOWN" }; - - lines.push(format!( - "{:14} {:>6} {:>8} {:>6} {:>6}", - name, - status_str, - uptime, - failures, - gateway_failures - )); - } - } - - lines.push("--------------------------------------------------------".to_string()); - lines.push(format!("Total: {} | Healthy: {}", total_bots, healthy_bots)); - lines.push("```".to_string()); - - let message = lines.join("\n"); - let _ = msg.channel_id.say(&ctx.http, message).await; - self.health.update_discord_timestamp(); + debug!("Received !status command from {}", msg.author.name); + let status = self.health_aggregator.to_json(); + let total_bots = status + .get("total_bots") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let healthy_bots = status + .get("healthy_bots") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let mut lines = vec![ + "```".to_string(), + format!( + "{:14} {:>6} {:>8} {:>6} {:>6}", + "Bot", "Status", "Uptime", "Fails", "GW" + ), + "----------------------------------------------------------------".to_string(), + ]; + + if let Some(bots) = status.get("bots").and_then(|v| v.as_array()) { + for bot in bots { + let name = bot.get("bot_name").and_then(|v| v.as_str()).unwrap_or("?"); + let healthy = bot + .get("healthy") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let failures = bot + .get("consecutive_failures") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let gateway_failures = bot + .get("gateway_failures") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let uptime_secs = bot + .get("uptime_seconds") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let uptime = format_uptime(uptime_secs); + let status_str = if healthy { "OK" } else { "DOWN" }; + + lines.push(format!( + "{:14} {:>6} {:>8} {:>6} {:>6}", + name, status_str, uptime, failures, gateway_failures + )); + } + } + + lines.push("--------------------------------------------------------".to_string()); + lines.push(format!("Total: {} | Healthy: {}", total_bots, healthy_bots)); + lines.push("```".to_string()); + let message = lines.join("\n"); + let _ = msg.channel_id.say(&ctx.http, message).await; + self.health.update_discord_timestamp(); } else { - debug!("Message '{}' does not match command '{}'", msg.content, command); + debug!( + "Message '{}' does not match command '{}'", + msg.content, command + ); } } } @@ -677,10 +811,7 @@ async fn read_prices_from_file() -> BotResult { // Check if file exists if !std::path::Path::new(file_path).exists() { if attempt < MAX_RETRIES { - warn!( - "Prices file not found (attempt {}), retrying...", - attempt - ); + warn!("Prices file not found (attempt {}), retrying...", attempt); sleep(Duration::from_millis(1000 * attempt as u64)).await; continue; } @@ -906,9 +1037,12 @@ async fn price_update_loop( Ok(time) => time / config.update_interval.as_secs(), Err(_) => 0, }; - + if update_count % 10 == 0 { - debug!("Running periodic Discord connectivity test for {}", crypto_name); + debug!( + "Running periodic Discord connectivity test for {}", + crypto_name + ); tokio::spawn({ let health_clone = health.clone(); async move { @@ -923,7 +1057,10 @@ async fn price_update_loop( if loop_duration < target_interval { let sleep_time = target_interval - loop_duration; - debug!("Update took {:?}, sleeping for {:?}", loop_duration, sleep_time); + debug!( + "Update took {:?}, sleeping for {:?}", + loop_duration, sleep_time + ); sleep(sleep_time).await; } else { warn!( @@ -1019,15 +1156,17 @@ fn format_custom_status( } "SILVER" | "XAG" => { // For Silver bot, show Gold/Silver ratio - let gold_price = shared_prices.prices.get("GOLD") + let gold_price = shared_prices + .prices + .get("GOLD") .or_else(|| shared_prices.prices.get("XAU")) .or_else(|| shared_prices.prices.get("PAXG")) .map(|p| p.price); - + let ratio_str = if let Some(gold) = gold_price { - format!("Au/Ag: {:.2}", gold / current_price) + format!("Au/Ag: {:.2}", gold / current_price) } else { - format!("{:.8} ₿", btc_amount) // Fallback + format!("{:.8} ₿", btc_amount) // Fallback }; match update_count { @@ -1048,16 +1187,17 @@ fn format_custom_status( "SHANGHAI" => { // For Shanghai bot, scroll through Premium and Premium Percent match update_count { - 0 | 3 => { // Show arrow/building history on 0 and 3 (half the time, or custom cycle) - // User asked for "always update price... and then cycle 2 and 3 would be underneath" - // Actually user said: "watching area scroll through the price delta 'premium'... and percentage delta" - // The default status (update_count 0) usually shows price change. - // Let's make it: - // 0: Price Change (standard) - // 1: Premium $ - // 2: Premium % - // 3: Source or back to standard - + 0 | 3 => { + // Show arrow/building history on 0 and 3 (half the time, or custom cycle) + // User asked for "always update price... and then cycle 2 and 3 would be underneath" + // Actually user said: "watching area scroll through the price delta 'premium'... and percentage delta" + // The default status (update_count 0) usually shows price change. + // Let's make it: + // 0: Price Change (standard) + // 1: Premium $ + // 2: Premium % + // 3: Source or back to standard + if change_percent == 0.0 && arrow == "🔄" { format!("{} Building history", arrow) } else { @@ -1065,16 +1205,20 @@ fn format_custom_status( format!("{} {}{:.2}% (1h)", arrow, change_sign, change_percent) } } - + 1 => { - let premium = shared_prices.prices.get("SHANGHAI") + let premium = shared_prices + .prices + .get("SHANGHAI") .and_then(|p| p.premium) .unwrap_or(0.0); format!("Prem: ${:.2}", premium) } - + 2 => { - let premium_pct = shared_prices.prices.get("SHANGHAI") + let premium_pct = shared_prices + .prices + .get("SHANGHAI") .and_then(|p| p.premium_percent) .unwrap_or(0.0); format!("Prem: {:.2}%", premium_pct) @@ -1092,10 +1236,12 @@ fn format_custom_status( format!("{} {}{:.2}% (1h)", arrow, change_sign, change_percent) } } - + 1 => { // Calculate premium: SHANGHAISILVER - SILVER - let silver_price = shared_prices.prices.get("SILVER") + let silver_price = shared_prices + .prices + .get("SILVER") .map(|p| p.price) .unwrap_or(0.0); let premium = if silver_price > 0.0 { @@ -1105,10 +1251,12 @@ fn format_custom_status( }; format!("Prem: ${:.2}", premium) } - + 2 => { // Calculate premium percent - let silver_price = shared_prices.prices.get("SILVER") + let silver_price = shared_prices + .prices + .get("SILVER") .map(|p| p.price) .unwrap_or(0.0); let premium_pct = if silver_price > 0.0 { @@ -1153,7 +1301,10 @@ async fn get_crypto_price(config: &BotConfig, database: &Arc) -> return Ok(price); } Ok(price) => { - debug!("Got SHANGHAISILVER price but it's zero or negative: {}", price); + debug!( + "Got SHANGHAISILVER price but it's zero or negative: {}", + price + ); } Err(e) => { debug!("Failed to get SHANGHAISILVER from database: {}", e); @@ -1277,11 +1428,8 @@ async fn get_individual_crypto_price(feed_id: &str) -> BotResult { /// Test Discord connectivity by making a simple API call async fn test_discord_connectivity(health: Arc) { use reqwest::Client; - - let client = match Client::builder() - .timeout(Duration::from_secs(10)) - .build() - { + + let client = match Client::builder().timeout(Duration::from_secs(10)).build() { Ok(client) => client, Err(e) => { error!("Failed to create HTTP client for Discord test: {}", e); @@ -1289,7 +1437,7 @@ async fn test_discord_connectivity(health: Arc) { return; } }; - + match client .get("https://discord.com/api/v10/gateway") .header("User-Agent", "Discord-Bot-Health-Check/1.0") @@ -1302,7 +1450,10 @@ async fn test_discord_connectivity(health: Arc) { health.update_discord_test_timestamp(); health.reset_discord_test_failures(); } else { - warn!("Discord connectivity test failed with status: {}", response.status()); + warn!( + "Discord connectivity test failed with status: {}", + response.status() + ); health.increment_discord_test_failures(); } } diff --git a/src/database.rs b/src/database.rs index a21cdbf..c9d9b42 100644 --- a/src/database.rs +++ b/src/database.rs @@ -33,7 +33,22 @@ impl PriceDatabase { timestamp INTEGER NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); - CREATE INDEX IF NOT EXISTS idx_prices_crypto_timestamp ON prices(crypto_name, timestamp);", + CREATE INDEX IF NOT EXISTS idx_prices_crypto_timestamp ON prices(crypto_name, timestamp); + CREATE TABLE IF NOT EXISTS price_aggregates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + crypto_name TEXT NOT NULL, + bucket_start INTEGER NOT NULL, + bucket_duration INTEGER NOT NULL, + open_price REAL NOT NULL, + high_price REAL NOT NULL, + low_price REAL NOT NULL, + close_price REAL NOT NULL, + avg_price REAL NOT NULL, + sample_count INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_aggregates_crypto_bucket + ON price_aggregates(crypto_name, bucket_start, bucket_duration);", ) }); diff --git a/src/shanghai_price_service.rs b/src/shanghai_price_service.rs index 12eea6e..c9425b3 100644 --- a/src/shanghai_price_service.rs +++ b/src/shanghai_price_service.rs @@ -9,10 +9,16 @@ use std::time::Duration; use tokio::time::sleep; use tracing::{error, info, warn}; -const UPDATE_INTERVAL_SECONDS: u64 = 1800; // 30 minutes const CRYPTO_NAME: &str = "SHANGHAISILVER"; const FILE_PATH: &str = "shared/prices.json"; +fn get_update_interval() -> u64 { + std::env::var("UPDATE_INTERVAL_SECONDS") + .unwrap_or_else(|_| "1800".to_string()) + .parse::() + .unwrap_or(1800) +} + /// Check if Shanghai Gold Exchange is currently in trading hours /// SGE Trading Hours in UTC: /// - Day Session: 01:00 - 07:30 UTC @@ -23,19 +29,26 @@ fn is_sge_market_open() -> bool { let hour = ((timestamp / 3600) % 24) as u32; let minute = ((timestamp / 60) % 60) as u32; let time = hour * 60 + minute; // minutes since midnight UTC - + // Day session: 01:00-07:30 UTC (60-450) let day_session = time >= 60 && time <= 450; - + // Night session: 12:00-18:30 UTC (720-1110) let night_session = time >= 720 && time <= 1110; - + day_session || night_session } -pub async fn run(database: Arc) -> Result<(), Box> { +pub async fn run( + database: Arc, +) -> Result<(), Box> { + let update_interval = get_update_interval(); info!("🚀 Starting Shanghai Silver Price Service..."); - info!("📊 Update interval: {} seconds (30 minutes)", UPDATE_INTERVAL_SECONDS); + info!( + "📊 Update interval: {} seconds ({} minutes)", + update_interval, + update_interval / 60 + ); info!("🕐 SGE Trading Hours - Day: 01:00-07:30 UTC, Night: 12:00-18:30 UTC"); let shared_dir = "shared"; @@ -76,25 +89,33 @@ pub async fn run(database: Arc) -> Result<(), Box {:?}", loop_duration, target_interval); + warn!( + "⚠️ Update took longer than interval: {:?} > {:?}", + loop_duration, target_interval + ); sleep(Duration::from_secs(1)).await; } } } -fn update_prices_json(price_data: &PriceData) -> Result<(), Box> { +fn update_prices_json( + price_data: &PriceData, +) -> Result<(), Box> { let mut prices = HashMap::new(); if Path::new(FILE_PATH).exists() { @@ -110,10 +131,7 @@ fn update_prices_json(price_data: &PriceData) -> Result<(), Box Date: Mon, 16 Mar 2026 22:19:10 -0700 Subject: [PATCH 14/18] Fix duplicate test function in utils.rs --- src/utils.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 03ffa62..272b929 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -141,9 +141,6 @@ mod tests { #[test] fn test_validate_price_invalid() { - #[test] - fn test_validate_price_invalid() { - assert!(validate_price(-10.0).is_err()); assert!(validate_price(-10.0).is_err()); assert!(validate_price(f64::NAN).is_err()); assert!(validate_price(f64::INFINITY).is_err()); From 47ad127206e8180a2459f71f64adb6aa7372e2c5 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 17 Mar 2026 08:55:08 -0700 Subject: [PATCH 15/18] Fix tracing import order and duplicate doc comment --- src/database.rs | 2 - src/price_service.rs | 250 +++++++++++++++++++++++++++---------------- 2 files changed, 159 insertions(+), 93 deletions(-) diff --git a/src/database.rs b/src/database.rs index c9d9b42..c4f8df2 100644 --- a/src/database.rs +++ b/src/database.rs @@ -321,8 +321,6 @@ impl PriceDatabase { ("🔄".to_string(), 0.0) } - /// Get price history for charting (up to specified days) - /// Returns vector of (timestamp, price) tuples /// Get price history for charting (up to specified days) /// Returns vector of (timestamp, price) tuples pub fn get_price_history(&self, crypto_name: &str, days: u64) -> BotResult> { diff --git a/src/price_service.rs b/src/price_service.rs index b658db2..5a61b0b 100644 --- a/src/price_service.rs +++ b/src/price_service.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::Path; use std::time::Duration; use tokio::time::sleep; - +use tracing::{error, info, warn}; const HERMES_API_URL: &str = "https://hermes.pyth.network/api/latest_price_feeds"; @@ -35,62 +35,69 @@ pub struct PricesFile { pub timestamp: u64, } - - - - fn get_feed_ids() -> HashMap { let mut feeds = HashMap::new(); - + // Read from environment variable CRYPTO_FEEDS // Format: BTC:0x...,ETH:0x...,SOL:0x...,WIF:0x... let feeds_str = std::env::var("CRYPTO_FEEDS").unwrap_or_else(|_| { // Default feeds if not specified "BTC:0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43,ETH:0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace,SOL:0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d".to_string() }); - + for pair in feeds_str.split(',') { if let Some((name, feed_id)) = pair.split_once(':') { feeds.insert(name.trim().to_string(), feed_id.trim().to_string()); } } - + feeds } async fn get_crypto_price(feed_id: &str) -> Result> { let url = format!("{}?ids[]={}", HERMES_API_URL, feed_id); const MAX_RETRIES: u32 = 3; - + for attempt in 1..=MAX_RETRIES { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()?; - - match client.get(&url) + + match client + .get(&url) .header("User-Agent", "Crypto-Price-Service/1.0") .send() .await { Ok(response) => { if !response.status().is_success() { - error!("HTTP request failed (attempt {}): {}", attempt, response.status()); + error!( + "HTTP request failed (attempt {}): {}", + attempt, + response.status() + ); if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; + tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)) + .await; continue; } return Err(format!("HTTP request failed: {}", response.status()).into()); } - + match response.json::().await { Ok(json) => { // Parse the price from the JSON array format if let Some(feeds_array) = json.as_array() { if let Some(first_feed) = feeds_array.first() { if let Some(price_data) = first_feed.get("price") { - if let Some(price_str) = price_data.get("price").and_then(|p| p.as_str()) { + if let Some(price_str) = + price_data.get("price").and_then(|p| p.as_str()) + { if let Ok(price) = price_str.parse::() { - let expo = price_data.get("expo").and_then(|e| e.as_i64()).unwrap_or(0); + let expo = price_data + .get("expo") + .and_then(|e| e.as_i64()) + .unwrap_or(0); let real_price = price as f64 * 10f64.powi(expo as i32); return Ok(real_price); } @@ -103,7 +110,10 @@ async fn get_crypto_price(feed_id: &str) -> Result { error!("JSON parsing failed (attempt {}): {}", attempt, e); if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; + tokio::time::sleep(std::time::Duration::from_millis( + 1000 * attempt as u64, + )) + .await; continue; } return Err(e.into()); @@ -113,33 +123,45 @@ async fn get_crypto_price(feed_id: &str) -> Result { error!("Network request failed (attempt {}): {}", attempt, e); if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; + tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)) + .await; continue; } return Err(e.into()); } } } - + unreachable!() } -pub async fn fetch_shanghai_history(range: &str, symbol: Option<&str>) -> Result, Box> { +pub async fn fetch_shanghai_history( + range: &str, + symbol: Option<&str>, +) -> Result, Box> { let symbol_param = symbol.unwrap_or(""); let url = format!( - "https://metalcharts.org/api/shanghai/history?range={}{}", + "https://metalcharts.org/api/shanghai/history?range={}{}", range, - if symbol_param.is_empty() { "".to_string() } else { format!("&symbol={}", symbol_param) } + if symbol_param.is_empty() { + "".to_string() + } else { + format!("&symbol={}", symbol_param) + } ); - + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()?; - let response = client.get(&url) + let response = client + .get(&url) .header("Origin", "https://metalcharts.org") .header("Referer", "https://metalcharts.org/") - .header("User-Agent", "Mozilla/5.0 (compatible; RustyMcPriceface/1.0)") + .header( + "User-Agent", + "Mozilla/5.0 (compatible; RustyMcPriceface/1.0)", + ) .send() .await?; @@ -148,7 +170,7 @@ pub async fn fetch_shanghai_history(range: &str, symbol: Option<&str>) -> Result } let json: Value = response.json().await?; - + // The API returns { data: [...], symbol: "..." } if let Some(data_array) = json.get("data") { let history: Vec = serde_json::from_value(data_array.clone())?; @@ -158,8 +180,13 @@ pub async fn fetch_shanghai_history(range: &str, symbol: Option<&str>) -> Result Err("Failed to parse Shanghai History JSON structure".into()) } -async fn fetch_yahoo_price(ticker: &str) -> Result> { - let url = format!("https://query1.finance.yahoo.com/v8/finance/chart/{}?interval=1d&range=1d", ticker); +async fn fetch_yahoo_price( + ticker: &str, +) -> Result> { + let url = format!( + "https://query1.finance.yahoo.com/v8/finance/chart/{}?interval=1d&range=1d", + ticker + ); const MAX_RETRIES: u32 = 3; for attempt in 1..=MAX_RETRIES { @@ -167,29 +194,47 @@ async fn fetch_yahoo_price(ticker: &str) -> Result { if !response.status().is_success() { - error!("Yahoo API request failed for {} (attempt {}): {}", ticker, attempt, response.status()); + error!( + "Yahoo API request failed for {} (attempt {}): {}", + ticker, + attempt, + response.status() + ); if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; + tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)) + .await; continue; } - return Err(format!("Yahoo API HTTP request failed: {}", response.status()).into()); + return Err( + format!("Yahoo API HTTP request failed: {}", response.status()).into(), + ); } match response.json::().await { Ok(json) => { // Navigate to chart.result[0].meta.regularMarketPrice - if let Some(result) = json.get("chart").and_then(|c| c.get("result")).and_then(|r| r.get(0)) { + if let Some(result) = json + .get("chart") + .and_then(|c| c.get("result")) + .and_then(|r| r.get(0)) + { if let Some(meta) = result.get("meta") { - let price = meta.get("regularMarketPrice").and_then(|p| p.as_f64()) + let price = meta + .get("regularMarketPrice") + .and_then(|p| p.as_f64()) .ok_or("Missing regularMarketPrice")?; - + // Parse timestamp if available, else use current let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? @@ -207,15 +252,16 @@ async fn fetch_yahoo_price(ticker: &str) -> Result { - error!("Yahoo JSON parsing failed: {}", e); - return Err(e.into()); + error!("Yahoo JSON parsing failed: {}", e); + return Err(e.into()); } } } Err(e) => { error!("Yahoo Network request failed: {}", e); if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)).await; + tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)) + .await; continue; } return Err(e.into()); @@ -228,17 +274,20 @@ async fn fetch_yahoo_price(ticker: &str) -> Result Result> { let feeds = get_feed_ids(); let mut prices = HashMap::new(); - + for (crypto, feed_id) in &feeds { match get_crypto_price(&feed_id).await { Ok(price) => { - prices.insert(crypto.clone(), PriceData { - price, - timestamp: 0, - premium: None, - premium_percent: None, - source: None - }); + prices.insert( + crypto.clone(), + PriceData { + price, + timestamp: 0, + premium: None, + premium_percent: None, + source: None, + }, + ); info!("Fetched {} price: ${:.6}", crypto, price); } Err(e) => { @@ -251,13 +300,16 @@ async fn fetch_all_prices() -> Result 2.0, _ => 1.0, }; - prices.insert(crypto.clone(), PriceData { - price: default_price, - timestamp: 0, - premium: None, - premium_percent: None, - source: None - }); + prices.insert( + crypto.clone(), + PriceData { + price: default_price, + timestamp: 0, + premium: None, + premium_percent: None, + source: None, + }, + ); } } } @@ -280,31 +332,31 @@ async fn fetch_all_prices() -> Result { prices.insert("DXY".to_string(), data.clone()); - info!("Fetched DXY price: ${:.2}", data.price); + info!("Fetched DXY price: ${:.2}", data.price); } Err(e) => { - error!("Failed to fetch DXY price: {}", e); + error!("Failed to fetch DXY price: {}", e); } } } - + let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| format!("System time error: {}", e))? .as_secs(); - + // Update timestamps for all prices for price_data in prices.values_mut() { price_data.timestamp = timestamp; } - - Ok(PricesFile { - prices, - timestamp, - }) + + Ok(PricesFile { prices, timestamp }) } -async fn write_prices_to_file(prices: &PricesFile, file_path: &str) -> Result<(), Box> { +async fn write_prices_to_file( + prices: &PricesFile, + file_path: &str, +) -> Result<(), Box> { let json_string = serde_json::to_string_pretty(prices)?; fs::write(file_path, json_string)?; info!("Wrote prices to {}", file_path); @@ -312,12 +364,12 @@ async fn write_prices_to_file(prices: &PricesFile, file_path: &str) -> Result<() } use crate::database::PriceDatabase; -use tracing::{info, error, warn}; use std::sync::Arc; const GOLDSILVER_AI_URL: &str = "https://goldsilver.ai/metal-prices/shanghai-silver-price"; -pub async fn fetch_shanghai_silver_price() -> Result> { +pub async fn fetch_shanghai_silver_price( +) -> Result> { // Use goldsilver.ai for Shanghai Spot price let (shanghai_spot, western_spot) = fetch_goldsilver_ai_prices().await?; @@ -342,7 +394,8 @@ pub async fn fetch_shanghai_silver_price() -> Result Result<(f64, f64), Box> { +async fn fetch_goldsilver_ai_prices() -> Result<(f64, f64), Box> +{ let client = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build()?; @@ -359,7 +412,9 @@ async fn fetch_goldsilver_ai_prices() -> Result<(f64, f64), Box Result<(f64, f64), Box> { +fn extract_goldsilver_prices( + html: &str, +) -> Result<(f64, f64), Box> { // Debug: print a snippet of the HTML around "Shanghai Spot" if let Some(pos) = html.find("Shanghai Spot") { let start = pos.saturating_sub(50); @@ -367,16 +422,19 @@ fn extract_goldsilver_prices(html: &str) -> Result<(f64, f64), Box Option { // Look for digits let mut chars = after_prefix.chars().peekable(); let mut number_str = String::new(); - + // Collect digits and decimal point while let Some(&c) = chars.peek() { if c.is_ascii_digit() || c == '.' { @@ -401,7 +459,7 @@ fn extract_first_number_after(html: &str, prefix: &str) -> Option { chars.next(); } } - + if !number_str.is_empty() { return number_str.parse::().ok(); } @@ -409,13 +467,15 @@ fn extract_first_number_after(html: &str, prefix: &str) -> Option { None } -pub async fn run(database: Arc) -> Result<(), Box> { +pub async fn run( + database: Arc, +) -> Result<(), Box> { // Get update interval from environment let update_interval = std::env::var("UPDATE_INTERVAL_SECONDS") .unwrap_or_else(|_| "12".to_string()) .parse::() .unwrap_or(12); - + // Create shared directory if it doesn't exist let shared_dir = "shared"; if !Path::new(shared_dir).exists() { @@ -425,32 +485,35 @@ pub async fn run(database: Arc) -> Result<(), Box>().join(", ")); - + info!( + "🪙 Tracking cryptos: {}", + feeds.keys().cloned().collect::>().join(", ") + ); + let mut consecutive_failures = 0; const MAX_CONSECUTIVE_FAILURES: u32 = 5; - + loop { let loop_start = std::time::Instant::now(); - + match fetch_all_prices().await { Ok(prices) => { consecutive_failures = 0; // Reset failure counter on success - + // Store in JSON file (for backward compatibility) if let Err(e) = write_prices_to_file(&prices, &file_path).await { error!("Failed to write prices to JSON: {}", e); } - + // Store in SQLite database using shared pool for (crypto, price_data) in &prices.prices { if let Err(e) = database.save_price(crypto, price_data.price) { @@ -460,9 +523,11 @@ pub async fn run(database: Arc) -> Result<(), Box { consecutive_failures += 1; - error!("❌ Failed to fetch prices (failure {}/{}): {}", - consecutive_failures, MAX_CONSECUTIVE_FAILURES, e); - + error!( + "❌ Failed to fetch prices (failure {}/{}): {}", + consecutive_failures, MAX_CONSECUTIVE_FAILURES, e + ); + if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { warn!("⚠️ Too many consecutive failures. Entering recovery mode for 60 seconds..."); sleep(Duration::from_secs(60)).await; @@ -470,18 +535,21 @@ pub async fn run(database: Arc) -> Result<(), Box {:?}", loop_duration, target_interval); + warn!( + "⚠️ Update took longer than interval: {:?} > {:?}", + loop_duration, target_interval + ); // Still sleep for a minimum time to prevent tight loops sleep(Duration::from_secs(1)).await; } } -} \ No newline at end of file +} From 26688617ece2ae71d2b38b4b0cd13b467230458d Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 17 Mar 2026 08:56:59 -0700 Subject: [PATCH 16/18] Replace static Mutex with tokio::Semaphore for async-friendly rate limiting --- src/discord_api.rs | 111 ++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 57 deletions(-) diff --git a/src/discord_api.rs b/src/discord_api.rs index 83af642..2340454 100644 --- a/src/discord_api.rs +++ b/src/discord_api.rs @@ -2,12 +2,13 @@ use crate::errors::{BotError, BotResult}; use serenity::http::Http; use serenity::model::id::GuildId; use std::sync::Arc; -use std::sync::Mutex; use std::time::Duration; +use tokio::sync::Semaphore; use tokio::time::sleep; use tracing::{debug, warn}; const MAX_RETRIES: u32 = 3; +const MAX_CONCURRENT_CALLS: usize = 2; const RATE_LIMIT_DELAY_MS: u64 = 2000; // 2 seconds between Discord API calls /// Discord API wrapper with rate limiting and error handling @@ -27,36 +28,16 @@ impl DiscordApi { F: FnMut() -> Fut, Fut: std::future::Future>, { - static LAST_CALL: Mutex> = Mutex::new(None); - - // Enforce rate limiting - let sleep_time = { - let mut last_call = LAST_CALL.lock().map_err(|_| serenity::Error::Other("Mutex lock error"))?; - let now = std::time::Instant::now(); - - if let Some(last) = *last_call { - let elapsed = last.elapsed(); - let min_interval = Duration::from_millis(RATE_LIMIT_DELAY_MS); - if elapsed < min_interval { - let sleep_duration = min_interval - elapsed; - *last_call = Some(now); - Some(sleep_duration) - } else { - *last_call = Some(now); - None - } - } else { - *last_call = Some(now); - None - } - }; - - // Sleep outside the mutex lock if needed - if let Some(duration) = sleep_time { - debug!("Rate limiting: sleeping for {:?}", duration); - sleep(duration).await; - } - + static SEMAPHORE: Semaphore = Semaphore::const_new(MAX_CONCURRENT_CALLS); + + let _permit = SEMAPHORE + .acquire() + .await + .map_err(|_| serenity::Error::Other("Semaphore acquire error"))?; + + // Enforce minimum delay between calls + sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await; + // Execute the operation with retry logic for attempt in 1..=MAX_RETRIES { match operation().await { @@ -64,7 +45,10 @@ impl DiscordApi { Err(e) => { if e.to_string().contains("rate limit") || e.to_string().contains("429") { let backoff_time = Duration::from_secs(2_u64.pow(attempt)); - warn!("Rate limited, backing off for {:?} (attempt {})", backoff_time, attempt); + warn!( + "Rate limited, backing off for {:?} (attempt {})", + backoff_time, attempt + ); sleep(backoff_time).await; } else if attempt < MAX_RETRIES { warn!("Discord API call failed (attempt {}): {}", attempt, e); @@ -75,7 +59,7 @@ impl DiscordApi { } } } - + unreachable!() } @@ -83,21 +67,29 @@ impl DiscordApi { pub async fn update_nickname(&self, guild_id: GuildId, nickname: &str) -> BotResult<()> { let http_ref = self.http.clone(); let nickname_owned = nickname.to_string(); - - match self.rate_limited_call(|| { - let http_clone = http_ref.clone(); - let nickname_clone = nickname_owned.clone(); - async move { - http_clone.edit_nickname(guild_id, Some(&nickname_clone), None).await - } - }).await { + + match self + .rate_limited_call(|| { + let http_clone = http_ref.clone(); + let nickname_clone = nickname_owned.clone(); + async move { + http_clone + .edit_nickname(guild_id, Some(&nickname_clone), None) + .await + } + }) + .await + { Ok(_) => { debug!("Updated nickname in guild {}", guild_id); Ok(()) } Err(e) => { if e.to_string().contains("rate limit") || e.to_string().contains("429") { - warn!("Rate limited while updating nickname in guild {}: {}", guild_id, e); + warn!( + "Rate limited while updating nickname in guild {}: {}", + guild_id, e + ); } else { warn!("Failed to update nickname in guild {}: {}", guild_id, e); } @@ -107,29 +99,34 @@ impl DiscordApi { } /// Update nicknames in multiple guilds in parallel - pub async fn update_nicknames_in_guilds(&self, guilds: &[GuildId], nickname: &str) -> Vec> { + pub async fn update_nicknames_in_guilds( + &self, + guilds: &[GuildId], + nickname: &str, + ) -> Vec> { use futures::stream::StreamExt; use std::sync::Arc; - + let nickname = nickname.to_string(); let self_arc = Arc::new(self.clone()); - - let futures: Vec<_> = guilds.iter().map(|guild_id| { - let api = self_arc.clone(); - let nickname = nickname.clone(); - let guild_id = *guild_id; - async move { - api.update_nickname(guild_id, &nickname).await - } - }).collect(); - + + let futures: Vec<_> = guilds + .iter() + .map(|guild_id| { + let api = self_arc.clone(); + let nickname = nickname.clone(); + let guild_id = *guild_id; + async move { api.update_nickname(guild_id, &nickname).await } + }) + .collect(); + let mut results = Vec::new(); let mut stream = futures::stream::iter(futures).buffer_unordered(3); - + while let Some(result) = stream.next().await { results.push(result); } - + results } -} \ No newline at end of file +} From ced440b1b2e27340dcecd10b062c14e24a7d79a3 Mon Sep 17 00:00:00 2001 From: buzzkillb Date: Tue, 17 Mar 2026 09:31:10 -0700 Subject: [PATCH 17/18] Security: fix User-Agent spoofing, bind health server to localhost, use absolute db path --- src/config.rs | 2 +- src/health_server.rs | 36 +++++++++++++++++------------------- src/price_service.rs | 10 +++++++--- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/config.rs b/src/config.rs index 9e715f6..0dd14dc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,7 +68,7 @@ impl BotConfig { } /// Constants for the application -pub const DATABASE_PATH: &str = "shared/prices.db"; +pub const DATABASE_PATH: &str = "/app/shared/prices.db"; /// Default update interval in seconds pub const UPDATE_INTERVAL_SECONDS: u64 = 12; diff --git a/src/health_server.rs b/src/health_server.rs index 8378d74..afc5a49 100644 --- a/src/health_server.rs +++ b/src/health_server.rs @@ -1,14 +1,8 @@ -use axum::{ - extract::State, - http::StatusCode, - response::Json, - routing::get, - Router, -}; +use crate::health::HealthAggregator; +use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router}; use std::sync::Arc; use tokio::net::TcpListener; -use tracing::{info, error}; -use crate::health::HealthAggregator; +use tracing::{error, info}; pub type SharedHealth = Arc; @@ -19,8 +13,8 @@ pub async fn start_health_server(health: SharedHealth, port: u16) { .route("/test-discord", get(test_discord_connectivity)) .with_state(health); - let addr = format!("0.0.0.0:{}", port); - + let addr = format!("127.0.0.1:{}", port); + match TcpListener::bind(&addr).await { Ok(listener) => { info!("Health check server listening on {}", addr); @@ -34,10 +28,12 @@ pub async fn start_health_server(health: SharedHealth, port: u16) { } } -async fn health_check(State(health): State) -> Result, StatusCode> { +async fn health_check( + State(health): State, +) -> Result, StatusCode> { let health_data = health.to_json(); let is_healthy = health.is_healthy(); - + if is_healthy { Ok(Json(health_data)) } else { @@ -45,15 +41,17 @@ async fn health_check(State(health): State) -> Result) -> Result, StatusCode> { +async fn test_discord_connectivity( + State(_health): State, +) -> Result, StatusCode> { use reqwest::Client; use std::time::Duration; - + let client = Client::builder() .timeout(Duration::from_secs(10)) .build() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let test_result = match client .get("https://discord.com/api/v10/gateway") .header("User-Agent", "Discord-Bot-Health-Check/1.0") @@ -63,9 +61,9 @@ async fn test_discord_connectivity(State(_health): State) -> Resul Ok(response) => response.status().is_success(), Err(_) => false, }; - + let health_data = _health.to_json(); - + let response = serde_json::json!({ "discord_connectivity_test": test_result, "timestamp": std::time::SystemTime::now() @@ -74,7 +72,7 @@ async fn test_discord_connectivity(State(_health): State) -> Resul .as_secs(), "health_data": health_data }); - + if test_result { Ok(Json(response)) } else { diff --git a/src/price_service.rs b/src/price_service.rs index 5a61b0b..a32d85b 100644 --- a/src/price_service.rs +++ b/src/price_service.rs @@ -400,9 +400,13 @@ async fn fetch_goldsilver_ai_prices() -> Result<(f64, f64), Box Date: Tue, 17 Mar 2026 10:39:51 -0700 Subject: [PATCH 18/18] Best practices: add fallback price alerts, restrict /status to admins, improve HTML parsing --- Cargo.lock | 1 + src/bot.rs | 25 ++++++++++++++++++++----- src/price_service.rs | 38 +++++++++++++++++--------------------- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3cc9a24..b1bafab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1754,6 +1754,7 @@ dependencies = [ "serde", "serde_json", "serenity", + "tempfile", "thiserror 1.0.69", "tokio", "tower 0.4.13", diff --git a/src/bot.rs b/src/bot.rs index 21025dd..6a4c908 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -539,8 +539,22 @@ impl EventHandler for Bot { } "status" => { debug!("Handling /status command"); - // Only BTC bot responds to status - if self.config.crypto_name == "BTC" { + + // Check if user has admin permissions + let has_permission = command_interaction + .member + .as_ref() + .map(|m| m.permissions.map(|p| p.administrator()).unwrap_or(false)) + .unwrap_or(false); + + if !has_permission { + let data = CreateInteractionResponseMessage::new() + .content("❌ This command is restricted to server administrators."); + let builder = CreateInteractionResponse::Message(data); + command_interaction + .create_response(&ctx.http, builder) + .await + } else if self.config.crypto_name == "BTC" { let status = self.health_aggregator.to_json(); let total_bots = status .get("total_bots") @@ -599,16 +613,17 @@ impl EventHandler for Bot { lines.push("```".to_string()); let message = lines.join("\n"); - let _ = command_interaction + command_interaction .create_response( &ctx.http, CreateInteractionResponse::Message( CreateInteractionResponseMessage::new().content(message), ), ) - .await; + .await + } else { + Ok(()) as Result<(), serenity::Error> } - Ok(()) } _ => { warn!("Unknown command: {}", command_interaction.data.name); diff --git a/src/price_service.rs b/src/price_service.rs index a32d85b..d390700 100644 --- a/src/price_service.rs +++ b/src/price_service.rs @@ -292,7 +292,7 @@ async fn fetch_all_prices() -> Result { error!("Failed to fetch {} price: {}", crypto, e); - // Use previous price or default + warn!("⚠️ Using FALLBACK price for {} - API may be down!", crypto); let default_price = match crypto.as_str() { "BTC" => 45000.0, "ETH" => 2800.0, @@ -444,31 +444,27 @@ fn extract_goldsilver_prices( } fn extract_first_number_after(html: &str, prefix: &str) -> Option { + // Find prefix and extract number after it if let Some(pos) = html.find(prefix) { let after_prefix = &html[pos + prefix.len()..]; - // Look for digits - let mut chars = after_prefix.chars().peekable(); - let mut number_str = String::new(); - - // Collect digits and decimal point - while let Some(&c) = chars.peek() { - if c.is_ascii_digit() || c == '.' { - number_str.push(c); - chars.next(); - } else if !number_str.is_empty() { - // Stop when we hit non-digit after starting number - break; - } else { - // Skip non-digit characters before number - chars.next(); - } - } - if !number_str.is_empty() { - return number_str.parse::().ok(); + // Use regex to find first number after prefix + let re = regex::Regex::new(r"-?\d+\.?\d*").ok()?; + if let Some(m) = re.find(after_prefix) { + return m.as_str().parse::().ok(); } } - None + + // Fallback: find all numbers and return the largest (reasonable for prices) + let re = regex::Regex::new(r"-?\d+\.?\d*").ok()?; + let numbers: Vec = re + .find_iter(html) + .filter_map(|m| m.as_str().parse::().ok()) + .collect(); + + numbers + .into_iter() + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) } pub async fn run(