//! Discord community features on top of account linking: role <-> group //! 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. use crate::auth::{AdminUser, AuthUser}; use crate::error::{AppError, AppResult}; use crate::routes::connections::ConnectionsSettings; use crate::state::AppState; use crate::store; use axum::extract::{Path, State}; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::time::Duration; const API: &str = "https://discord.com/api/v10"; #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] #[serde(default)] pub struct DiscordSettings { /// `off`, `discord_to_panel`, `panel_to_discord` or `both`. pub role_sync: String, /// Shown to players in the launcher. pub invite_url: String, /// Announcements go here. Empty turns them off. pub webhook_url: String, pub notify_achievements: bool, pub notify_guilds: bool, pub notify_members: bool, } 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 } } } pub async fn load(state: &AppState) -> AppResult { store::kv_get(state, "discord_settings").await } pub fn valid_webhook(url: &str) -> bool { url.is_empty() || ["https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/", "https://ptb.discord.com/api/webhooks/", "https://canary.discord.com/api/webhooks/"] .iter() .any(|p| url.starts_with(p)) } pub fn valid_invite(url: &str) -> bool { url.is_empty() || ["https://discord.gg/", "https://discord.com/invite/", "https://discordapp.com/invite/"].iter().any(|p| url.starts_with(p)) } fn view(s: &DiscordSettings) -> Value { json!({ "role_sync": s.role_sync, "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, }) } pub async fn get_settings(_: AdminUser, State(state): State) -> AppResult> { let conn: ConnectionsSettings = store::kv_get(&state, "connections_settings").await?; let linked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_connections WHERE provider = 'discord'").fetch_one(&state.db).await?; let mut v = view(&load(&state).await?); v["bot_ready"] = json!(!conn.discord_bot_token.is_empty() && !conn.discord_guild_id.is_empty()); v["linked_accounts"] = json!(linked); Ok(Json(v)) } #[derive(Deserialize)] pub struct SettingsInput { role_sync: String, invite_url: String, /// Omitted or empty keeps the saved webhook; `"-"` clears it. #[serde(default)] webhook_url: String, notify_achievements: bool, notify_guilds: bool, notify_members: bool, } pub async fn put_settings(_: AdminUser, State(state): State, Json(i): Json) -> AppResult> { if !["off", "discord_to_panel", "panel_to_discord", "both"].contains(&i.role_sync.as_str()) { return Err(AppError::bad_request("unknown role sync mode")); } let old = load(&state).await?; let webhook = match i.webhook_url.trim() { "" => old.webhook_url.clone(), "-" => String::new(), w => w.to_string(), }; let invite = i.invite_url.trim().to_string(); if !valid_webhook(&webhook) { return Err(AppError::bad_request("that is not a Discord webhook address")); } if !valid_invite(&invite) { return Err(AppError::bad_request("invite links look like https://discord.gg/yourcode")); } let next = DiscordSettings { role_sync: i.role_sync, invite_url: invite, webhook_url: webhook, notify_achievements: i.notify_achievements, notify_guilds: i.notify_guilds, notify_members: i.notify_members, }; store::kv_set(&state, "discord_settings", &next).await?; Ok(Json(view(&next))) } /// What launchers show: the invite link, and whether the account is linked. pub async fn public_info(State(state): State, AuthUser(user): AuthUser) -> AppResult> { let s = load(&state).await?; let linked: Option<(String, String)> = sqlx::query_as("SELECT provider_id, display_name FROM account_connections WHERE user_id = ? AND provider = 'discord'").bind(user.id).fetch_optional(&state.db).await?; Ok(Json(json!({ "invite_url": s.invite_url, "linked": linked.is_some(), "display_name": linked.map(|l| l.1) }))) } // --------------------------------------------------------------------------- // Role mapping // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct RoleMapping { pub discord_role: String, } pub async fn set_role_mapping(_: AdminUser, State(state): State, Path(id): Path, Json(m): Json) -> AppResult> { let role = m.discord_role.trim(); if !role.bytes().all(|b| b.is_ascii_digit()) || role.len() > 24 { return Err(AppError::bad_request("Discord role IDs are numbers")); } let done = sqlx::query("UPDATE groups SET discord_role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?; if done.rows_affected() == 0 { return Err(AppError::not_found("group not found")); } Ok(Json(json!({ "ok": true }))) } /// What to change for one person. Only ever additions. #[derive(Debug, Default, PartialEq)] pub struct Plan { pub add_groups: Vec, pub add_roles: Vec, } /// `mapping` is (group id, discord role id); `member_roles` are the Discord roles /// the person has; `groups` the panel groups they are in. pub fn plan(mode: &str, mapping: &[(i64, String)], member_roles: &[String], groups: &[i64]) -> Plan { let mut p = Plan::default(); let from_discord = matches!(mode, "discord_to_panel" | "both"); let to_discord = matches!(mode, "panel_to_discord" | "both"); for (group, role) in mapping.iter().filter(|(_, r)| !r.is_empty()) { let has_role = member_roles.contains(role); let in_group = groups.contains(group); if from_discord && has_role && !in_group { p.add_groups.push(*group); } if to_discord && in_group && !has_role { p.add_roles.push(role.clone()); } } p } fn bot_request(state: &AppState, method: reqwest::Method, url: String, token: &str) -> reqwest::RequestBuilder { state.http.request(method, url).header("Authorization", format!("Bot {token}")).timeout(Duration::from_secs(10)) } pub async fn roles(_: AdminUser, State(state): State) -> AppResult> { let c: ConnectionsSettings = store::kv_get(&state, "connections_settings").await?; if c.discord_bot_token.is_empty() || c.discord_guild_id.is_empty() { return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first")); } let resp = bot_request(&state, reqwest::Method::GET, format!("{API}/guilds/{}/roles", c.discord_guild_id), &c.discord_bot_token) .send() .await .map_err(|_| AppError::bad_request("could not reach Discord"))?; if !resp.status().is_success() { return Err(AppError::bad_request("Discord refused the bot token or server ID")); } let list: Vec = resp.json().await.map_err(|_| AppError::bad_request("unexpected Discord response"))?; let mut out: Vec = list .into_iter() .filter(|r| r["name"] != "@everyone" && !r["managed"].as_bool().unwrap_or(false)) .map(|r| json!({ "id": r["id"], "name": r["name"], "color": r["color"], "position": r["position"] })) .collect(); out.sort_by_key(|r| std::cmp::Reverse(r["position"].as_i64().unwrap_or(0))); Ok(Json(json!(out))) } #[derive(Default, Serialize)] pub struct SyncReport { pub checked: u32, pub not_in_server: u32, pub groups_added: u32, pub roles_added: u32, pub failed: u32, } /// Sync every linked account. Safe to run repeatedly. pub async fn sync_all(state: &AppState) -> AppResult { let settings = load(state).await?; let mut report = SyncReport::default(); if settings.role_sync == "off" { return Ok(report); } let c: ConnectionsSettings = store::kv_get(state, "connections_settings").await?; if c.discord_bot_token.is_empty() || c.discord_guild_id.is_empty() { 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() { 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 { 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 { Ok(r) => r, Err(_) => { report.failed += 1; continue; } }; if resp.status() == reqwest::StatusCode::NOT_FOUND { report.not_in_server += 1; continue; } if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { // Discord asked us to slow down; the next run finishes the rest. report.failed += 1; break; } let Ok(member) = resp.json::().await else { report.failed += 1; continue; }; let roles: Vec = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default(); let groups: Vec = 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); 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 { 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() .await; match done { Ok(r) if r.status().is_success() => report.roles_added += 1, _ => report.failed += 1, } tokio::time::sleep(Duration::from_millis(250)).await; } tokio::time::sleep(Duration::from_millis(120)).await; } Ok(report) } pub async fn sync_now(_: AdminUser, State(state): State) -> AppResult> { if load(&state).await?.role_sync == "off" { return Err(AppError::bad_request("turn on a role sync mode first")); } Ok(Json(sync_all(&state).await?)) } // --------------------------------------------------------------------------- // Announcements // --------------------------------------------------------------------------- #[derive(Default, Serialize, Deserialize)] #[serde(default)] struct Cursor { achievements: String, guilds: String, members: String, } async fn post_webhook(state: &AppState, url: &str, title: &str, text: &str, color: u32) { let body = json!({ "username": "SCOPENET", "allowed_mentions": { "parse": [] }, "embeds": [{ "title": title, "description": text, "color": color }] }); if let Err(e) = state.http.post(url).json(&body).timeout(Duration::from_secs(10)).send().await { tracing::debug!("discord webhook failed: {e}"); } } fn clip(s: &str) -> String { s.chars().take(200).collect() } /// Post what happened since the last look. The first run starts from "now". pub async fn announce(state: &AppState) -> AppResult { let s = load(state).await?; if s.webhook_url.is_empty() || !valid_webhook(&s.webhook_url) { return Ok(0); } let now = crate::db::now(); let mut cursor: Cursor = store::kv_get(state, "discord_cursor").await?; if cursor.achievements.is_empty() { cursor = Cursor { achievements: now.clone(), guilds: now.clone(), members: now.clone() }; store::kv_set(state, "discord_cursor", &cursor).await?; return Ok(0); } let mut sent = 0; if s.notify_achievements { let rows: Vec<(String, String, String)> = sqlx::query_as( "SELECT COALESCE(u.username, ua.user_uuid), a.title, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id LEFT JOIN users u ON u.uuid = ua.user_uuid WHERE ua.unlocked_at > ? ORDER BY ua.unlocked_at LIMIT 10", ) .bind(&cursor.achievements) .fetch_all(&state.db) .await?; for (who, title, at) in rows { post_webhook(state, &s.webhook_url, "Achievement unlocked", &format!("**{}** earned **{}**", clip(&who), clip(&title)), 0xfcd34d).await; cursor.achievements = at; sent += 1; } } if s.notify_guilds { let rows: Vec<(String, String, String)> = sqlx::query_as("SELECT name, tag, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?; for (name, tag, at) in rows { post_webhook(state, &s.webhook_url, "New guild", &format!("**{}** [{}] was founded", clip(&name), clip(&tag)), 0x22d3ee).await; cursor.guilds = at; sent += 1; } } if s.notify_members { let rows: Vec<(String, String)> = sqlx::query_as("SELECT username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.members).fetch_all(&state.db).await?; for (name, at) in rows { post_webhook(state, &s.webhook_url, "New player", &format!("Welcome **{}**!", clip(&name)), 0x6ee7b7).await; cursor.members = at; sent += 1; } } store::kv_set(state, "discord_cursor", &cursor).await?; Ok(sent) } pub async fn test_webhook(_: AdminUser, State(state): State) -> AppResult> { let s = load(&state).await?; if s.webhook_url.is_empty() { return Err(AppError::bad_request("save a webhook first")); } post_webhook(&state, &s.webhook_url, "SCOPENET is connected", "Announcements will appear in this channel.", 0x8b6cff).await; Ok(Json(json!({ "ok": true }))) } /// Background loop: announcements every 30 s, role sync every 15 min. pub fn spawn_worker(state: AppState) { tokio::spawn(async move { let mut tick = 0u64; loop { tokio::time::sleep(Duration::from_secs(30)).await; if let Err(e) = announce(&state).await { tracing::debug!("discord announce: {e:?}"); } tick += 1; if tick % 30 == 0 { if let Err(e) = sync_all(&state).await { tracing::debug!("discord sync: {e:?}"); } } } }); } #[cfg(test)] mod tests { use super::*; fn map() -> Vec<(i64, String)> { vec![(1, "100".into()), (2, "200".into()), (3, String::new())] } #[test] fn off_changes_nothing() { assert_eq!(plan("off", &map(), &["100".into()], &[2]), Plan::default()); } #[test] fn discord_roles_add_panel_groups() { let p = plan("discord_to_panel", &map(), &["100".into(), "999".into()], &[]); assert_eq!(p, Plan { add_groups: vec![1], add_roles: vec![] }); } #[test] fn panel_groups_add_discord_roles_and_unmapped_are_ignored() { let p = plan("panel_to_discord", &map(), &[], &[2, 3]); assert_eq!(p, Plan { add_groups: vec![], add_roles: vec!["200".into()] }); } #[test] fn both_never_removes() { let p = plan("both", &map(), &["100".into()], &[2]); assert_eq!(p, Plan { add_groups: vec![1], add_roles: vec!["200".into()] }); // Already in sync: nothing to do. assert_eq!(plan("both", &map(), &["100".into()], &[1]), Plan::default()); } #[test] fn webhook_and_invite_addresses_are_checked() { assert!(valid_webhook("https://discord.com/api/webhooks/1/abc")); assert!(!valid_webhook("https://evil.example/api/webhooks/1")); assert!(valid_invite("https://discord.gg/abc")); assert!(!valid_invite("http://discord.gg/abc")); } }