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/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/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/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 +} 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 b658db2..d390700 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,22 +274,25 @@ 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) => { 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, @@ -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,14 +394,19 @@ 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()?; - let response = client.get(GOLDSILVER_AI_URL) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + let response = client + .get(GOLDSILVER_AI_URL) + .header("User-Agent", "RustyMcPriceface/1.0 (crypto price bot)") + .header( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ) .header("Accept-Language", "en-US,en;q=0.9") .send() .await?; @@ -359,7 +416,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,55 +426,56 @@ fn extract_goldsilver_prices(html: &str) -> Result<(f64, f64), Box 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(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 +}