From 485a81e0a4a5f3f9611a02e8a0fb88f840b1cced Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:20:33 +0000 Subject: [PATCH] Discord community: role sync, announcements webhook, launcher invite Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT --- launcher/src-tauri/src/commands.rs | 11 + launcher/src-tauri/src/lib.rs | 1 + launcher/src/pages/Settings.svelte | 5 + panel/server/src/db.rs | 4 + panel/server/src/main.rs | 1 + panel/server/src/routes/admin.rs | 5 +- panel/server/src/routes/discord.rs | 414 ++++++++++++++++++++++++++++ panel/server/src/routes/mod.rs | 7 + panel/web/src/lib/types.ts | 2 +- panel/web/src/pages/Settings.svelte | 52 ++++ panel/web/src/pages/Users.svelte | 13 + 11 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 panel/server/src/routes/discord.rs diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 1147cd0..d136f6b 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -211,6 +211,17 @@ pub async fn discord_connection(state: State<'_, AppState>) -> Res) -> Res { + let req = account_api(&state, reqwest::Method::GET, "/account/discord").await?; + let response = req.send().await.map_err(err)?; + if !response.status().is_success() { + return Err(accounts::panel_error(response).await.to_string()); + } + response.json().await.map_err(err) +} + #[tauri::command] pub async fn discord_link_start(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/auth/discord/start?kind=link").await?; diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index 33df5f7..ba6833e 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -57,6 +57,7 @@ pub fn run() { commands::discord_sign_in_poll, commands::request_password_reset, commands::discord_connection, + commands::discord_info, commands::discord_link_start, commands::discord_unlink, commands::register_panel, diff --git a/launcher/src/pages/Settings.svelte b/launcher/src/pages/Settings.svelte index 5b918c2..07087a6 100644 --- a/launcher/src/pages/Settings.svelte +++ b/launcher/src/pages/Settings.svelte @@ -17,9 +17,11 @@ const serverAccount = $derived(activeAccount()?.kind === 'panel'); let discordConnection = $state<{id: string; name: string} | null>(null); let discordBusy = $state(false); + let discordInvite = $state(''); $effect(() => { if (serverAccount && app.settingsTab === 'account') { invoke<{id: string; name: string} | null>('discord_connection').then(x => discordConnection = x).catch(() => discordConnection = null); + invoke<{invite_url: string}>('discord_info').then(x => discordInvite = x.invite_url ?? '').catch(() => discordInvite = ''); } }); async function linkDiscord() { @@ -363,6 +365,9 @@ {:else} {/if} + {#if discordInvite} + + {/if} {/if} {:else if app.settingsTab === 'skin'} diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index a258437..016f13d 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -668,6 +668,10 @@ const MIGRATIONS: &[&str] = &[ CREATE TRIGGER guilds_rev_d AFTER DELETE ON guilds BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; CREATE TRIGGER guilds_rev_u AFTER UPDATE OF name, tag, leader_uuid ON guilds BEGIN UPDATE kv SET value = CAST(value AS INTEGER) + 1 WHERE key = 'guild_rev'; END; "#, + // Discord role <-> panel group mapping. + r#" + ALTER TABLE groups ADD COLUMN discord_role TEXT NOT NULL DEFAULT ''; + "#, ]; pub async fn connect(data_dir: &Path) -> Result { diff --git a/panel/server/src/main.rs b/panel/server/src/main.rs index 496e4ea..ae6957c 100644 --- a/panel/server/src/main.rs +++ b/panel/server/src/main.rs @@ -23,6 +23,7 @@ async fn main() -> anyhow::Result<()> { let state = build_state(cfg, pool).await?; bootstrap_admin(&state).await?; state.livemap.spawn_reaper(); + scopenet_panel::routes::discord::spawn_worker(state.clone()); let listener = tokio::net::TcpListener::bind(&bind).await?; tracing::info!("SCOPENET panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION")); diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs index d3daf4f..237d06f 100644 --- a/panel/server/src/routes/admin.rs +++ b/panel/server/src/routes/admin.rs @@ -252,6 +252,9 @@ pub struct Group { #[serde(default)] #[sqlx(default)] luckperms_group: String, + #[serde(default)] + #[sqlx(default)] + discord_role: String, } fn default_color() -> String { "#7c5cff".into() @@ -260,7 +263,7 @@ fn default_color() -> String { pub async fn list_groups(_: AdminUser, State(state): State) -> AppResult>> { Ok(Json( sqlx::query_as( - "SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members, g.luckperms_group FROM groups g ORDER BY g.name", + "SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members, g.luckperms_group, g.discord_role FROM groups g ORDER BY g.name", ) .fetch_all(&state.db) .await?, diff --git a/panel/server/src/routes/discord.rs b/panel/server/src/routes/discord.rs new file mode 100644 index 0000000..2b97cd8 --- /dev/null +++ b/panel/server/src/routes/discord.rs @@ -0,0 +1,414 @@ +//! 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")); + } +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index e425b30..b82975d 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -7,6 +7,7 @@ pub mod dev_api; pub mod economy; pub mod guild_bank; pub mod guilds; +pub mod discord; pub mod integrations; pub mod landing; pub mod livemap; @@ -40,6 +41,7 @@ pub fn api(state: &AppState) -> Router { .route("/auth/discord/start", get(connections::discord_start)) .route("/auth/discord/callback", get(connections::discord_callback)) .route("/auth/discord/poll", get(connections::discord_poll)) + .route("/account/discord", get(discord::public_info)) .route("/account/connections/discord", get(connections::my_discord).delete(connections::unlink_discord)) .route("/account/profile", get(account::profile)) .route("/account/stats", get(servers::account_player_stats)) @@ -169,6 +171,11 @@ pub fn api(state: &AppState) -> Router { .route("/progression/players/{uuid}/adjust", post(progression::adjust_player)) .route("/integrations/settings", get(integrations::get_settings).put(integrations::put_settings)) .route("/groups/{id}/luckperms", put(integrations::set_group_mapping)) + .route("/groups/{id}/discord-role", put(discord::set_role_mapping)) + .route("/discord", get(discord::get_settings).put(discord::put_settings)) + .route("/discord/roles", get(discord::roles)) + .route("/discord/sync", post(discord::sync_now)) + .route("/discord/test", post(discord::test_webhook)) .route("/servers/{id}/integrations", get(integrations::admin_server)) .route("/guilds", get(guilds::admin_list_guilds)) .route("/guilds/{id}", delete(guilds::admin_delete_guild)) diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 5dae903..888315f 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -71,7 +71,7 @@ export interface User { last_seen_ingame: string | null; } -export interface Group { id: number; name: string; color: string; members: number; luckperms_group?: string } +export interface Group { id: number; name: string; color: string; members: number; luckperms_group?: string; discord_role?: string } export interface Settings { auth: { diff --git a/panel/web/src/pages/Settings.svelte b/panel/web/src/pages/Settings.svelte index 64a4ab9..75a1f51 100644 --- a/panel/web/src/pages/Settings.svelte +++ b/panel/web/src/pages/Settings.svelte @@ -16,6 +16,32 @@ let testAddress = $state(''); let testingEmail = $state(false); let savingConnections = $state(false); + let disc = $state(null); + let discWebhook = $state(''); + let discRoles = $state<{ id: string; name: string }[]>([]); + let savingDisc = $state(false); + let syncingDisc = $state(false); + const loadDisc = () => get('/api/admin/discord').then((x) => (disc = x)).catch(toastError); + async function saveDisc() { + savingDisc = true; + try { + disc = await put('/api/admin/discord', { role_sync: disc.role_sync, invite_url: disc.invite_url, webhook_url: discWebhook, notify_achievements: disc.notify_achievements, notify_guilds: disc.notify_guilds, notify_members: disc.notify_members }); + discWebhook = ''; + toast('Discord community settings saved'); + loadDisc(); + } catch (e) { toastError(e); } finally { savingDisc = false; } + } + async function syncDisc() { + syncingDisc = true; + try { + const r = await post('/api/admin/discord/sync', {}); + toast(`Checked ${r.checked} · ${r.groups_added} groups added · ${r.roles_added} roles added${r.not_in_server ? ` · ${r.not_in_server} not in server` : ''}`); + } catch (e) { toastError(e); } finally { syncingDisc = false; } + } + async function testDisc() { + try { await post('/api/admin/discord/test', {}); toast('Test message sent'); } catch (e) { toastError(e); } + } + let info = $state(null); const loadInfo = () => get('/api/admin/auth-server').then((x) => (info = x)).catch(toastError); @@ -23,6 +49,7 @@ $effect(() => { get('/api/admin/settings').then((x) => { s = x; usernameRules = x.username_blocklist.join('\n'); }).catch(toastError); get('/api/admin/connections').then((x) => (connections = x)).catch(toastError); + loadDisc(); loadInfo(); }); @@ -173,6 +200,31 @@ Secrets are never returned to your browser after saving. Type - in a secret field to remove it. + {#if disc} +
+

Discord community

+

{disc.linked_accounts} linked account{disc.linked_accounts === 1 ? '' : 's'}. Role sync needs the bot token and server ID above{disc.bot_ready ? '' : ' (not saved yet)'}. Sync only ever adds: it never removes a role or group. Map roles to groups under Players → Groups.

+ + + + + + +
+ + + +
+ Roles also sync automatically every 15 minutes. +
+ {/if}

Resend SMTP

Verify the sender domain in Resend, then enter an API key. SCOPENET connects to smtp.resend.com over TLS.

diff --git a/panel/web/src/pages/Users.svelte b/panel/web/src/pages/Users.svelte index 08e3835..e4ce862 100644 --- a/panel/web/src/pages/Users.svelte +++ b/panel/web/src/pages/Users.svelte @@ -111,6 +111,17 @@ } } + async function mapDiscord(g: Group, value: string) { + try { + await put(`/api/admin/groups/${g.id}/discord-role`, { discord_role: value.trim() }); + toast(value.trim() ? `${g.name} is mapped to a Discord role` : `${g.name} is no longer mapped to Discord`); + refresh(); + } catch (e) { + toastError(e); + refresh(); + } + } + async function removeGroup(g: Group) { try { await del(`/api/admin/groups/${g.id}`); @@ -194,6 +205,8 @@ {g.name} {g.members} member{g.members === 1 ? '' : 's'} + mapDiscord(g, e.currentTarget.value)} /> mapLp(g, e.currentTarget.value)} />