Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
415 lines
17 KiB
Rust
415 lines
17 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. 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<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, "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,
|
|
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"));
|
|
}
|
|
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<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 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?;
|
|
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::<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);
|
|
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<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)> = 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<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;
|
|
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"));
|
|
}
|
|
}
|