Added endpoints for level and leaderboard
This commit is contained in:
502
src/api.rs
502
src/api.rs
@@ -38,17 +38,94 @@ pub struct LeaderboardMember {
|
|||||||
pub struct Role {
|
pub struct Role {
|
||||||
pub role_id: String,
|
pub role_id: String,
|
||||||
pub role_name: String,
|
pub role_name: String,
|
||||||
|
pub color: u32,
|
||||||
|
pub position: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
pub struct LevelRole {
|
pub struct TrackLevelRole {
|
||||||
pub id: String,
|
pub role_id: u64,
|
||||||
pub name: String,
|
pub level: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for TrackLevelRole {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct TrackLevelRoleVisitor;
|
||||||
|
|
||||||
|
impl<'de> serde::de::Visitor<'de> for TrackLevelRoleVisitor {
|
||||||
|
type Value = TrackLevelRole;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("a u64 (legacy) or a TrackLevelRole struct")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(TrackLevelRole {
|
||||||
|
role_id: value,
|
||||||
|
level: 0, // Default level for legacy data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(TrackLevelRole {
|
||||||
|
role_id: value as u64,
|
||||||
|
level: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut role_id = None;
|
||||||
|
let mut level = None;
|
||||||
|
|
||||||
|
while let Some(key) = map.next_key::<String>()? {
|
||||||
|
match key.as_str() {
|
||||||
|
"role_id" => {
|
||||||
|
if role_id.is_some() {
|
||||||
|
return Err(serde::de::Error::duplicate_field("role_id"));
|
||||||
|
}
|
||||||
|
role_id = Some(map.next_value()?);
|
||||||
|
}
|
||||||
|
"level" => {
|
||||||
|
if level.is_some() {
|
||||||
|
return Err(serde::de::Error::duplicate_field("level"));
|
||||||
|
}
|
||||||
|
level = Some(map.next_value()?);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let role_id = role_id.ok_or_else(|| serde::de::Error::missing_field("role_id"))?;
|
||||||
|
let level = level.ok_or_else(|| serde::de::Error::missing_field("level"))?;
|
||||||
|
|
||||||
|
Ok(TrackLevelRole { role_id, level })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(TrackLevelRoleVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct GuildRecord {
|
struct GuildRecord {
|
||||||
level_role_stack: Option<std::collections::HashMap<String, Vec<u64>>>,
|
level_role_stack: Option<std::collections::HashMap<String, Vec<TrackLevelRole>>>,
|
||||||
|
level_up_role_mapper: Option<std::collections::HashMap<String, u64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
@@ -275,6 +352,8 @@ async fn get_roles(
|
|||||||
.map(|(role_id, role)| Role {
|
.map(|(role_id, role)| Role {
|
||||||
role_id: role_id.to_string(),
|
role_id: role_id.to_string(),
|
||||||
role_name: role.name.clone(),
|
role_name: role.name.clone(),
|
||||||
|
color: role.colour.0,
|
||||||
|
position: role.position,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -288,14 +367,13 @@ async fn get_level_tracks(
|
|||||||
data: web::Data<ApiState>,
|
data: web::Data<ApiState>,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
let guild_id_value = guild_id.into_inner();
|
let guild_id_value = guild_id.into_inner();
|
||||||
info!("Processing /api/{}/level/track request", guild_id_value);
|
info!("Processing GET /api/{}/level/track request", guild_id_value);
|
||||||
|
|
||||||
// Check if bot is in the guild
|
// Check if bot is in the guild
|
||||||
let guild = data.cache.guild(guild_id_value)
|
if data.cache.guild(guild_id_value).is_none() {
|
||||||
.ok_or_else(|| {
|
warn!("Bot is not in guild {}", guild_id_value);
|
||||||
warn!("Bot is not in guild {}", guild_id_value);
|
return Err(error::ErrorNotFound("Bot is not in this guild"));
|
||||||
error::ErrorNotFound("Bot is not in this guild")
|
}
|
||||||
})?;
|
|
||||||
|
|
||||||
// Query the guild record from database
|
// Query the guild record from database
|
||||||
let record: Option<GuildRecord> = data.db.select(("guilds", guild_id_value.to_string())).await
|
let record: Option<GuildRecord> = data.db.select(("guilds", guild_id_value.to_string())).await
|
||||||
@@ -304,24 +382,14 @@ async fn get_level_tracks(
|
|||||||
error::ErrorInternalServerError("Database query failed")
|
error::ErrorInternalServerError("Database query failed")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut result: std::collections::HashMap<String, Vec<LevelRole>> = std::collections::HashMap::new();
|
let mut result: std::collections::HashMap<String, Vec<TrackLevelRole>> = std::collections::HashMap::new();
|
||||||
|
|
||||||
if let Some(record) = record {
|
if let Some(record) = record {
|
||||||
if let Some(level_role_stack) = record.level_role_stack {
|
if let Some(level_role_stack) = record.level_role_stack {
|
||||||
for (track_name, role_ids) in level_role_stack {
|
for (track_name, mut track_roles) in level_role_stack {
|
||||||
let mut roles: Vec<LevelRole> = Vec::new();
|
// Sort by level ascending
|
||||||
for role_id in role_ids {
|
track_roles.sort_by_key(|r| r.level);
|
||||||
let role_id_obj = serenity::all::RoleId::new(role_id);
|
result.insert(track_name, track_roles);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,6 +398,381 @@ async fn get_level_tracks(
|
|||||||
Ok(HttpResponse::Ok().json(result))
|
Ok(HttpResponse::Ok().json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateLevelTrackRequest {
|
||||||
|
pub track_name: String,
|
||||||
|
pub roles: Vec<TrackLevelRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::post("/api/{guild_id}/level/track")]
|
||||||
|
async fn create_level_track(
|
||||||
|
guild_id: web::Path<u64>,
|
||||||
|
body: web::Json<CreateLevelTrackRequest>,
|
||||||
|
data: web::Data<ApiState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let guild_id_value = guild_id.into_inner();
|
||||||
|
info!("Processing POST /api/{}/level/track 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing record
|
||||||
|
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 level_role_stack = record
|
||||||
|
.and_then(|r| r.level_role_stack)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Check if track already exists
|
||||||
|
if level_role_stack.contains_key(&body.track_name) {
|
||||||
|
return Err(error::ErrorConflict("Track already exists. Use PATCH to update."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new track
|
||||||
|
level_role_stack.insert(body.track_name.clone(), body.roles.clone());
|
||||||
|
|
||||||
|
// Update the database
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct GuildUpdate {
|
||||||
|
level_role_stack: std::collections::HashMap<String, Vec<TrackLevelRole>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: Option<GuildUpdate> = data.db
|
||||||
|
.upsert(("guilds", guild_id_value.to_string()))
|
||||||
|
.merge(GuildUpdate { level_role_stack })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Database update error: {}", e);
|
||||||
|
error::ErrorInternalServerError("Database update failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Created level track '{}' for guild {}", body.track_name, guild_id_value);
|
||||||
|
Ok(HttpResponse::Created().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": format!("Level track '{}' created successfully", body.track_name)
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateLevelTrackRequest {
|
||||||
|
pub track_name: String,
|
||||||
|
pub roles: Vec<TrackLevelRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::patch("/api/{guild_id}/level/track")]
|
||||||
|
async fn update_level_track(
|
||||||
|
guild_id: web::Path<u64>,
|
||||||
|
body: web::Json<UpdateLevelTrackRequest>,
|
||||||
|
data: web::Data<ApiState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let guild_id_value = guild_id.into_inner();
|
||||||
|
info!("Processing PATCH /api/{}/level/track 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing record
|
||||||
|
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 level_role_stack = record
|
||||||
|
.and_then(|r| r.level_role_stack)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Update track (create if doesn't exist)
|
||||||
|
level_role_stack.insert(body.track_name.clone(), body.roles.clone());
|
||||||
|
|
||||||
|
// Update the database
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct GuildUpdate {
|
||||||
|
level_role_stack: std::collections::HashMap<String, Vec<TrackLevelRole>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: Option<GuildUpdate> = data.db
|
||||||
|
.upsert(("guilds", guild_id_value.to_string()))
|
||||||
|
.merge(GuildUpdate { level_role_stack })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Database update error: {}", e);
|
||||||
|
error::ErrorInternalServerError("Database update failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Updated level track '{}' for guild {}", body.track_name, guild_id_value);
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": format!("Level track '{}' updated successfully", body.track_name)
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteLevelTrackRequest {
|
||||||
|
pub track_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::delete("/api/{guild_id}/level/track")]
|
||||||
|
async fn delete_level_track(
|
||||||
|
guild_id: web::Path<u64>,
|
||||||
|
body: web::Json<DeleteLevelTrackRequest>,
|
||||||
|
data: web::Data<ApiState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let guild_id_value = guild_id.into_inner();
|
||||||
|
info!("Processing DELETE /api/{}/level/track 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing record
|
||||||
|
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 level_role_stack = record
|
||||||
|
.and_then(|r| r.level_role_stack)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Check if track exists
|
||||||
|
if !level_role_stack.contains_key(&body.track_name) {
|
||||||
|
return Err(error::ErrorNotFound("Track not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove track
|
||||||
|
level_role_stack.remove(&body.track_name);
|
||||||
|
|
||||||
|
// Update the database
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct GuildUpdate {
|
||||||
|
level_role_stack: std::collections::HashMap<String, Vec<TrackLevelRole>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: Option<GuildUpdate> = data.db
|
||||||
|
.upsert(("guilds", guild_id_value.to_string()))
|
||||||
|
.merge(GuildUpdate { level_role_stack })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Database update error: {}", e);
|
||||||
|
error::ErrorInternalServerError("Database update failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Deleted level track '{}' for guild {}", body.track_name, guild_id_value);
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": format!("Level track '{}' deleted successfully", body.track_name)
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct LevelBridgerEntry {
|
||||||
|
pub in_role_id: String,
|
||||||
|
pub out_role_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/{guild_id}/level_bridger")]
|
||||||
|
async fn get_level_bridger(
|
||||||
|
guild_id: web::Path<u64>,
|
||||||
|
data: web::Data<ApiState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let guild_id_value = guild_id.into_inner();
|
||||||
|
info!("Processing GET /api/{}/level_bridger request", guild_id_value);
|
||||||
|
|
||||||
|
// Check if bot is in the guild
|
||||||
|
if data.cache.guild(guild_id_value).is_none() {
|
||||||
|
return Err(error::ErrorNotFound("Bot is not in this guild"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mapper = record
|
||||||
|
.and_then(|r| r.level_up_role_mapper)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let result: Vec<LevelBridgerEntry> = mapper
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| LevelBridgerEntry {
|
||||||
|
in_role_id: k,
|
||||||
|
out_role_id: v.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/{guild_id}/level_bridger")]
|
||||||
|
async fn create_level_bridger(
|
||||||
|
guild_id: web::Path<u64>,
|
||||||
|
body: web::Json<LevelBridgerEntry>,
|
||||||
|
data: web::Data<ApiState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let guild_id_value = guild_id.into_inner();
|
||||||
|
info!("Processing POST /api/{}/level_bridger request", guild_id_value);
|
||||||
|
|
||||||
|
if data.cache.guild(guild_id_value).is_none() {
|
||||||
|
return Err(error::ErrorNotFound("Bot is not in this guild"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mapper = record
|
||||||
|
.and_then(|r| r.level_up_role_mapper)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if mapper.contains_key(&body.in_role_id) {
|
||||||
|
return Err(error::ErrorConflict("Bridge already exists for this role. Use PUT to update."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate roles exist? Optional but good practice. For now simpler is better unless requested.
|
||||||
|
|
||||||
|
let out_role_u64 = body.out_role_id.parse::<u64>().map_err(|_| error::ErrorBadRequest("Invalid out_role_id"))?;
|
||||||
|
mapper.insert(body.in_role_id.clone(), out_role_u64);
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct GuildUpdate {
|
||||||
|
level_up_role_mapper: std::collections::HashMap<String, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: Option<GuildUpdate> = data.db
|
||||||
|
.upsert(("guilds", guild_id_value.to_string()))
|
||||||
|
.merge(GuildUpdate { level_up_role_mapper: mapper })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Database update error: {}", e);
|
||||||
|
error::ErrorInternalServerError("Database update failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Created().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Level bridge created successfully"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::put("/api/{guild_id}/level_bridger")]
|
||||||
|
async fn update_level_bridger(
|
||||||
|
guild_id: web::Path<u64>,
|
||||||
|
body: web::Json<LevelBridgerEntry>,
|
||||||
|
data: web::Data<ApiState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let guild_id_value = guild_id.into_inner();
|
||||||
|
info!("Processing PUT /api/{}/level_bridger request", guild_id_value);
|
||||||
|
|
||||||
|
if data.cache.guild(guild_id_value).is_none() {
|
||||||
|
return Err(error::ErrorNotFound("Bot is not in this guild"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mapper = record
|
||||||
|
.and_then(|r| r.level_up_role_mapper)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Resetting or creating if not exists? User said PUT endpoints. Conventionally PUT replaces.
|
||||||
|
// If it doesn't exist, we can create it too (upsert).
|
||||||
|
|
||||||
|
let out_role_u64 = body.out_role_id.parse::<u64>().map_err(|_| error::ErrorBadRequest("Invalid out_role_id"))?;
|
||||||
|
mapper.insert(body.in_role_id.clone(), out_role_u64);
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct GuildUpdate {
|
||||||
|
level_up_role_mapper: std::collections::HashMap<String, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: Option<GuildUpdate> = data.db
|
||||||
|
.upsert(("guilds", guild_id_value.to_string()))
|
||||||
|
.merge(GuildUpdate { level_up_role_mapper: mapper })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Database update error: {}", e);
|
||||||
|
error::ErrorInternalServerError("Database update failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Level bridge updated successfully"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteLevelBridgerRequest {
|
||||||
|
pub in_role_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::delete("/api/{guild_id}/level_bridger")]
|
||||||
|
async fn delete_level_bridger(
|
||||||
|
guild_id: web::Path<u64>,
|
||||||
|
body: web::Json<DeleteLevelBridgerRequest>,
|
||||||
|
data: web::Data<ApiState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
let guild_id_value = guild_id.into_inner();
|
||||||
|
info!("Processing DELETE /api/{}/level_bridger request", guild_id_value);
|
||||||
|
|
||||||
|
if data.cache.guild(guild_id_value).is_none() {
|
||||||
|
return Err(error::ErrorNotFound("Bot is not in this guild"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mapper = record
|
||||||
|
.and_then(|r| r.level_up_role_mapper)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !mapper.contains_key(&body.in_role_id) {
|
||||||
|
return Err(error::ErrorNotFound("Bridge not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
mapper.remove(&body.in_role_id);
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct GuildUpdate {
|
||||||
|
level_up_role_mapper: std::collections::HashMap<String, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: Option<GuildUpdate> = data.db
|
||||||
|
.upsert(("guilds", guild_id_value.to_string()))
|
||||||
|
.merge(GuildUpdate { level_up_role_mapper: mapper })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Database update error: {}", e);
|
||||||
|
error::ErrorInternalServerError("Database update failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Level bridge deleted successfully"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn start_api_server(
|
pub async fn start_api_server(
|
||||||
cache: Arc<Cache>,
|
cache: Arc<Cache>,
|
||||||
db: Surreal<Client>,
|
db: Surreal<Client>,
|
||||||
@@ -348,6 +791,13 @@ pub async fn start_api_server(
|
|||||||
.service(get_leaderboard)
|
.service(get_leaderboard)
|
||||||
.service(get_roles)
|
.service(get_roles)
|
||||||
.service(get_level_tracks)
|
.service(get_level_tracks)
|
||||||
|
.service(create_level_track)
|
||||||
|
.service(update_level_track)
|
||||||
|
.service(delete_level_track)
|
||||||
|
.service(get_level_bridger)
|
||||||
|
.service(create_level_bridger)
|
||||||
|
.service(update_level_bridger)
|
||||||
|
.service(delete_level_bridger)
|
||||||
})
|
})
|
||||||
.bind(("0.0.0.0", port))?
|
.bind(("0.0.0.0", port))?
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use poise::serenity_prelude as serenity;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serenity::Mentionable;
|
use serenity::Mentionable;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Cursor;
|
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -52,9 +51,88 @@ pub struct UserLevel {
|
|||||||
pub last_message: chrono::DateTime<chrono::Utc>,
|
pub last_message: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Role with level requirement - used for level track system
|
||||||
|
#[derive(serde::Serialize, Clone, Debug)]
|
||||||
|
pub struct TrackLevelRole {
|
||||||
|
pub role_id: u64,
|
||||||
|
pub level: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for TrackLevelRole {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct TrackLevelRoleVisitor;
|
||||||
|
|
||||||
|
impl<'de> serde::de::Visitor<'de> for TrackLevelRoleVisitor {
|
||||||
|
type Value = TrackLevelRole;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("a u64 (legacy) or a TrackLevelRole struct")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(TrackLevelRole {
|
||||||
|
role_id: value,
|
||||||
|
level: 0, // Default level for legacy data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(TrackLevelRole {
|
||||||
|
role_id: value as u64,
|
||||||
|
level: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut role_id = None;
|
||||||
|
let mut level = None;
|
||||||
|
|
||||||
|
while let Some(key) = map.next_key::<String>()? {
|
||||||
|
match key.as_str() {
|
||||||
|
"role_id" => {
|
||||||
|
if role_id.is_some() {
|
||||||
|
return Err(serde::de::Error::duplicate_field("role_id"));
|
||||||
|
}
|
||||||
|
role_id = Some(map.next_value()?);
|
||||||
|
}
|
||||||
|
"level" => {
|
||||||
|
if level.is_some() {
|
||||||
|
return Err(serde::de::Error::duplicate_field("level"));
|
||||||
|
}
|
||||||
|
level = Some(map.next_value()?);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let role_id = role_id.ok_or_else(|| serde::de::Error::missing_field("role_id"))?;
|
||||||
|
let level = level.ok_or_else(|| serde::de::Error::missing_field("level"))?;
|
||||||
|
|
||||||
|
Ok(TrackLevelRole { role_id, level })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(TrackLevelRoleVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct GuildRecord {
|
struct GuildRecord {
|
||||||
level_role_stack: Option<HashMap<String, Vec<u64>>>,
|
level_role_stack: Option<HashMap<String, Vec<TrackLevelRole>>>,
|
||||||
levelup_channel: Option<u64>,
|
levelup_channel: Option<u64>,
|
||||||
levelup_message: Option<String>,
|
levelup_message: Option<String>,
|
||||||
level_up_role_mapper: Option<HashMap<String, u64>>,
|
level_up_role_mapper: Option<HashMap<String, u64>>,
|
||||||
@@ -67,122 +145,6 @@ struct LeaderboardEntry {
|
|||||||
level: u64,
|
level: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[poise::command(
|
|
||||||
slash_command,
|
|
||||||
prefix_command,
|
|
||||||
guild_only,
|
|
||||||
default_member_permissions = "MANAGE_GUILD",
|
|
||||||
required_permissions = "MANAGE_GUILD"
|
|
||||||
)]
|
|
||||||
pub async fn set_level_roles(
|
|
||||||
ctx: Context<'_>,
|
|
||||||
#[description = "Name of the level role stack"] name: String,
|
|
||||||
#[description = "Comma-separated list of role mentions (e.g. @Role1, @Role2, ...)"]
|
|
||||||
roles: String,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let guild_id = ctx
|
|
||||||
.guild_id()
|
|
||||||
.ok_or_else(|| Error::msg("Guild only command"))?;
|
|
||||||
|
|
||||||
let mut role_ids = Vec::new();
|
|
||||||
|
|
||||||
for part in roles.split(',') {
|
|
||||||
let part = part.trim();
|
|
||||||
if let Some(id_str) = part.strip_prefix("<@&").and_then(|s| s.strip_suffix(">")) {
|
|
||||||
if let Ok(id) = id_str.parse::<u64>() {
|
|
||||||
role_ids.push(serenity::RoleId::new(id));
|
|
||||||
}
|
|
||||||
} else if let Ok(id) = part.parse::<u64>() {
|
|
||||||
role_ids.push(serenity::RoleId::new(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if role_ids.is_empty() {
|
|
||||||
ctx.say("No valid roles found. Please use role mentions (e.g. @Role, @Role2, ...).")
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let role_count = role_ids.len();
|
|
||||||
|
|
||||||
let db = &ctx.data().db;
|
|
||||||
|
|
||||||
let role_ids_u64: Vec<u64> = role_ids.iter().map(|r| r.get()).collect();
|
|
||||||
|
|
||||||
tracing::info!("Updating level_role_stack for guild {}", guild_id);
|
|
||||||
|
|
||||||
let updated: Option<serde::de::IgnoredAny> = db
|
|
||||||
.update(("guilds", guild_id.to_string()))
|
|
||||||
.merge(serde_json::json!({
|
|
||||||
"level_role_stack": {
|
|
||||||
name.clone(): role_ids_u64
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if updated.is_none() {
|
|
||||||
tracing::info!("Guild record not found, creating new record");
|
|
||||||
// Record didn't exist, create it
|
|
||||||
let _: Option<serde::de::IgnoredAny> = db
|
|
||||||
.create(("guilds", guild_id.to_string()))
|
|
||||||
.content(serde_json::json!({
|
|
||||||
"level_role_stack": {
|
|
||||||
name.clone(): role_ids_u64
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.say(format!(
|
|
||||||
"Level role stack `{}` updated successfully with {} roles.",
|
|
||||||
name, role_count
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[poise::command(slash_command, prefix_command, guild_only)]
|
|
||||||
pub async fn get_level_roles(ctx: Context<'_>) -> Result<(), Error> {
|
|
||||||
let guild_id = ctx
|
|
||||||
.guild_id()
|
|
||||||
.ok_or_else(|| Error::msg("Guild only command"))?;
|
|
||||||
|
|
||||||
let db = &ctx.data().db;
|
|
||||||
|
|
||||||
let record: Option<GuildRecord> = db.select(("guilds", guild_id.to_string())).await?;
|
|
||||||
|
|
||||||
let mut response = String::new();
|
|
||||||
|
|
||||||
if let Some(record) = record {
|
|
||||||
if let Some(stack) = record.level_role_stack {
|
|
||||||
if stack.is_empty() {
|
|
||||||
response = "No level role stacks found.".to_string();
|
|
||||||
} else {
|
|
||||||
for (name, roles) in stack {
|
|
||||||
response.push_str(&format!("**{}:** ", name));
|
|
||||||
let role_mentions: Vec<String> =
|
|
||||||
roles.iter().map(|id| format!("<@&{}>", id)).collect();
|
|
||||||
response.push_str(&role_mentions.join(", "));
|
|
||||||
response.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
response = "No level role stacks found.".to_string();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
response = "No configuration found for this guild.".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.send(
|
|
||||||
poise::CreateReply::default()
|
|
||||||
.content(response)
|
|
||||||
.allowed_mentions(serenity::CreateAllowedMentions::new().empty_roles()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
slash_command,
|
slash_command,
|
||||||
@@ -251,40 +213,7 @@ pub async fn set_levelup_message(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[poise::command(
|
|
||||||
slash_command,
|
|
||||||
prefix_command,
|
|
||||||
guild_only,
|
|
||||||
default_member_permissions = "MANAGE_GUILD",
|
|
||||||
required_permissions = "MANAGE_GUILD"
|
|
||||||
)]
|
|
||||||
pub async fn levelup_role_bridger(
|
|
||||||
ctx: Context<'_>,
|
|
||||||
#[description = "The role to bridge from"] in_role: serenity::Role,
|
|
||||||
#[description = "The role to bridge to"] out_role: serenity::Role,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let guild_id = ctx
|
|
||||||
.guild_id()
|
|
||||||
.ok_or_else(|| Error::msg("Guild only command"))?;
|
|
||||||
let db = &ctx.data().db;
|
|
||||||
|
|
||||||
let _: Option<serde::de::IgnoredAny> = db
|
|
||||||
.update(("guilds", guild_id.to_string()))
|
|
||||||
.merge(serde_json::json!({
|
|
||||||
"level_up_role_mapper": {
|
|
||||||
in_role.id.to_string(): out_role.id.get()
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
ctx.say(format!(
|
|
||||||
"Role bridge created: {} -> {}",
|
|
||||||
in_role.name, out_role.name
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn process_message(
|
pub async fn process_message(
|
||||||
ctx: &serenity::Context,
|
ctx: &serenity::Context,
|
||||||
@@ -360,67 +289,42 @@ pub async fn process_message(
|
|||||||
|
|
||||||
if let Some(record) = &guild_record {
|
if let Some(record) = &guild_record {
|
||||||
if let Some(stack) = &record.level_role_stack {
|
if let Some(stack) = &record.level_role_stack {
|
||||||
if let Some(roles) = stack.get(&level_data.track) {
|
tracing::info!("Checking roles for track: {}", level_data.track);
|
||||||
// Check all roles in the stack for level requirements
|
if let Some(track_roles) = stack.get(&level_data.track) {
|
||||||
// We need to fetch role names to check for patterns like "(min - max)"
|
tracing::info!("Found {} roles in track {}", track_roles.len(), level_data.track);
|
||||||
// Try to get from cache first
|
// Find the role that matches the current level
|
||||||
let guild = guild_id.to_guild_cached(&ctx.cache).map(|g| g.clone());
|
for track_role in track_roles {
|
||||||
|
tracing::info!("Checking role {} for level {}", track_role.role_id, track_role.level);
|
||||||
// Regex to match "(min - max)"
|
if level_data.level == track_role.level {
|
||||||
let re = regex::Regex::new(r"\((\d+)\s*-\s*(\d+)\)").unwrap();
|
let role_id = serenity::RoleId::new(track_role.role_id);
|
||||||
|
tracing::info!(
|
||||||
for role_id_u64 in roles {
|
"Assigning role {} at level {} to user {}",
|
||||||
let role_id = serenity::RoleId::new(*role_id_u64);
|
role_id,
|
||||||
|
track_role.level,
|
||||||
// Try to find the role name
|
user_id
|
||||||
let role_name: Option<String> =
|
);
|
||||||
if let Some(guild) = &guild {
|
if let Err(e) = ctx
|
||||||
guild.roles.get(&role_id).map(|r| r.name.clone())
|
.http
|
||||||
} else {
|
.add_member_role(
|
||||||
// Fallback to HTTP if not in cache (expensive but safe)
|
guild_id,
|
||||||
ctx.http.get_guild_roles(guild_id).await.ok().and_then(
|
user_id,
|
||||||
|roles| {
|
role_id,
|
||||||
roles
|
Some("Level up"),
|
||||||
.iter()
|
|
||||||
.find(|r| r.id == role_id)
|
|
||||||
.map(|r| r.name.clone())
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
};
|
.await
|
||||||
|
{
|
||||||
if let Some(name) = role_name {
|
tracing::error!("Failed to add role: {}", e);
|
||||||
if let Some(caps) = re.captures(&name) {
|
} else {
|
||||||
if let (Ok(min), Ok(_max)) =
|
role_assigned_id = Some(role_id);
|
||||||
(caps[1].parse::<u64>(), caps[2].parse::<u64>())
|
|
||||||
{
|
|
||||||
// Check if we just hit the min level
|
|
||||||
if level_data.level == min {
|
|
||||||
tracing::info!(
|
|
||||||
"Assigning role {} ({}) to user {}",
|
|
||||||
role_id,
|
|
||||||
name,
|
|
||||||
user_id
|
|
||||||
);
|
|
||||||
if let Err(e) = ctx
|
|
||||||
.http
|
|
||||||
.add_member_role(
|
|
||||||
guild_id,
|
|
||||||
user_id,
|
|
||||||
role_id,
|
|
||||||
Some("Level up"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!("Failed to add role: {}", e);
|
|
||||||
} else {
|
|
||||||
role_assigned_id = Some(role_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
break; // Only assign one role per level up
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Track {} not found in guild {}", level_data.track, guild_id);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No level role stack configured for guild {}", guild_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,11 +406,12 @@ pub async fn process_message(
|
|||||||
Err(_) => return Ok(()),
|
Err(_) => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
for (track_name, roles) in stack {
|
for (track_name, track_roles) in stack {
|
||||||
if let Some(first_role_id) = roles.first() {
|
// Find the starting role (the one with the lowest level, typically 0)
|
||||||
|
if let Some(starting_role) = track_roles.iter().min_by_key(|r| r.level) {
|
||||||
if member
|
if member
|
||||||
.roles
|
.roles
|
||||||
.contains(&serenity::RoleId::new(*first_role_id))
|
.contains(&serenity::RoleId::new(starting_role.role_id))
|
||||||
{
|
{
|
||||||
// User has the starting role for this track
|
// User has the starting role for this track
|
||||||
let new_level = UserLevel {
|
let new_level = UserLevel {
|
||||||
|
|||||||
@@ -89,12 +89,9 @@ async fn main() -> Result<(), Error> {
|
|||||||
.options(FrameworkOptions::<Data, Error> {
|
.options(FrameworkOptions::<Data, Error> {
|
||||||
owners,
|
owners,
|
||||||
commands: vec![
|
commands: vec![
|
||||||
commands::level::set_level_roles(),
|
|
||||||
commands::level::get_level_roles(),
|
|
||||||
commands::level::leaderboard(),
|
commands::level::leaderboard(),
|
||||||
commands::level::set_levelup_message_channel(),
|
commands::level::set_levelup_message_channel(),
|
||||||
commands::level::set_levelup_message(),
|
commands::level::set_levelup_message(),
|
||||||
commands::level::levelup_role_bridger(),
|
|
||||||
commands::fun::say(),
|
commands::fun::say(),
|
||||||
commands::fun::urban(),
|
commands::fun::urban(),
|
||||||
commands::fun::ai_chat(),
|
commands::fun::ai_chat(),
|
||||||
|
|||||||
Reference in New Issue
Block a user