Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f940c8c
Fix .dockerignore to allow Dockerfile and docker-compose
buzzkillb Mar 10, 2026
f08f2ef
Fix Pyth Network URL in README
buzzkillb Mar 10, 2026
9475c0c
Add curl example for health check
buzzkillb Mar 10, 2026
621eaf1
Add Docker and OS to tech stack
buzzkillb Mar 10, 2026
a107b6b
Skip saving invalid prices (0 or negative) to database
buzzkillb Mar 10, 2026
55862c4
Replace println! with tracing in price_service.rs
buzzkillb Mar 10, 2026
7b61fba
Remove unused db_query.rs file
buzzkillb Mar 10, 2026
60e068b
Fix default UPDATE_INTERVAL_SECONDS to match docker-compose (12s)
buzzkillb Mar 10, 2026
7899368
Clean up debug comments
buzzkillb Mar 10, 2026
4ecca29
Add unit tests for utils and database
buzzkillb Mar 10, 2026
3dd5f72
Add GitHub Actions CI for running tests
buzzkillb Mar 10, 2026
42dd612
Add CONTRIBUTING.md, LICENSE, and rustfmt.toml for professional open …
buzzkillb Mar 10, 2026
33fccf4
Merge main into dev
buzzkillb Mar 17, 2026
3b47747
Fix scan findings: add price_aggregates table, env var for shanghai i…
buzzkillb Mar 17, 2026
255240b
Fix duplicate test function in utils.rs
buzzkillb Mar 17, 2026
ee97624
Merge branch 'main' into dev
buzzkillb Mar 17, 2026
47ad127
Fix tracing import order and duplicate doc comment
buzzkillb Mar 17, 2026
2668861
Replace static Mutex with tokio::Semaphore for async-friendly rate li…
buzzkillb Mar 17, 2026
ced440b
Security: fix User-Agent spoofing, bind health server to localhost, u…
buzzkillb Mar 17, 2026
26cf4fe
Best practices: add fallback price alerts, restrict /status to admins…
buzzkillb Mar 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 20 additions & 5 deletions src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 0 additions & 2 deletions src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<(i64, f64)>> {
Expand Down
111 changes: 54 additions & 57 deletions src/discord_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,44 +28,27 @@ impl DiscordApi {
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, serenity::Error>>,
{
static LAST_CALL: Mutex<Option<std::time::Instant>> = 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 {
Ok(result) => return Ok(result),
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);
Expand All @@ -75,29 +59,37 @@ impl DiscordApi {
}
}
}

unreachable!()
}

/// Update bot nickname in a specific guild
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);
}
Expand All @@ -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<BotResult<()>> {
pub async fn update_nicknames_in_guilds(
&self,
guilds: &[GuildId],
nickname: &str,
) -> Vec<BotResult<()>> {
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
}
}
}
36 changes: 17 additions & 19 deletions src/health_server.rs
Original file line number Diff line number Diff line change
@@ -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<HealthAggregator>;

Expand All @@ -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);
Expand All @@ -34,26 +28,30 @@ pub async fn start_health_server(health: SharedHealth, port: u16) {
}
}

async fn health_check(State(health): State<SharedHealth>) -> Result<Json<serde_json::Value>, StatusCode> {
async fn health_check(
State(health): State<SharedHealth>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let health_data = health.to_json();
let is_healthy = health.is_healthy();

if is_healthy {
Ok(Json(health_data))
} else {
Err(StatusCode::SERVICE_UNAVAILABLE)
}
}

async fn test_discord_connectivity(State(_health): State<SharedHealth>) -> Result<Json<serde_json::Value>, StatusCode> {
async fn test_discord_connectivity(
State(_health): State<SharedHealth>,
) -> Result<Json<serde_json::Value>, 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")
Expand All @@ -63,9 +61,9 @@ async fn test_discord_connectivity(State(_health): State<SharedHealth>) -> 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()
Expand All @@ -74,7 +72,7 @@ async fn test_discord_connectivity(State(_health): State<SharedHealth>) -> Resul
.as_secs(),
"health_data": health_data
});

if test_result {
Ok(Json(response))
} else {
Expand Down
Loading
Loading