diff --git a/panel/server/src/embeds.rs b/panel/server/src/embeds.rs new file mode 100644 index 0000000..4c735d8 --- /dev/null +++ b/panel/server/src/embeds.rs @@ -0,0 +1,641 @@ +//! Discord messages the panel posts: announcements (achievements, new players, new guilds) built from admin-edited templates, +//! and live embeds (server status, active guilds, leaderboards, richest players) that are edited in place on a schedule. +//! +//! Everything goes through a webhook, so no bot is needed. Templates use `{placeholders}`; unknown ones are left as typed. + +use crate::error::{AppError, AppResult}; +use crate::routes::discord; +use crate::state::AppState; +use crate::store; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::time::Duration; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug, Default)] +#[serde(default)] +pub struct Field { + pub name: String, + pub value: String, + pub inline: bool, +} + +/// Everything about how one Discord message looks. All text may contain placeholders. +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] +#[serde(default)] +pub struct EmbedStyle { + pub enabled: bool, + /// Webhook display name and picture (empty = SCOPENET's). + pub username: String, + pub avatar_url: String, + /// Plain text above the embed. Mentions work here, e.g. `<@&roleid>`. + pub content: String, + pub title: String, + pub url: String, + pub description: String, + /// `#rrggbb` + pub color: String, + pub thumbnail: String, + pub image: String, + pub author_name: String, + pub author_icon: String, + pub footer: String, + pub footer_icon: String, + /// Show the time the message was made. + pub timestamp: bool, + pub fields: Vec, +} + +impl Default for EmbedStyle { + fn default() -> Self { + Self { + enabled: true, username: String::new(), avatar_url: String::new(), content: String::new(), title: String::new(), url: String::new(), + description: String::new(), color: "#8b6cff".into(), thumbnail: String::new(), image: String::new(), author_name: String::new(), + author_icon: String::new(), footer: String::new(), footer_icon: String::new(), timestamp: true, fields: vec![], + } + } +} + +fn field(name: &str, value: &str, inline: bool) -> Field { + Field { name: name.into(), value: value.into(), inline } +} + +/// What the three announcements say until an admin changes them. +pub fn default_templates() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert( + "achievement".to_string(), + EmbedStyle { + title: "๐Ÿ† Achievement unlocked".into(), + description: "**{player}** earned **{achievement}**\n*{achievement_description}*".into(), + color: "#fcd34d".into(), + thumbnail: "{avatar}".into(), + footer: "{category} ยท {server_name}".into(), + fields: vec![field("Reward", "+{xp} XP", true), field("Level", "{level}", true), field("Guild", "{guild_line}", true)], + ..Default::default() + }, + ); + m.insert( + "member".to_string(), + EmbedStyle { + enabled: false, + title: "๐Ÿ‘‹ New player".into(), + description: "Welcome **{player}**! You are player number **{members_total}**.".into(), + color: "#6ee7b7".into(), + thumbnail: "{avatar}".into(), + footer: "{server_name}".into(), + ..Default::default() + }, + ); + m.insert( + "guild".to_string(), + EmbedStyle { + title: "๐Ÿ›ก๏ธ New guild".into(), + description: "**{guild}** [{tag}] was founded by **{leader}**.\n{guild_description}".into(), + color: "#22d3ee".into(), + thumbnail: "{leader_avatar}".into(), + footer: "{guilds_total} guilds in total ยท {server_name}".into(), + fields: vec![field("Leader", "{leader}", true), field("Members", "{members}", true), field("Land", "{claims} chunks", true)], + ..Default::default() + }, + ); + m +} + +pub async fn templates(state: &AppState) -> AppResult> { + let mut stored: BTreeMap = store::kv_get(state, "discord_templates").await?; + for (k, v) in default_templates() { + stored.entry(k).or_insert(v); + } + Ok(stored) +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +pub type Vars = BTreeMap; + +/// Replace `{name}` with its value. Unknown placeholders stay as typed so mistakes are visible. +pub fn fill(text: &str, vars: &Vars) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find('{') { + out.push_str(&rest[..start]); + let after = &rest[start + 1..]; + match after.find('}') { + Some(end) if !after[..end].contains(char::is_whitespace) && !after[..end].contains('{') => { + match vars.get(&after[..end]) { + Some(v) => out.push_str(v), + None => { + out.push('{'); + out.push_str(&after[..end]); + out.push('}'); + } + } + rest = &after[end + 1..]; + } + _ => { + out.push('{'); + rest = after; + } + } + } + out.push_str(rest); + out +} + +fn cut(s: &str, max: usize) -> String { + if s.chars().count() <= max { s.to_string() } else { format!("{}โ€ฆ", s.chars().take(max.saturating_sub(1)).collect::()) } +} + +fn color(s: &str) -> u32 { + u32::from_str_radix(s.trim().trim_start_matches('#'), 16).ok().filter(|c| *c <= 0xFFFFFF).unwrap_or(0x8b6cff) +} + +fn https(s: String) -> Option { + let s = s.trim().to_string(); + (s.starts_with("https://") || s.starts_with("http://")).then_some(s) +} + +/// The webhook body for a style and its values, kept within Discord's limits. +pub fn render(style: &EmbedStyle, vars: &Vars, extra_description: Option<&str>) -> Value { + let mut e = Map::new(); + let title = fill(&style.title, vars); + if !title.trim().is_empty() { + e.insert("title".into(), json!(cut(&title, 256))); + } + if let Some(u) = https(fill(&style.url, vars)) { + e.insert("url".into(), json!(u)); + } + let mut desc = fill(&style.description, vars); + if let Some(extra) = extra_description { + desc = desc.replace("{rows}", extra); + } + if !desc.trim().is_empty() { + e.insert("description".into(), json!(cut(&desc, 4096))); + } + e.insert("color".into(), json!(color(&style.color))); + if let Some(u) = https(fill(&style.thumbnail, vars)) { + e.insert("thumbnail".into(), json!({ "url": u })); + } + if let Some(u) = https(fill(&style.image, vars)) { + e.insert("image".into(), json!({ "url": u })); + } + let author = fill(&style.author_name, vars); + if !author.trim().is_empty() { + let mut a = Map::new(); + a.insert("name".into(), json!(cut(&author, 256))); + if let Some(u) = https(fill(&style.author_icon, vars)) { + a.insert("icon_url".into(), json!(u)); + } + e.insert("author".into(), Value::Object(a)); + } + let footer = fill(&style.footer, vars); + if !footer.trim().is_empty() { + let mut f = Map::new(); + f.insert("text".into(), json!(cut(&footer, 2048))); + if let Some(u) = https(fill(&style.footer_icon, vars)) { + f.insert("icon_url".into(), json!(u)); + } + e.insert("footer".into(), Value::Object(f)); + } + if style.timestamp { + e.insert("timestamp".into(), json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true))); + } + let fields: Vec = style + .fields + .iter() + .map(|f| (fill(&f.name, vars), fill(&f.value, vars), f.inline)) + .filter(|(n, v, _)| !n.trim().is_empty() && !v.trim().is_empty()) + .take(25) + .map(|(n, v, i)| json!({ "name": cut(&n, 256), "value": cut(&v, 1024), "inline": i })) + .collect(); + if !fields.is_empty() { + e.insert("fields".into(), json!(fields)); + } + let mut body = Map::new(); + body.insert("username".into(), json!(cut(&if style.username.trim().is_empty() { "SCOPENET".to_string() } else { fill(&style.username, vars) }, 80))); + if let Some(u) = https(fill(&style.avatar_url, vars)) { + body.insert("avatar_url".into(), json!(u)); + } + let content = fill(&style.content, vars); + if !content.trim().is_empty() { + body.insert("content".into(), json!(cut(&content, 2000))); + } + // Mentions only where the admin wrote them in the message text; never from player-supplied names. + body.insert("allowed_mentions".into(), json!({ "parse": ["roles", "users"] })); + body.insert("embeds".into(), json!([Value::Object(e)])); + Value::Object(body) +} + +/// Player-supplied text can't ping anyone or break formatting out of its place. +pub fn plain(s: &str) -> String { + s.chars().filter(|c| !c.is_control()).collect::().replace('@', "@\u{200b}").replace('`', "'").chars().take(120).collect() +} + +fn base_url(state: &AppState) -> String { + state.cfg.public_url.clone().unwrap_or_default().trim_end_matches('/').to_string() +} + +pub fn avatar(state: &AppState, uuid: &str) -> String { + let base = base_url(state); + if base.is_empty() || uuid.is_empty() { String::new() } else { format!("{base}/api/v1/avatar/{uuid}?size=128") } +} + +async fn server_name(state: &AppState) -> String { + store::branding(state).await.map(|b| b.name).unwrap_or_default() +} + +/// Values common to every message. +async fn common(state: &AppState) -> Vars { + let mut v = Vars::new(); + v.insert("server_name".into(), server_name(state).await); + v.insert("panel".into(), base_url(state)); + v.insert("time".into(), format!("", chrono::Utc::now().timestamp())); + v.insert("updated".into(), format!("", chrono::Utc::now().timestamp())); + v +} + +pub const ANNOUNCEMENT_PLACEHOLDERS: [(&str, &[&str]); 3] = [ + ("achievement", &["player", "uuid", "avatar", "achievement", "achievement_description", "xp", "category", "level", "rank_title", "guild", "guild_tag", "guild_line", "server_name", "panel", "time"]), + ("member", &["player", "uuid", "avatar", "members_total", "server_name", "panel", "time"]), + ("guild", &["guild", "tag", "leader", "leader_avatar", "guild_description", "members", "claims", "guilds_total", "server_name", "panel", "time"]), +]; + +pub async fn player_vars(state: &AppState, uuid: &str, name: &str) -> Vars { + let mut v = common(state).await; + v.insert("player".into(), plain(name)); + v.insert("uuid".into(), uuid.to_string()); + v.insert("avatar".into(), avatar(state, uuid)); + let level: Option<(i64, Option)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten(); + let (l, t) = level.unwrap_or((1, None)); + v.insert("level".into(), l.to_string()); + v.insert("rank_title".into(), plain(&t.unwrap_or_default())); + let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten(); + let (gn, gt) = guild.unwrap_or_default(); + v.insert("guild".into(), plain(&gn)); + v.insert("guild_tag".into(), plain(>)); + v.insert("guild_line".into(), if gn.is_empty() { "No guild".into() } else { format!("[{}] {}", plain(>), plain(&gn)) }); + v +} + +pub async fn guild_vars(state: &AppState, guild_id: &str) -> Vars { + let mut v = common(state).await; + let row: Option<(String, String, String, String, i64, i64)> = sqlx::query_as( + "SELECT g.name, g.tag, g.description, g.leader_uuid, (SELECT COUNT(*) FROM guild_members WHERE guild_id = g.id), (SELECT COUNT(*) FROM guild_claims WHERE guild_id = g.id) FROM guilds g WHERE g.id = ?", + ) + .bind(guild_id) + .fetch_optional(&state.db) + .await + .ok() + .flatten(); + let (name, tag, desc, leader_uuid, members, claims) = row.unwrap_or_default(); + let leader: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&leader_uuid).fetch_optional(&state.db).await.ok().flatten().unwrap_or_default(); + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guilds").fetch_one(&state.db).await.unwrap_or(0); + v.insert("guild".into(), plain(&name)); + v.insert("tag".into(), plain(&tag)); + v.insert("guild_description".into(), plain(&desc)); + v.insert("leader".into(), plain(&leader)); + v.insert("leader_avatar".into(), avatar(state, &leader_uuid)); + v.insert("members".into(), members.to_string()); + v.insert("claims".into(), claims.to_string()); + v.insert("guilds_total".into(), total.to_string()); + v +} + +/// Example values, for previews and test messages. +pub async fn sample_vars(state: &AppState, kind: &str) -> Vars { + let mut v = common(state).await; + let me = state.cfg.admin_username.clone(); + let pairs: Vec<(&str, String)> = match kind { + "achievement" => vec![("player", me), ("uuid", "00000000-0000-0000-0000-000000000000".into()), ("avatar", String::new()), ("achievement", "Deep Diver".into()), + ("achievement_description", "Mine 5,000 blocks below Y 0".into()), ("xp", "250".into()), ("category", "Mining".into()), ("level", "14".into()), + ("rank_title", "Veteran".into()), ("guild", "Iron Wolves".into()), ("guild_tag", "IRON".into()), ("guild_line", "[IRON] Iron Wolves".into())], + "member" => vec![("player", me), ("uuid", String::new()), ("avatar", String::new()), ("members_total", "128".into())], + _ => vec![("guild", "Iron Wolves".into()), ("tag", "IRON".into()), ("leader", me), ("leader_avatar", String::new()), ("guild_description", "Defenders of the realm.".into()), + ("members", "1".into()), ("claims", "0".into()), ("guilds_total", "12".into())], + }; + for (k, val) in pairs { + v.insert(k.into(), val); + } + v +} + +// --------------------------------------------------------------------------- +// Sending +// --------------------------------------------------------------------------- + +pub async fn post(state: &AppState, webhook: &str, body: &Value) -> AppResult<()> { + let resp = state.http.post(webhook).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16()))); + } + Ok(()) +} + +/// Post a new message and return its id (so it can be edited later). +async fn post_returning_id(state: &AppState, webhook: &str, body: &Value) -> AppResult { + let resp = state.http.post(format!("{webhook}?wait=true")).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16()))); + } + let v: Value = resp.json().await.map_err(|_| AppError::bad_request("Discord sent an unexpected answer"))?; + Ok(v["id"].as_str().unwrap_or_default().to_string()) +} + +pub async fn announce_event(state: &AppState, kind: &str, vars: &Vars) -> AppResult { + let s = discord::load(state).await?; + if s.webhook_url.is_empty() { + return Ok(false); + } + let style = templates(state).await?.remove(kind).unwrap_or_default(); + if !style.enabled { + return Ok(false); + } + post(state, &s.webhook_url, &render(&style, vars, None)).await?; + Ok(true) +} + +// --------------------------------------------------------------------------- +// Live embeds +// --------------------------------------------------------------------------- + +pub const LIVE_KINDS: [&str; 4] = ["status", "guilds", "leaderboard", "baltop"]; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] +#[serde(default)] +pub struct LiveConfig { + pub style: EmbedStyle, + /// Where to post. Empty = the announcement webhook. + pub webhook_url: String, + /// Seconds between updates (at least 60). + pub interval_secs: u64, + /// Rows shown (leaderboards, guilds). + pub limit: u32, + /// leaderboard: `playtime`, `level`, `kills`, `blocks`. + pub sort: String, + /// baltop: the economy of this server (0 = the first one). + pub server_id: i64, + /// One line per row; `{rows}` in the description is replaced by them. + pub row: String, + #[serde(skip_serializing)] + pub message_id: String, +} + +impl Default for LiveConfig { + fn default() -> Self { + Self { style: EmbedStyle::default(), webhook_url: String::new(), interval_secs: 120, limit: 10, sort: "playtime".into(), server_id: 0, row: String::new(), message_id: String::new() } + } +} + +pub fn default_live() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert("status".to_string(), LiveConfig { + style: EmbedStyle { + enabled: false, + title: "๐ŸŸข Server status".into(), + description: "{rows}\n\n**{players_online}** players online across **{servers_online}/{servers_total}** servers".into(), + color: "#34d399".into(), footer: "{server_name} ยท updated".into(), ..Default::default() + }, + row: "{status_icon} **{server}** โ€” {players}/{max} players ยท TPS {tps} ยท {version}".into(), + ..Default::default() + }); + m.insert("guilds".to_string(), LiveConfig { + style: EmbedStyle { + enabled: false, title: "๐Ÿ›ก๏ธ Active guilds".into(), description: "{rows}\n\n{guilds_total} guilds ยท {claims_total} claimed chunks".into(), + color: "#22d3ee".into(), footer: "{server_name}".into(), ..Default::default() + }, + row: "**{rank}. [{tag}] {guild}** โ€” level {level} ยท {members} members ยท {claims} chunks ยท led by {leader}".into(), + ..Default::default() + }); + m.insert("leaderboard".to_string(), LiveConfig { + style: EmbedStyle { + enabled: false, title: "๐Ÿ† Leaderboard โ€” {sort_label}".into(), description: "{rows}".into(), + color: "#fcd34d".into(), footer: "{server_name}".into(), ..Default::default() + }, + row: "`{rank}.` **{player}** โ€” {value}".into(), + ..Default::default() + }); + m.insert("baltop".to_string(), LiveConfig { + style: EmbedStyle { + enabled: false, title: "๐Ÿ’ฐ Richest players".into(), description: "{rows}\n\nTotal in circulation: **{total}**".into(), + color: "#f59e0b".into(), footer: "{server_name}".into(), ..Default::default() + }, + row: "`{rank}.` **{player}** โ€” {balance}".into(), + ..Default::default() + }); + m +} + +pub async fn live_configs(state: &AppState) -> AppResult> { + let mut stored: BTreeMap = store::kv_get(state, "discord_live").await?; + for (k, v) in default_live() { + stored.entry(k).or_insert(v); + } + Ok(stored) +} + +pub async fn save_live(state: &AppState, configs: &BTreeMap) -> AppResult<()> { + // message_id is runtime state; keep it out of the JSON the admin edits but in storage. + let mut stored = serde_json::Map::new(); + for (k, c) in configs { + let mut v = serde_json::to_value(c)?; + v["message_id"] = json!(c.message_id); + stored.insert(k.clone(), v); + } + store::kv_set(state, "discord_live", &Value::Object(stored)).await +} + +fn money(v: f64) -> String { + let whole = v.round() as i64; + let s = whole.abs().to_string(); + let mut out = String::new(); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(ch); + } + format!("${}{}", if whole < 0 { "-" } else { "" }, out.chars().rev().collect::()) +} + +fn hours(secs: i64) -> String { + if secs >= 3600 { format!("{}h {}m", secs / 3600, (secs % 3600) / 60) } else { format!("{}m", secs / 60) } +} + +/// The embed body for one live kind, built from current data. +pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppResult { + let mut vars = common(state).await; + let mut rows: Vec = Vec::new(); + let default_row = default_live().remove(kind).map(|c| c.row).unwrap_or_default(); + let row_tpl = if cfg.row.trim().is_empty() { default_row } else { cfg.row.clone() }; + let limit = cfg.limit.clamp(1, 25) as i64; + match kind { + "status" => { + let list = crate::routes::servers::status_rows(state).await?; + vars.insert("servers_total".into(), list.len().to_string()); + vars.insert("servers_online".into(), list.iter().filter(|s| s.online).count().to_string()); + vars.insert("players_online".into(), list.iter().map(|s| s.players).sum::().to_string()); + vars.insert("players_max".into(), list.iter().map(|s| s.max_players).sum::().to_string()); + for s in list { + let mut r = vars.clone(); + r.insert("status_icon".into(), if s.online { "๐ŸŸข" } else { "๐Ÿ”ด" }.into()); + r.insert("server".into(), plain(&s.name)); + r.insert("status".into(), if s.online { "Online" } else { "Offline" }.into()); + r.insert("players".into(), s.players.to_string()); + r.insert("max".into(), if s.max_players > 0 { s.max_players.to_string() } else { "โ€“".into() }); + r.insert("tps".into(), s.tps.map(|t| format!("{t:.1}")).unwrap_or_else(|| "โ€“".into())); + r.insert("version".into(), plain(&if s.version.is_empty() { s.software.clone() } else { format!("{} {}", s.software, s.version) })); + r.insert("last_seen".into(), s.last_seen.as_deref().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| format!("", t.timestamp())).unwrap_or_else(|| "never".into())); + rows.push(fill(&row_tpl, &r)); + } + } + "guilds" => { + let list: Vec<(String, String, i64, i64, i64, String, String)> = sqlx::query_as( + "SELECT g.name, g.tag, g.level, (SELECT COUNT(*) FROM guild_members WHERE guild_id = g.id), (SELECT COUNT(*) FROM guild_claims WHERE guild_id = g.id), COALESCE(u.username, ''), g.leader_uuid + FROM guilds g LEFT JOIN users u ON u.uuid = g.leader_uuid ORDER BY g.level DESC, g.xp DESC, 4 DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(&state.db) + .await?; + let totals: (i64, i64) = sqlx::query_as("SELECT (SELECT COUNT(*) FROM guilds), (SELECT COUNT(*) FROM guild_claims)").fetch_one(&state.db).await?; + vars.insert("guilds_total".into(), totals.0.to_string()); + vars.insert("claims_total".into(), totals.1.to_string()); + for (i, (name, tag, level, members, claims, leader, leader_uuid)) in list.into_iter().enumerate() { + let mut r = vars.clone(); + for (k, v) in [("rank", (i + 1).to_string()), ("guild", plain(&name)), ("tag", plain(&tag)), ("level", level.to_string()), ("members", members.to_string()), + ("claims", claims.to_string()), ("leader", plain(&leader)), ("leader_avatar", avatar(state, &leader_uuid))] { + r.insert(k.into(), v); + } + rows.push(fill(&row_tpl, &r)); + } + } + "leaderboard" => { + let (sort_label, order, unit) = match cfg.sort.as_str() { + "level" => ("Level", "COALESCE(l.global_xp, 0)", "level"), + "kills" => ("Player kills", "kills", "kills"), + "blocks" => ("Blocks broken", "blocks", "blocks"), + _ => ("Playtime", "playtime", "playtime"), + }; + vars.insert("sort_label".into(), sort_label.into()); + let sql = format!( + "SELECT s.uuid, COALESCE(u.username, MAX(s.name)), SUM(s.playtime_secs) AS playtime, SUM(s.player_kills) AS kills, SUM(s.blocks_broken) AS blocks, COALESCE(l.global_level, 1), SUM(s.deaths), SUM(s.mob_kills) + FROM player_stats s LEFT JOIN users u ON u.uuid = s.uuid LEFT JOIN user_levels l ON l.uuid = s.uuid GROUP BY s.uuid ORDER BY {order} DESC LIMIT ?" + ); + let list: Vec<(String, String, i64, i64, i64, i64, i64, i64)> = sqlx::query_as(&sql).bind(limit).fetch_all(&state.db).await?; + for (i, (uuid, name, playtime, kills, blocks, level, deaths, mobs)) in list.into_iter().enumerate() { + let value = match unit { "level" => format!("level {level}"), "kills" => format!("{kills} kills"), "blocks" => format!("{blocks} blocks"), _ => hours(playtime) }; + let mut r = vars.clone(); + for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("value", value), ("playtime", hours(playtime)), + ("kills", kills.to_string()), ("blocks", blocks.to_string()), ("level", level.to_string()), ("deaths", deaths.to_string()), ("mob_kills", mobs.to_string())] { + r.insert(k.into(), v); + } + rows.push(fill(&row_tpl, &r)); + } + } + _ => { + let sid: i64 = if cfg.server_id > 0 { cfg.server_id } else { sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers").fetch_one(&state.db).await? }; + let economy = crate::routes::servers::economy_scope(&state.db, sid).await?; + let list: Vec<(String, String, f64)> = sqlx::query_as("SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT ?").bind(economy).bind(limit).fetch_all(&state.db).await?; + let total: f64 = sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0) FROM server_economy WHERE server_id = ?").bind(economy).fetch_one(&state.db).await?; + vars.insert("total".into(), money(total)); + for (i, (uuid, name, balance)) in list.into_iter().enumerate() { + let mut r = vars.clone(); + for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("balance", money(balance))] { + r.insert(k.into(), v); + } + rows.push(fill(&row_tpl, &r)); + } + } + } + let rows_text = if rows.is_empty() { "*Nothing to show yet.*".to_string() } else { rows.join("\n") }; + Ok(render(&cfg.style, &vars, Some(&rows_text))) +} + +/// Post or update one live embed. Returns the stored message id. +pub async fn refresh_live(state: &AppState, kind: &str, force_new: bool) -> AppResult<()> { + let mut all = live_configs(state).await?; + let Some(cfg) = all.get(kind).cloned() else { return Err(AppError::bad_request("unknown embed")) }; + let main = discord::load(state).await?; + let hook = if cfg.webhook_url.trim().is_empty() { main.webhook_url.clone() } else { cfg.webhook_url.trim().to_string() }; + if hook.is_empty() || !discord::valid_webhook(&hook) { + return Err(AppError::bad_request("set a Discord webhook first (Discord settings, or this embed's own webhook)")); + } + let body = build_live(state, kind, &cfg).await?; + let mut id = if force_new { String::new() } else { cfg.message_id.clone() }; + if !id.is_empty() { + let resp = state.http.patch(format!("{hook}/messages/{id}")).json(&body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + id.clear(); // the message was deleted in Discord: post a fresh one + } else if !resp.status().is_success() { + return Err(AppError::bad_request(format!("Discord refused the update (HTTP {})", resp.status().as_u16()))); + } + } + if id.is_empty() { + id = post_returning_id(state, &hook, &body).await?; + } + if let Some(c) = all.get_mut(kind) { + c.message_id = id; + } + save_live(state, &all).await +} + +/// Called every half minute: refresh the embeds that are due. +pub async fn tick_live(state: &AppState, last: &mut BTreeMap) { + let Ok(all) = live_configs(state).await else { return }; + for (kind, cfg) in all { + if !cfg.style.enabled { + continue; + } + let every = Duration::from_secs(cfg.interval_secs.max(60)); + if last.get(&kind).is_some_and(|t| t.elapsed() < every) { + continue; + } + last.insert(kind.clone(), std::time::Instant::now()); + if let Err(e) = refresh_live(state, &kind, false).await { + tracing::debug!("live embed {kind}: {}", e.message); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholders_are_filled_and_unknown_ones_kept() { + let mut v = Vars::new(); + v.insert("player".into(), "Steve".into()); + assert_eq!(fill("Hi {player}, {nope} and {a b} {", &v), "Hi Steve, {nope} and {a b} {"); + assert_eq!(fill("{player}{player}", &v), "SteveSteve"); + } + + #[test] + fn embeds_stay_inside_discords_limits_and_drop_empties() { + let mut v = Vars::new(); + v.insert("long".into(), "x".repeat(5000)); + v.insert("img".into(), "javascript:alert(1)".into()); + let style = EmbedStyle { title: "{long}".into(), description: "{long}".into(), thumbnail: "{img}".into(), color: "nonsense".into(), fields: vec![field("", "empty name", false), field("ok", "{long}", true)], ..Default::default() }; + let body = render(&style, &v, None); + let e = &body["embeds"][0]; + assert!(e["title"].as_str().unwrap().chars().count() <= 256); + assert!(e["description"].as_str().unwrap().chars().count() <= 4096); + assert!(e.get("thumbnail").is_none(), "only web addresses become images"); + assert_eq!(e["color"], 0x8b6cff); + assert_eq!(e["fields"].as_array().unwrap().len(), 1); + assert!(e["fields"][0]["value"].as_str().unwrap().chars().count() <= 1024); + } + + #[test] + fn player_text_cannot_ping() { + assert_eq!(plain("@everyone `x`"), "@\u{200b}everyone 'x'"); + } + + #[test] + fn money_and_hours_read_well() { + assert_eq!(money(1234567.4), "$1,234,567"); + assert_eq!(hours(3700), "1h 1m"); + assert_eq!(hours(300), "5m"); + } +} diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index 701eef1..7970f5d 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod config; pub mod db; +pub mod embeds; pub mod error; pub mod worldmap; pub mod net; diff --git a/panel/server/src/routes/discord.rs b/panel/server/src/routes/discord.rs index b7554ab..eb9bdd9 100644 --- a/panel/server/src/routes/discord.rs +++ b/panel/server/src/routes/discord.rs @@ -11,6 +11,7 @@ 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}; @@ -336,36 +337,46 @@ pub async fn announce(state: &AppState) -> AppResult { } 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 + 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 (who, title, at) in rows { - post_webhook(state, &s.webhook_url, "Achievement unlocked", &format!("**{}** earned **{}**", clip(&who), clip(&title)), 0xfcd34d).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; - 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; + 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; - 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; + 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; - sent += 1; } } store::kv_set(state, "discord_cursor", &cursor).await?; @@ -385,11 +396,13 @@ pub async fn test_webhook(_: AdminUser, State(state): State) -> AppRes 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 { diff --git a/panel/server/src/routes/discord_studio.rs b/panel/server/src/routes/discord_studio.rs new file mode 100644 index 0000000..d9e28f7 --- /dev/null +++ b/panel/server/src/routes/discord_studio.rs @@ -0,0 +1,155 @@ +//! Admin editing of Discord message templates and live embeds, with previews built from real data. + +use crate::auth::AdminUser; +use crate::embeds::{self, EmbedStyle, LiveConfig}; +use crate::error::{AppError, AppResult}; +use crate::routes::discord; +use crate::state::AppState; +use crate::store; +use axum::extract::{Path, State}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +fn check_style(s: &EmbedStyle) -> AppResult<()> { + let too_long = |t: &str, n: usize| t.chars().count() > n; + if too_long(&s.title, 600) || too_long(&s.description, 4000) || too_long(&s.footer, 600) || too_long(&s.content, 2000) || s.fields.len() > 25 { + return Err(AppError::bad_request("a Discord message part is too long (title 256, text 4096, footer 2048, 25 fields)")); + } + for url in [&s.url, &s.thumbnail, &s.image, &s.avatar_url, &s.author_icon, &s.footer_icon] { + let u = url.trim(); + if !u.is_empty() && !u.starts_with('{') && !(u.starts_with("https://") || u.starts_with("http://")) { + return Err(AppError::bad_request("image and link addresses must start with https:// (or be a placeholder like {avatar})")); + } + } + Ok(()) +} + +pub async fn get_all(_: AdminUser, State(state): State) -> AppResult> { + let templates = embeds::templates(&state).await?; + let live = embeds::live_configs(&state).await?; + let mut live_out = serde_json::Map::new(); + for (k, c) in &live { + let mut v = serde_json::to_value(c)?; + v["posted"] = json!(!c.message_id.is_empty()); + v["webhook_set"] = json!(!c.webhook_url.is_empty()); + v["webhook_url"] = json!(""); + live_out.insert(k.clone(), v); + } + let placeholders: BTreeMap<&str, Vec<&str>> = embeds::ANNOUNCEMENT_PLACEHOLDERS.iter().map(|(k, v)| (*k, v.to_vec())).collect(); + Ok(Json(json!({ + "templates": templates, + "live": live_out, + "placeholders": placeholders, + "live_placeholders": { + "status": ["rows", "servers_online", "servers_total", "players_online", "players_max", "server_name", "updated", "time"], + "status_row": ["status_icon", "server", "status", "players", "max", "tps", "version", "last_seen"], + "guilds": ["rows", "guilds_total", "claims_total", "server_name", "updated", "time"], + "guilds_row": ["rank", "guild", "tag", "level", "members", "claims", "leader", "leader_avatar"], + "leaderboard": ["rows", "sort_label", "server_name", "updated", "time"], + "leaderboard_row": ["rank", "player", "avatar", "value", "playtime", "kills", "blocks", "level", "deaths", "mob_kills"], + "baltop": ["rows", "total", "server_name", "updated", "time"], + "baltop_row": ["rank", "player", "avatar", "balance"], + }, + "webhook_set": !discord::load(&state).await?.webhook_url.is_empty(), + }))) +} + +#[derive(Deserialize)] +pub struct TemplatesBody { + templates: BTreeMap, +} + +pub async fn put_templates(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + let known = embeds::default_templates(); + let mut next = BTreeMap::new(); + for (k, v) in b.templates { + if !known.contains_key(&k) { + return Err(AppError::bad_request("unknown message")); + } + check_style(&v)?; + next.insert(k, v); + } + store::kv_set(&state, "discord_templates", &next).await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct LiveBody { + live: BTreeMap, +} + +pub async fn put_live(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + let mut all = embeds::live_configs(&state).await?; + for (k, mut v) in b.live { + let Some(old) = all.get(&k) else { return Err(AppError::bad_request("unknown embed")) }; + check_style(&v.style)?; + let hook = v.webhook_url.trim().to_string(); + v.webhook_url = match hook.as_str() { + "" => old.webhook_url.clone(), // blank keeps the saved one; "-" clears it + "-" => String::new(), + h if discord::valid_webhook(h) => h.to_string(), + _ => return Err(AppError::bad_request("that is not a Discord webhook address")), + }; + v.interval_secs = v.interval_secs.clamp(60, 86_400); + v.limit = v.limit.clamp(1, 25); + if !["playtime", "level", "kills", "blocks"].contains(&v.sort.as_str()) { + v.sort = "playtime".into(); + } + v.message_id = old.message_id.clone(); + all.insert(k, v); + } + embeds::save_live(&state, &all).await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct PreviewBody { + /// `achievement`, `member`, `guild` or a live kind + kind: String, + style: Option, + live: Option, +} + +/// What the message would look like right now, as the exact JSON Discord receives. +pub async fn preview(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + if embeds::LIVE_KINDS.contains(&b.kind.as_str()) { + let cfg = b.live.ok_or_else(|| AppError::bad_request("missing embed settings"))?; + check_style(&cfg.style)?; + return Ok(Json(embeds::build_live(&state, &b.kind, &cfg).await?)); + } + let style = b.style.ok_or_else(|| AppError::bad_request("missing message settings"))?; + check_style(&style)?; + if !embeds::default_templates().contains_key(&b.kind) { + return Err(AppError::bad_request("unknown message")); + } + let vars = embeds::sample_vars(&state, &b.kind).await; + Ok(Json(embeds::render(&style, &vars, None))) +} + +/// Send the saved message to Discord now (a sample announcement, or a live embed refresh). +pub async fn send_now(_: AdminUser, State(state): State, Path(kind): Path) -> AppResult> { + if embeds::LIVE_KINDS.contains(&kind.as_str()) { + embeds::refresh_live(&state, &kind, false).await?; + return Ok(Json(json!({ "ok": true }))); + } + let templates = embeds::templates(&state).await?; + let style = templates.get(&kind).ok_or_else(|| AppError::bad_request("unknown message"))?; + let s = discord::load(&state).await?; + if s.webhook_url.is_empty() { + return Err(AppError::bad_request("save a webhook first")); + } + let vars = embeds::sample_vars(&state, &kind).await; + embeds::post(&state, &s.webhook_url, &embeds::render(style, &vars, None)).await?; + Ok(Json(json!({ "ok": true }))) +} + +/// Forget the posted message so the next update creates a new one (use after moving the embed to another channel). +pub async fn repost(_: AdminUser, State(state): State, Path(kind): Path) -> AppResult> { + if !embeds::LIVE_KINDS.contains(&kind.as_str()) { + return Err(AppError::bad_request("unknown embed")); + } + embeds::refresh_live(&state, &kind, true).await?; + Ok(Json(json!({ "ok": true }))) +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index f4709c6..4390c6c 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -8,6 +8,7 @@ pub mod economy; pub mod guild_bank; pub mod guilds; pub mod discord; +pub mod discord_studio; pub mod integrations; pub mod landing; pub mod worldmap; @@ -175,6 +176,12 @@ pub fn api(state: &AppState) -> Router { .route("/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward)) .route("/levels/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward)) .route("/levels/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward)) + .route("/discord/studio", get(discord_studio::get_all)) + .route("/discord/studio/templates", put(discord_studio::put_templates)) + .route("/discord/studio/live", put(discord_studio::put_live)) + .route("/discord/studio/preview", post(discord_studio::preview)) + .route("/discord/studio/send/{kind}", post(discord_studio::send_now)) + .route("/discord/studio/repost/{kind}", post(discord_studio::repost)) .route("/reward-bundles/{kind}/{id}", get(rewards::admin_get).put(rewards::admin_put)) .route("/reward-deliveries", get(rewards::admin_deliveries)) .route("/quests", get(quests::admin_list_quests).post(quests::admin_create_quest)) diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index 9e57e2f..2544796 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -1315,3 +1315,37 @@ mod tests { assert!(!ips_match("8.8.8.8", "1.1.1.1")); } } + +/// One server's public status, for Discord embeds and the like. +pub struct StatusRow { + pub id: i64, + pub name: String, + pub online: bool, + pub players: i64, + pub max_players: i64, + pub tps: Option, + pub software: String, + pub version: String, + pub last_seen: Option, +} + +pub async fn status_rows(state: &AppState) -> AppResult> { + let rows: Vec = sqlx::query_as("SELECT * FROM game_servers ORDER BY id").fetch_all(&state.db).await?; + Ok(rows + .into_iter() + .map(|s| { + let online = s.is_online(); + StatusRow { + id: s.id, + name: s.name.clone(), + online, + players: if online { s.online_count } else { 0 }, + max_players: s.max_players, + tps: if online { s.tps } else { None }, + software: s.software.clone().unwrap_or_default(), + version: s.mc_version.clone().unwrap_or_default(), + last_seen: s.last_seen.clone(), + } + }) + .collect()) +} diff --git a/panel/server/tests/discord_studio.rs b/panel/server/tests/discord_studio.rs new file mode 100644 index 0000000..8c0937a --- /dev/null +++ b/panel/server/tests/discord_studio.rs @@ -0,0 +1,58 @@ +mod common; +use common::*; + +#[tokio::test] +async fn discord_messages_are_customised_and_previewed_with_real_data() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (s, all) = t.call("GET", "/api/admin/discord/studio", Some(&admin), None).await; + assert_eq!(s, StatusCode::OK, "{all}"); + assert_eq!(all["templates"].as_object().unwrap().len(), 3); + assert_eq!(all["live"].as_object().unwrap().len(), 4); + assert!(all["placeholders"]["achievement"].as_array().unwrap().iter().any(|p| p == "achievement_description")); + + // Edit the achievement message; bad image addresses are refused. + let mut tpl = all["templates"].clone(); + tpl["achievement"]["title"] = json!("๐ŸŽ‰ {player} did it!"); + tpl["achievement"]["image"] = json!("javascript:evil"); + let (s, _) = t.call("PUT", "/api/admin/discord/studio/templates", Some(&admin), Some(json!({"templates": tpl}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + tpl["achievement"]["image"] = json!("https://cdn.example.com/banner.png"); + let (s, _) = t.call("PUT", "/api/admin/discord/studio/templates", Some(&admin), Some(json!({"templates": tpl.clone()}))).await; + assert_eq!(s, StatusCode::OK); + let (s, p) = t.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": "achievement", "style": tpl["achievement"]}))).await; + assert_eq!(s, StatusCode::OK, "{p}"); + let e = &p["embeds"][0]; + assert_eq!(e["title"], "๐ŸŽ‰ admin did it!"); + assert_eq!(e["image"]["url"], "https://cdn.example.com/banner.png"); + assert!(e["description"].as_str().unwrap().contains("Deep Diver")); + assert_eq!(e["fields"][0]["name"], "Reward"); + + // Live embeds show real data: a server, a guild and a rich player. + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await; + let steve_token = t.login("Steve", "password123").await; + let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Survival SMP", "instance_id": "smp"}))).await; + let sid = srv["server"]["id"].as_i64().unwrap(); + t.call("POST", "/api/v1/guilds", Some(&steve_token), Some(json!({"instance_id": "smp", "name": "Iron Wolves", "tag": "IRON"}))).await; + sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 123456, 'now')").bind(sid).bind(t.uuid("Steve").await).execute(&t.db).await.unwrap(); + for (kind, needle) in [("status", "Survival SMP"), ("guilds", "Iron Wolves"), ("baltop", "$123,456"), ("leaderboard", "Leaderboard")] { + let cfg = all["live"][kind].clone(); + let (s, p) = t.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": kind, "live": cfg}))).await; + assert_eq!(s, StatusCode::OK, "{kind}: {p}"); + assert!(p.to_string().contains(needle), "{kind} should mention {needle}: {p}"); + } + + // Interval and row limits are clamped; the webhook is write-only. + let mut live = all["live"].clone(); + live["status"]["interval_secs"] = json!(5); + live["status"]["limit"] = json!(500); + live["status"]["webhook_url"] = json!("https://evil.example.com/hook"); + assert_eq!(t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live.clone()}))).await.0, StatusCode::BAD_REQUEST); + live["status"]["webhook_url"] = json!("https://discord.com/api/webhooks/1/abc"); + assert_eq!(t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live}))).await.0, StatusCode::OK); + let (_, again) = t.call("GET", "/api/admin/discord/studio", Some(&admin), None).await; + assert_eq!(again["live"]["status"]["interval_secs"], 60); + assert_eq!(again["live"]["status"]["limit"], 25); + assert_eq!(again["live"]["status"]["webhook_set"], true); + assert_eq!(again["live"]["status"]["webhook_url"], ""); +}