added optimization for leaderboard
This commit is contained in:
355
src/api.rs
Normal file
355
src/api.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use actix_web::{web, App, HttpServer, HttpResponse, post, get, error, body::EitherBody, dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}};
|
||||
use futures::future::LocalBoxFuture;
|
||||
use futures::FutureExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serenity::cache::Cache;
|
||||
use std::sync::Arc;
|
||||
use surrealdb::Surreal;
|
||||
use surrealdb::engine::remote::ws::Client;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IsBotThereRequest {
|
||||
pub guild_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IsBotThereResponse {
|
||||
pub results: Vec<bool>,
|
||||
}
|
||||
|
||||
pub struct ApiState {
|
||||
pub cache: Arc<Cache>,
|
||||
pub db: Surreal<Client>,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LeaderboardMember {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub avatar: String,
|
||||
pub level: u64,
|
||||
pub xp: u64,
|
||||
pub rank: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Role {
|
||||
pub role_id: String,
|
||||
pub role_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LevelRole {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuildRecord {
|
||||
level_role_stack: Option<std::collections::HashMap<String, Vec<u64>>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct LeaderboardEntry {
|
||||
id: surrealdb::sql::Thing,
|
||||
xp: u64,
|
||||
level: u64,
|
||||
}
|
||||
|
||||
/// API Key Authentication Middleware
|
||||
pub struct ApiKeyMiddleware {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl ApiKeyMiddleware {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
ApiKeyMiddleware { api_key }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for ApiKeyMiddleware
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = error::Error>,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = error::Error;
|
||||
type InitError = ();
|
||||
type Transform = ApiKeyMiddlewareService<S>;
|
||||
type Future = futures::future::Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
futures::future::ok(ApiKeyMiddlewareService {
|
||||
service,
|
||||
api_key: self.api_key.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiKeyMiddlewareService<S> {
|
||||
service: S,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for ApiKeyMiddlewareService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = error::Error>,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = error::Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let api_key = self.api_key.clone();
|
||||
let path = req.path().to_string();
|
||||
|
||||
// Check API key from header
|
||||
let header_key = req
|
||||
.headers()
|
||||
.get("X-API-Key")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if header_key.is_empty() || header_key != api_key {
|
||||
warn!("Unauthorized API request to {} - missing or invalid API key", path);
|
||||
return Box::pin(async move {
|
||||
Err(error::ErrorUnauthorized("Missing or invalid API key"))
|
||||
});
|
||||
}
|
||||
|
||||
info!("Authorized API request to {}", path);
|
||||
|
||||
Box::pin(
|
||||
self.service
|
||||
.call(req)
|
||||
.then(|res: Result<ServiceResponse<B>, error::Error>| async move {
|
||||
Ok(res?.map_into_left_body())
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/api/is_bot_there")]
|
||||
async fn is_bot_there(
|
||||
request_body: web::Json<IsBotThereRequest>,
|
||||
data: web::Data<ApiState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
info!(
|
||||
"Processing /api/is_bot_there request with {} guild IDs",
|
||||
request_body.guild_ids.len()
|
||||
);
|
||||
|
||||
let results: Vec<bool> = request_body
|
||||
.guild_ids
|
||||
.iter()
|
||||
.map(|guild_id| {
|
||||
let guild_exists = data.cache.guild(*guild_id).is_some();
|
||||
guild_exists
|
||||
})
|
||||
.collect();
|
||||
|
||||
let found_count = results.iter().filter(|&&b| b).count();
|
||||
info!(
|
||||
"Bot found in {}/{} requested guilds",
|
||||
found_count,
|
||||
results.len()
|
||||
);
|
||||
|
||||
Ok(HttpResponse::Ok().json(IsBotThereResponse { results }))
|
||||
}
|
||||
|
||||
#[get("/api/{guild_id}/leaderboard")]
|
||||
async fn get_leaderboard(
|
||||
guild_id: web::Path<u64>,
|
||||
data: web::Data<ApiState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let guild_id_value = guild_id.into_inner();
|
||||
info!("Processing /api/{}/leaderboard request", guild_id_value);
|
||||
|
||||
// Check if bot is in the guild
|
||||
if data.cache.guild(guild_id_value).is_none() {
|
||||
warn!("Bot is not in guild {}", guild_id_value);
|
||||
return Err(error::ErrorNotFound("Bot is not in this guild"));
|
||||
}
|
||||
|
||||
// Query all users for this guild, ordered by level and xp
|
||||
let sql = "SELECT * FROM levels WHERE string::starts_with(record::id(id), $prefix) ORDER BY level DESC, xp DESC";
|
||||
let prefix = format!("{}:", guild_id_value);
|
||||
|
||||
let mut response = data.db.query(sql).bind(("prefix", prefix)).await
|
||||
.map_err(|e| {
|
||||
warn!("Database query error: {}", e);
|
||||
error::ErrorInternalServerError("Database query failed")
|
||||
})?;
|
||||
|
||||
let entries: Vec<LeaderboardEntry> = response.take(0)
|
||||
.map_err(|e| {
|
||||
warn!("Failed to parse database response: {}", e);
|
||||
error::ErrorInternalServerError("Failed to parse database response")
|
||||
})?;
|
||||
|
||||
if entries.is_empty() {
|
||||
info!("No leaderboard data found for guild {}", guild_id_value);
|
||||
return Ok(HttpResponse::Ok().json(Vec::<LeaderboardMember>::new()));
|
||||
}
|
||||
|
||||
info!("Found {} members in leaderboard for guild {}", entries.len(), guild_id_value);
|
||||
|
||||
// Fetch user data for all entries
|
||||
let mut leaderboard: Vec<LeaderboardMember> = Vec::new();
|
||||
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
// Extract user id from Surreal Thing
|
||||
let id_value = &entry.id.id;
|
||||
let clean_id_str = match id_value {
|
||||
surrealdb::sql::Id::String(s) => s.as_str().to_string(),
|
||||
_ => entry.id.id.to_string(),
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = clean_id_str.split(':').collect();
|
||||
let user_id_str = parts.last().unwrap_or(&"0");
|
||||
let user_id_u64 = user_id_str.parse::<u64>().unwrap_or(0);
|
||||
|
||||
if user_id_u64 == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut username = String::from("Unknown User");
|
||||
let mut avatar_url = String::new();
|
||||
|
||||
let user_id = serenity::all::UserId::new(user_id_u64);
|
||||
|
||||
// Try to get member info from cache first
|
||||
if let Some(guild) = data.cache.guild(guild_id_value) {
|
||||
if let Some(member) = guild.members.get(&user_id) {
|
||||
username = member.display_name().to_string();
|
||||
avatar_url = member.user.face();
|
||||
}
|
||||
}
|
||||
|
||||
// If not in cache, we'll just use Unknown User
|
||||
if avatar_url.is_empty() {
|
||||
info!("User {} not in cache for guild {}", user_id_u64, guild_id_value);
|
||||
}
|
||||
|
||||
leaderboard.push(LeaderboardMember {
|
||||
user_id: user_id_u64.to_string(),
|
||||
username,
|
||||
avatar: avatar_url,
|
||||
level: entry.level,
|
||||
xp: entry.xp,
|
||||
rank: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
info!("Returning {} members in leaderboard", leaderboard.len());
|
||||
Ok(HttpResponse::Ok().json(leaderboard))
|
||||
}
|
||||
|
||||
#[get("/api/{guild_id}/roles")]
|
||||
async fn get_roles(
|
||||
guild_id: web::Path<u64>,
|
||||
data: web::Data<ApiState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let guild_id_value = guild_id.into_inner();
|
||||
info!("Processing /api/{}/roles request", guild_id_value);
|
||||
|
||||
// Check if bot is in the guild
|
||||
let guild = data.cache.guild(guild_id_value)
|
||||
.ok_or_else(|| {
|
||||
warn!("Bot is not in guild {}", guild_id_value);
|
||||
error::ErrorNotFound("Bot is not in this guild")
|
||||
})?;
|
||||
|
||||
// Get all roles from the guild
|
||||
let roles: Vec<Role> = guild.roles
|
||||
.iter()
|
||||
.map(|(role_id, role)| Role {
|
||||
role_id: role_id.to_string(),
|
||||
role_name: role.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!("Returning {} roles for guild {}", roles.len(), guild_id_value);
|
||||
Ok(HttpResponse::Ok().json(roles))
|
||||
}
|
||||
|
||||
#[get("/api/{guild_id}/level/track")]
|
||||
async fn get_level_tracks(
|
||||
guild_id: web::Path<u64>,
|
||||
data: web::Data<ApiState>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let guild_id_value = guild_id.into_inner();
|
||||
info!("Processing /api/{}/level/track request", guild_id_value);
|
||||
|
||||
// Check if bot is in the guild
|
||||
let guild = data.cache.guild(guild_id_value)
|
||||
.ok_or_else(|| {
|
||||
warn!("Bot is not in guild {}", guild_id_value);
|
||||
error::ErrorNotFound("Bot is not in this guild")
|
||||
})?;
|
||||
|
||||
// Query the guild record from database
|
||||
let record: Option<GuildRecord> = data.db.select(("guilds", guild_id_value.to_string())).await
|
||||
.map_err(|e| {
|
||||
warn!("Database query error: {}", e);
|
||||
error::ErrorInternalServerError("Database query failed")
|
||||
})?;
|
||||
|
||||
let mut result: std::collections::HashMap<String, Vec<LevelRole>> = std::collections::HashMap::new();
|
||||
|
||||
if let Some(record) = record {
|
||||
if let Some(level_role_stack) = record.level_role_stack {
|
||||
for (track_name, role_ids) in level_role_stack {
|
||||
let mut roles: Vec<LevelRole> = Vec::new();
|
||||
for role_id in role_ids {
|
||||
let role_id_obj = serenity::all::RoleId::new(role_id);
|
||||
let role_name = guild.roles.get(&role_id_obj)
|
||||
.map(|r| r.name.clone())
|
||||
.unwrap_or_else(|| "Unknown Role".to_string());
|
||||
|
||||
roles.push(LevelRole {
|
||||
id: role_id.to_string(),
|
||||
name: role_name,
|
||||
});
|
||||
}
|
||||
result.insert(track_name, roles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Returning {} level tracks for guild {}", result.len(), guild_id_value);
|
||||
Ok(HttpResponse::Ok().json(result))
|
||||
}
|
||||
|
||||
pub async fn start_api_server(
|
||||
cache: Arc<Cache>,
|
||||
db: Surreal<Client>,
|
||||
api_key: String,
|
||||
port: u16,
|
||||
) -> std::io::Result<()> {
|
||||
info!("Starting API server on port {}", port);
|
||||
|
||||
let state = web::Data::new(ApiState { cache, db, api_key: api_key.clone() });
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(state.clone())
|
||||
.wrap(ApiKeyMiddleware::new(api_key.clone()))
|
||||
.service(is_bot_there)
|
||||
.service(get_leaderboard)
|
||||
.service(get_roles)
|
||||
.service(get_level_tracks)
|
||||
})
|
||||
.bind(("0.0.0.0", port))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
Reference in New Issue
Block a user