Discord: customizable announcements and live embeds (status, guilds, leaderboard, baltop) backend
This commit is contained in:
7 files changed
+924
-15
No files matched your search
@@ -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<Field>,
|
||||
}
|
||||
|
||||
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<String, EmbedStyle> {
|
||||
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<BTreeMap<String, EmbedStyle>> {
|
||||
let mut stored: BTreeMap<String, EmbedStyle> = 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<String, String>;
|
||||
|
||||
/// 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::<String>()) }
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<Value> = 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::<String>().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!("<t:{}:f>", chrono::Utc::now().timestamp()));
|
||||
v.insert("updated".into(), format!("<t:{}:R>", 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<String>)> = 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<String> {
|
||||
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<bool> {
|
||||
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<String, LiveConfig> {
|
||||
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<BTreeMap<String, LiveConfig>> {
|
||||
let mut stored: BTreeMap<String, LiveConfig> = 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<String, LiveConfig>) -> 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::<String>())
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
let mut vars = common(state).await;
|
||||
let mut rows: Vec<String> = 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::<i64>().to_string());
|
||||
vars.insert("players_max".into(), list.iter().map(|s| s.max_players).sum::<i64>().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:{}:R>", 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<String, std::time::Instant>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u32> {
|
||||
}
|
||||
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<AppState>) -> 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 {
|
||||
|
||||
@@ -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<AppState>) -> AppResult<Json<Value>> {
|
||||
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<String, EmbedStyle>,
|
||||
}
|
||||
|
||||
pub async fn put_templates(_: AdminUser, State(state): State<AppState>, Json(b): Json<TemplatesBody>) -> AppResult<Json<Value>> {
|
||||
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<String, LiveConfig>,
|
||||
}
|
||||
|
||||
pub async fn put_live(_: AdminUser, State(state): State<AppState>, Json(b): Json<LiveBody>) -> AppResult<Json<Value>> {
|
||||
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<EmbedStyle>,
|
||||
live: Option<LiveConfig>,
|
||||
}
|
||||
|
||||
/// What the message would look like right now, as the exact JSON Discord receives.
|
||||
pub async fn preview(_: AdminUser, State(state): State<AppState>, Json(b): Json<PreviewBody>) -> AppResult<Json<Value>> {
|
||||
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<AppState>, Path(kind): Path<String>) -> AppResult<Json<Value>> {
|
||||
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<AppState>, Path(kind): Path<String>) -> AppResult<Json<Value>> {
|
||||
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 })))
|
||||
}
|
||||
@@ -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<AppState> {
|
||||
.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))
|
||||
|
||||
@@ -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<f64>,
|
||||
pub software: String,
|
||||
pub version: String,
|
||||
pub last_seen: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn status_rows(state: &AppState) -> AppResult<Vec<StatusRow>> {
|
||||
let rows: Vec<ServerRow> = 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())
|
||||
}
|
||||
@@ -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"], "");
|
||||
}
|
||||
Reference in new issue
Block a user