Files
SCOPENET-MC/panel/server/src/routes/discord.rs
T

457 lines
20 KiB
Rust

//! 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. 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};
use crate::routes::connections::ConnectionsSettings;
use crate::state::AppState;
use crate::store;
use axum::extract::{Path, State};
use crate::embeds;
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,
/// 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.
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(), base_role_id: String::new(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
}
}
pub async fn load(state: &AppState) -> AppResult<DiscordSettings> {
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, "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,
})
}
pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
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,
#[serde(default)]
base_role_id: 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<AppState>, Json(i): Json<SettingsInput>) -> AppResult<Json<Value>> {
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(),
"-" => 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,
base_role_id: i.base_role_id,
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<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<Value>> {
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<AppState>, Path(id): Path<i64>, Json(m): Json<RoleMapping>) -> AppResult<Json<Value>> {
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<i64>,
pub add_roles: Vec<String>,
}
/// `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<AppState>) -> AppResult<Json<Value>> {
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<Value> = resp.json().await.map_err(|_| AppError::bad_request("unexpected Discord response"))?;
let mut out: Vec<Value> = 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 roles_removed: u32,
pub failed: u32,
}
/// Sync every linked account. Safe to run repeatedly.
pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
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?;
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, 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 {
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;
}
if !resp.status().is_success() { report.failed += 1; continue; }
let Ok(member) = resp.json::<Value>().await else {
report.failed += 1;
continue;
};
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;
}
// 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()
.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;
}
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)
}
pub async fn sync_now(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<SyncReport>> {
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<u32> {
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, String, i64, String, String)> = sqlx::query_as(
"SELECT ua.user_uuid, COALESCE(u.username, ua.user_uuid), a.title, a.description, a.xp_reward, a.category, 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 (uuid, who, title, desc, xp, category, at) in rows {
let mut vars = embeds::player_vars(state, &uuid, &who).await;
for (k, v) in [("achievement", embeds::plain(&title)), ("achievement_description", embeds::plain(&desc)), ("xp", xp.to_string()), ("category", embeds::plain(&category))] {
vars.insert(k.into(), v);
}
if embeds::announce_event(state, "achievement", &vars).await.unwrap_or(false) {
sent += 1;
}
cursor.achievements = at;
}
}
if s.notify_guilds {
let rows: Vec<(String, String)> = sqlx::query_as("SELECT id, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?;
for (id, at) in rows {
let vars = embeds::guild_vars(state, &id).await;
if embeds::announce_event(state, "guild", &vars).await.unwrap_or(false) {
sent += 1;
}
cursor.guilds = at;
}
}
if s.notify_members {
let rows: Vec<(String, String, String)> =
sqlx::query_as("SELECT uuid, 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 (uuid, name, at) in rows {
let mut vars = embeds::player_vars(state, &uuid, &name).await;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'").fetch_one(&state.db).await.unwrap_or(0);
vars.insert("members_total".into(), total.to_string());
if embeds::announce_event(state, "member", &vars).await.unwrap_or(false) {
sent += 1;
}
cursor.members = at;
}
}
store::kv_set(state, "discord_cursor", &cursor).await?;
Ok(sent)
}
pub async fn test_webhook(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
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;
let mut live_last = std::collections::BTreeMap::new();
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
if let Err(e) = announce(&state).await {
tracing::debug!("discord announce: {e:?}");
}
embeds::tick_live(&state, &mut live_last).await;
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"));
}
}