Rework BlueMap claiming and expand ranks guilds chat and warps

This commit is contained in:
scoped committed 2026-09-30 18:09:18 -04:00
1 parent 434ed5338a
commit 6a2944a88e
68 files changed
+1453 -2500

No files matched your search

+36 -7
View File
@@ -2,7 +2,8 @@
//! sync, an invite link for launchers, and optional webhook announcements.
//!
//! Everything here is opt-in and needs the bot token and server ID saved in
//! Settings. Sync only ever *adds* membership, in the direction the admin picks.
//! Settings. Group mappings only add membership in the chosen direction;
//! managed level-title roles are replaced when a player's rank changes.
use crate::auth::{AdminUser, AuthUser};
use crate::error::{AppError, AppResult};
@@ -22,6 +23,8 @@ const API: &str = "https://discord.com/api/v10";
pub struct DiscordSettings {
/// `off`, `discord_to_panel`, `panel_to_discord` or `both`.
pub role_sync: String,
/// Discord role used until the first mapped rank title is earned.
pub base_role_id: String,
/// Shown to players in the launcher.
pub invite_url: String,
/// Announcements go here. Empty turns them off.
@@ -33,7 +36,7 @@ pub struct DiscordSettings {
impl Default for DiscordSettings {
fn default() -> Self {
Self { role_sync: "off".into(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
Self { role_sync: "off".into(), base_role_id: String::new(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
}
}
@@ -54,7 +57,7 @@ pub fn valid_invite(url: &str) -> bool {
fn view(s: &DiscordSettings) -> Value {
json!({
"role_sync": s.role_sync, "invite_url": s.invite_url,
"role_sync": s.role_sync, "base_role_id": s.base_role_id, "invite_url": s.invite_url,
"webhook_set": !s.webhook_url.is_empty(),
"notify_achievements": s.notify_achievements, "notify_guilds": s.notify_guilds, "notify_members": s.notify_members,
})
@@ -72,6 +75,8 @@ pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppRes
#[derive(Deserialize)]
pub struct SettingsInput {
role_sync: String,
#[serde(default)]
base_role_id: String,
invite_url: String,
/// Omitted or empty keeps the saved webhook; `"-"` clears it.
#[serde(default)]
@@ -85,6 +90,9 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
if !["off", "discord_to_panel", "panel_to_discord", "both"].contains(&i.role_sync.as_str()) {
return Err(AppError::bad_request("unknown role sync mode"));
}
if !i.base_role_id.is_empty() && (!i.base_role_id.bytes().all(|b| b.is_ascii_digit()) || i.base_role_id.len() > 24) {
return Err(AppError::bad_request("Base Discord role ID must contain digits only"));
}
let old = load(&state).await?;
let webhook = match i.webhook_url.trim() {
"" => old.webhook_url.clone(),
@@ -100,6 +108,7 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
}
let next = DiscordSettings {
role_sync: i.role_sync,
base_role_id: i.base_role_id,
invite_url: invite,
webhook_url: webhook,
notify_achievements: i.notify_achievements,
@@ -197,6 +206,7 @@ pub struct SyncReport {
pub not_in_server: u32,
pub groups_added: u32,
pub roles_added: u32,
pub roles_removed: u32,
pub failed: u32,
}
@@ -212,11 +222,13 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first"));
}
let mapping: Vec<(i64, String)> = sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
if mapping.is_empty() {
let rank_roles: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.discord_role_id') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.discord_role_id') IS NOT NULL AND json_extract(reward_data, '$.discord_role_id') <> '' ORDER BY level_req DESC")
.fetch_all(&state.db).await?;
if mapping.is_empty() && rank_roles.is_empty() && settings.base_role_id.is_empty() {
return Ok(report);
}
let linked: Vec<(i64, String)> = sqlx::query_as("SELECT user_id, provider_id FROM account_connections WHERE provider = 'discord'").fetch_all(&state.db).await?;
for (user_id, discord_id) in linked {
let linked: Vec<(i64, String, String)> = sqlx::query_as("SELECT c.user_id, c.provider_id, u.uuid FROM account_connections c JOIN users u ON u.id=c.user_id WHERE c.provider = 'discord'").fetch_all(&state.db).await?;
for (user_id, discord_id, uuid) in linked {
report.checked += 1;
let resp = bot_request(state, reqwest::Method::GET, format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id), &c.discord_bot_token).send().await;
let resp = match resp {
@@ -235,6 +247,7 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
report.failed += 1;
break;
}
if !resp.status().is_success() { report.failed += 1; continue; }
let Ok(member) = resp.json::<Value>().await else {
report.failed += 1;
continue;
@@ -242,11 +255,17 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
let roles: Vec<String> = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
let groups: Vec<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
let p = plan(&settings.role_sync, &mapping, &roles, &groups);
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&state.db).await?.unwrap_or(0);
let level = crate::progression::level_from_xp(xp).0;
let chosen = rank_roles.iter().find(|(required,_)| *required <= level).map(|(_,role)| role.clone())
.or_else(|| (!settings.base_role_id.is_empty()).then(|| settings.base_role_id.clone()));
for g in p.add_groups {
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(g).execute(&state.db).await?;
report.groups_added += 1;
}
for role in p.add_roles {
// A level title takes precedence over mapped panel groups on Discord.
let wanted_roles = if let Some(role) = &chosen { if roles.contains(role) { Vec::new() } else { vec![role.clone()] } } else { p.add_roles };
for role in wanted_roles {
let done = bot_request(state, reqwest::Method::PUT, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token)
.header("Content-Length", "0")
.send()
@@ -257,6 +276,16 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
if let Some(chosen) = chosen {
let mut managed: Vec<String> = rank_roles.iter().map(|(_, role)| role.clone()).collect();
if !settings.base_role_id.is_empty() { managed.push(settings.base_role_id.clone()); }
managed.sort(); managed.dedup();
for role in managed.into_iter().filter(|r| r != &chosen && roles.contains(r)) {
let done = bot_request(state, reqwest::Method::DELETE, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token).send().await;
match done { Ok(r) if r.status().is_success() => report.roles_removed += 1, _ => report.failed += 1 }
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
tokio::time::sleep(Duration::from_millis(120)).await;
}
Ok(report)