Panel: plugin integrations (LuckPerms ranks + group mapping, reports), server event queue, developer API endpoints
This commit is contained in:
10 files changed
+906
-2
No files matched your search
@@ -575,6 +575,85 @@ const MIGRATIONS: &[&str] = &[
|
||||
ALTER TABLE server_market ADD COLUMN seller_guild_id TEXT;
|
||||
ALTER TABLE guild_wallet_transactions ADD COLUMN note TEXT NOT NULL DEFAULT '';
|
||||
"#,
|
||||
// Plugin integrations (LuckPerms ranks, WorldGuard, Spark, CoreProtect…) and
|
||||
// events queued for game servers (level-ups, guild joins, achievements).
|
||||
r#"
|
||||
ALTER TABLE groups ADD COLUMN luckperms_group TEXT NOT NULL DEFAULT '';
|
||||
CREATE TABLE server_integrations (
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, name)
|
||||
);
|
||||
CREATE TABLE player_ranks (
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL,
|
||||
primary_group TEXT NOT NULL DEFAULT '',
|
||||
display TEXT NOT NULL DEFAULT '',
|
||||
prefix TEXT NOT NULL DEFAULT '',
|
||||
suffix TEXT NOT NULL DEFAULT '',
|
||||
groups TEXT NOT NULL DEFAULT '[]',
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
weight INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, uuid)
|
||||
);
|
||||
CREATE INDEX player_ranks_uuid ON player_ranks(uuid);
|
||||
CREATE TABLE server_notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
uuid TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX server_notifications_server ON server_notifications(server_id, id);
|
||||
"#,
|
||||
// Every code path that changes a level, unlocks an achievement or moves a
|
||||
// member queues the matching event for the servers the player is on.
|
||||
r#"
|
||||
CREATE TRIGGER notify_global_level AFTER UPDATE OF global_level ON user_levels WHEN NEW.global_level > OLD.global_level BEGIN
|
||||
INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at)
|
||||
SELECT so.server_id, 'level_up', NEW.uuid,
|
||||
json_object('scope', 'global', 'level', NEW.global_level, 'previous', OLD.global_level, 'xp', NEW.global_xp),
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
FROM server_online so WHERE so.uuid = NEW.uuid;
|
||||
END;
|
||||
CREATE TRIGGER notify_server_level AFTER UPDATE OF server_level ON server_levels WHEN NEW.server_level > OLD.server_level BEGIN
|
||||
INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at)
|
||||
SELECT NEW.server_id, 'level_up', NEW.uuid,
|
||||
json_object('scope', 'server', 'level', NEW.server_level, 'previous', OLD.server_level, 'xp', NEW.server_xp),
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE EXISTS (SELECT 1 FROM server_online so WHERE so.server_id = NEW.server_id AND so.uuid = NEW.uuid);
|
||||
END;
|
||||
CREATE TRIGGER notify_achievement AFTER INSERT ON user_achievements BEGIN
|
||||
INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at)
|
||||
SELECT so.server_id, 'achievement', NEW.user_uuid,
|
||||
json_object('id', NEW.achievement_id,
|
||||
'title', (SELECT title FROM achievements WHERE id = NEW.achievement_id),
|
||||
'xp', (SELECT xp_reward FROM achievements WHERE id = NEW.achievement_id)),
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
FROM server_online so WHERE so.uuid = NEW.user_uuid;
|
||||
END;
|
||||
CREATE TRIGGER notify_guild_join AFTER INSERT ON guild_members BEGIN
|
||||
INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at)
|
||||
SELECT so.server_id, 'guild_join', NEW.uuid,
|
||||
json_object('guild_id', NEW.guild_id, 'guild', (SELECT name FROM guilds WHERE id = NEW.guild_id),
|
||||
'tag', (SELECT tag FROM guilds WHERE id = NEW.guild_id), 'role', NEW.role),
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
FROM server_online so WHERE so.uuid = NEW.uuid;
|
||||
END;
|
||||
CREATE TRIGGER notify_guild_leave AFTER DELETE ON guild_members BEGIN
|
||||
INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at)
|
||||
SELECT so.server_id, 'guild_leave', OLD.uuid,
|
||||
json_object('guild_id', OLD.guild_id, 'guild', (SELECT name FROM guilds WHERE id = OLD.guild_id),
|
||||
'tag', (SELECT tag FROM guilds WHERE id = OLD.guild_id)),
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
FROM server_online so WHERE so.uuid = OLD.uuid;
|
||||
END;
|
||||
"#,
|
||||
// Bumped by any change to claims, guilds or membership so game servers can
|
||||
// poll a single number instead of asking about every block.
|
||||
r#"
|
||||
|
||||
@@ -63,6 +63,8 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
|
||||
// ---- gameplay records ----
|
||||
report.add("stats", run(c, "DELETE FROM player_stats WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("stats", run(c, "DELETE FROM server_online WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("ranks", run(c, "DELETE FROM player_ranks WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("notifications", run(c, "DELETE FROM server_notifications WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("activity", run(c, "DELETE FROM server_events WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("activity", run(c, "DELETE FROM events WHERE uuid = ? OR (uuid IS NULL AND username = ? COLLATE NOCASE)", &[uuid, name]).await?);
|
||||
|
||||
|
||||
@@ -248,6 +248,10 @@ pub struct Group {
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
members: i64,
|
||||
/// LuckPerms group this panel group is mapped to (empty: none).
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
luckperms_group: String,
|
||||
}
|
||||
fn default_color() -> String {
|
||||
"#7c5cff".into()
|
||||
@@ -256,7 +260,7 @@ fn default_color() -> String {
|
||||
pub async fn list_groups(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<Group>>> {
|
||||
Ok(Json(
|
||||
sqlx::query_as(
|
||||
"SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members FROM groups g ORDER BY g.name",
|
||||
"SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members, g.luckperms_group FROM groups g ORDER BY g.name",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Data and actions for in-game plugins: what the SCOPENET Developer API, the
|
||||
//! PlaceholderAPI expansion and other plugins read and change for a player.
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::routes::economy::{begin_operation, finish_operation};
|
||||
use crate::routes::servers::GameServer;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Who {
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
fn dashed_or_err(uuid: &str) -> AppResult<String> {
|
||||
crate::yggdrasil::dashed(uuid).ok_or_else(|| AppError::bad_request("invalid player UUID"))
|
||||
}
|
||||
|
||||
/// Everything a placeholder or `getPlayer()` needs in one round trip.
|
||||
pub async fn player_info(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Who>) -> AppResult<Json<Value>> {
|
||||
let uuid = dashed_or_err(&p.uuid)?;
|
||||
let user: Option<(String, String, String)> = sqlx::query_as("SELECT username, role, created_at FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let Some((name, role, joined)) = user else {
|
||||
return Ok(Json(json!({ "exists": false, "uuid": uuid })));
|
||||
};
|
||||
let levels = crate::routes::leveling::get_user_levels_by_uuid(&state, &uuid).await?;
|
||||
let here = levels.server_levels.iter().find(|s| s.server_id == server.id);
|
||||
let guild: Option<(String, String, String, String, i64)> = sqlx::query_as(
|
||||
"SELECT g.id, g.name, g.tag, gm.role, (SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id)
|
||||
FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?",
|
||||
)
|
||||
.bind(&uuid)
|
||||
.bind(&server.instance_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let balance = crate::routes::economy::balance_for(&state, server.id, &uuid).await?;
|
||||
let (playtime, kills, deaths): (Option<i64>, Option<i64>, Option<i64>) =
|
||||
sqlx::query_as("SELECT SUM(playtime_secs), SUM(player_kills + mob_kills), SUM(deaths) FROM player_stats WHERE uuid = ?").bind(&uuid).fetch_one(&state.db).await?;
|
||||
let (server_playtime,): (Option<i64>,) = sqlx::query_as("SELECT SUM(playtime_secs) FROM player_stats WHERE uuid = ? AND server_id = ?").bind(&uuid).bind(server.id).fetch_one(&state.db).await?;
|
||||
let friends: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ?1 OR friend_uuid = ?1)").bind(&uuid).fetch_one(&state.db).await?;
|
||||
let achievements: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?").bind(&uuid).fetch_one(&state.db).await?;
|
||||
|
||||
// Quest progress for this period: how many of the player's quests are done.
|
||||
let cfg = crate::progression::load_pool(&state.db).await?;
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let mut quests = serde_json::Map::new();
|
||||
for (period, key) in [("daily", crate::routes::quests::current_daily_key()), ("weekly", crate::routes::quests::current_weekly_key())] {
|
||||
let ids = crate::progression::assigned_quest_ids(&mut conn, &cfg, &uuid, period, &key).await?;
|
||||
let (done, claimed): (i64, i64) = if ids.is_empty() {
|
||||
(0, 0)
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(completed), 0), COALESCE(SUM(claimed), 0) FROM user_quests
|
||||
WHERE user_uuid = ? AND period_key = ? AND quest_id IN (SELECT value FROM json_each(?))",
|
||||
)
|
||||
.bind(&uuid)
|
||||
.bind(&key)
|
||||
.bind(serde_json::to_string(&ids)?)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?
|
||||
};
|
||||
quests.insert(period.into(), json!({ "total": ids.len(), "completed": done, "claimed": claimed }));
|
||||
}
|
||||
drop(conn); // give the connection back before the pool is used again
|
||||
let rank: Option<(String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"exists": true,
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"joined": joined,
|
||||
"online": online,
|
||||
"global": { "level": levels.global_level, "xp": levels.global_xp, "next_level_xp": levels.next_level_xp, "current_level_xp": levels.current_level_xp, "progress_pct": levels.progress_pct, "title": levels.title, "rank": levels.rank, "badges": levels.badges },
|
||||
"server": here.map(|s| json!({ "level": s.server_level, "xp": s.server_xp, "next_level_xp": s.next_level_xp, "current_level_xp": s.current_level_xp, "progress_pct": s.progress_pct, "rank_name": s.rank_name }))
|
||||
.unwrap_or(json!({ "level": 1, "xp": 0, "next_level_xp": 0, "current_level_xp": 0, "progress_pct": 0.0, "rank_name": null })),
|
||||
"guild": guild.map(|(id, name, tag, role, claims)| json!({ "id": id, "name": name, "tag": tag, "role": role, "claims": claims })),
|
||||
"balance": balance,
|
||||
"playtime_secs": playtime.unwrap_or(0),
|
||||
"server_playtime_secs": server_playtime.unwrap_or(0),
|
||||
"kills": kills.unwrap_or(0),
|
||||
"deaths": deaths.unwrap_or(0),
|
||||
"friends": friends,
|
||||
"achievements": achievements,
|
||||
"quests": quests,
|
||||
"rank": rank.map(|(primary, display, prefix)| json!({ "group": primary, "display": display, "prefix": prefix })),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct XpPayload {
|
||||
pub operation_id: String,
|
||||
pub uuid: String,
|
||||
/// `global` or `server` (the calling server).
|
||||
#[serde(default = "global")]
|
||||
pub scope: String,
|
||||
/// Positive grants XP, negative takes it away.
|
||||
pub amount: i64,
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
fn global() -> String {
|
||||
"global".into()
|
||||
}
|
||||
|
||||
/// `addXP()`: grant (or remove) XP on behalf of a plugin. Idempotent on `operation_id`.
|
||||
pub async fn add_xp(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<XpPayload>) -> AppResult<Json<Value>> {
|
||||
let uuid = dashed_or_err(&p.uuid)?;
|
||||
if p.amount == 0 || p.amount.abs() > 10_000_000 {
|
||||
return Err(AppError::bad_request("amount must be between -10,000,000 and 10,000,000 and not zero"));
|
||||
}
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||
if let Some(previous) = previous {
|
||||
return Ok(Json(previous));
|
||||
}
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?)").bind(&uuid).fetch_one(&mut *tx).await?;
|
||||
if !exists {
|
||||
return Err(AppError::not_found("player not found"));
|
||||
}
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let (mode, amount) = if p.amount > 0 { (crate::progression::Mode::Add, p.amount) } else { (crate::progression::Mode::Remove, -p.amount) };
|
||||
let change = match p.scope.as_str() {
|
||||
"global" => crate::progression::adjust_global(&mut tx, &uuid, mode, amount, &now).await?,
|
||||
"server" => crate::progression::adjust_server(&mut tx, server.id, &uuid, mode, amount, &now).await?,
|
||||
_ => return Err(AppError::bad_request("scope must be global or server")),
|
||||
};
|
||||
if change.new_xp > change.old_xp {
|
||||
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?;
|
||||
}
|
||||
let detail = format!("{} XP {} → {} (level {} → {}) via {}: {}", p.scope, change.old_xp, change.new_xp, change.old_level, change.new_level, server.name, p.reason.chars().filter(|c| !c.is_control()).take(100).collect::<String>());
|
||||
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, NULL, 'xp', ?, ?)").bind(server.id).bind(&uuid).bind(detail).bind(crate::db::now()).execute(&mut *tx).await?;
|
||||
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "scope": p.scope, "xp": change.new_xp, "level": change.new_level, "previous_level": change.old_level })).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ObjectivePayload {
|
||||
pub operation_id: String,
|
||||
pub uuid: String,
|
||||
/// A quest id (`q_d_mine_coal`), or an action such as `block_broken:DIAMOND_ORE`.
|
||||
pub objective: String,
|
||||
/// Progress to add. `0` completes the quest outright.
|
||||
#[serde(default)]
|
||||
pub amount: i64,
|
||||
}
|
||||
|
||||
/// `completeQuestObjective()`: move a player's quest forward from another plugin.
|
||||
pub async fn quest_objective(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<ObjectivePayload>) -> AppResult<Json<Value>> {
|
||||
let uuid = dashed_or_err(&p.uuid)?;
|
||||
if !(0..=1_000_000).contains(&p.amount) || p.objective.is_empty() || p.objective.len() > 120 {
|
||||
return Err(AppError::bad_request("invalid objective"));
|
||||
}
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||
if let Some(previous) = previous {
|
||||
return Ok(Json(previous));
|
||||
}
|
||||
let now = crate::db::now();
|
||||
let quest: Option<(String, i64)> = sqlx::query_as("SELECT period, target_count FROM quests WHERE id = ? AND enabled = 1").bind(&p.objective).fetch_optional(&mut *tx).await?;
|
||||
let advanced = if let Some((period, target)) = quest {
|
||||
let cfg = crate::progression::load(&mut tx).await?;
|
||||
let key = if period == "weekly" { crate::routes::quests::current_weekly_key() } else { crate::routes::quests::current_daily_key() };
|
||||
if !crate::progression::is_assigned(&mut tx, &cfg, &uuid, &period, &key, &p.objective).await? {
|
||||
return Err(AppError::bad_request("that quest isn't one of the player's quests this period"));
|
||||
}
|
||||
let add = if p.amount == 0 { target } else { p.amount };
|
||||
sqlx::query(
|
||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||
ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET progress = progress + excluded.progress,
|
||||
completed = CASE WHEN progress + excluded.progress >= ? THEN 1 ELSE completed END, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(&uuid).bind(&p.objective).bind(&key).bind(add).bind(add >= target).bind(&now).bind(target)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
true
|
||||
} else {
|
||||
// Not a quest id: treat it as an action ("block_broken:STONE") like the plugin's own events.
|
||||
let before: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?").bind(&uuid).fetch_one(&mut *tx).await?;
|
||||
crate::routes::quests::advance_action_quests(&mut tx, &uuid, &p.objective, p.amount.max(1), &now).await?;
|
||||
let after: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?").bind(&uuid).fetch_one(&mut *tx).await?;
|
||||
after > before
|
||||
};
|
||||
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "advanced": advanced })).await
|
||||
}
|
||||
|
||||
/// `getFriends()`: accepted friends with where they are playing.
|
||||
pub async fn friends(GameServer(_): GameServer, State(state): State<AppState>, Json(p): Json<Who>) -> AppResult<Json<Value>> {
|
||||
let uuid = dashed_or_err(&p.uuid)?;
|
||||
let rows: Vec<(String, String, bool, Option<String>)> = sqlx::query_as(
|
||||
"SELECT u.uuid, u.username, EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = u.uuid),
|
||||
(SELECT gs.name FROM server_online so JOIN game_servers gs ON gs.id = so.server_id WHERE so.uuid = u.uuid LIMIT 1)
|
||||
FROM friendships f JOIN users u ON u.uuid = (CASE WHEN f.user_uuid = ?1 THEN f.friend_uuid ELSE f.user_uuid END)
|
||||
WHERE f.status = 'accepted' AND (f.user_uuid = ?1 OR f.friend_uuid = ?1) ORDER BY 3 DESC, u.username COLLATE NOCASE LIMIT 500",
|
||||
)
|
||||
.bind(&uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "friends": rows.into_iter().map(|(uuid, name, online, playing_on)| json!({ "uuid": uuid, "name": name, "online": online, "playing_on": playing_on })).collect::<Vec<_>>() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GuildQuery {
|
||||
/// Look a guild up by id, tag or name — or by one of its members.
|
||||
#[serde(default)]
|
||||
pub guild: String,
|
||||
#[serde(default)]
|
||||
pub member: String,
|
||||
}
|
||||
|
||||
/// `getGuild()`.
|
||||
pub async fn guild(GameServer(server): GameServer, State(state): State<AppState>, Json(q): Json<GuildQuery>) -> AppResult<Json<Value>> {
|
||||
let id: Option<String> = if !q.member.is_empty() {
|
||||
let uuid = dashed_or_err(&q.member)?;
|
||||
sqlx::query_scalar("SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?").bind(uuid).bind(&server.instance_id).fetch_optional(&state.db).await?
|
||||
} else {
|
||||
sqlx::query_scalar("SELECT id FROM guilds WHERE instance_id = ?2 AND (id = ?1 OR tag = ?1 COLLATE NOCASE OR name = ?1 COLLATE NOCASE)").bind(q.guild.trim()).bind(&server.instance_id).fetch_optional(&state.db).await?
|
||||
};
|
||||
let Some(id) = id else { return Ok(Json(json!({ "exists": false }))) };
|
||||
let (name, tag, desc, leader, created): (String, String, String, String, String) =
|
||||
sqlx::query_as("SELECT name, tag, description, leader_uuid, created_at FROM guilds WHERE id = ?").bind(&id).fetch_one(&state.db).await?;
|
||||
let members: Vec<(String, String, String)> = sqlx::query_as("SELECT uuid, name, role FROM guild_members WHERE guild_id = ? ORDER BY joined_at").bind(&id).fetch_all(&state.db).await?;
|
||||
let claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ? AND server_id = ?").bind(&id).bind(server.id).fetch_one(&state.db).await?;
|
||||
let balance: f64 = sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0)").bind(&id).bind(server.id).fetch_one(&state.db).await?;
|
||||
Ok(Json(json!({
|
||||
"exists": true, "id": id, "name": name, "tag": tag, "description": desc, "leader": leader, "created": created,
|
||||
"claims": claims, "balance": balance,
|
||||
"members": members.into_iter().map(|(uuid, name, role)| json!({ "uuid": uuid, "name": name, "role": role })).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
@@ -213,6 +213,11 @@ pub struct ServerBalanceQuery {
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
/// A player's balance on a server (0 until they have an account there).
|
||||
pub async fn balance_for(state: &AppState, server_id: i64, uuid: &str) -> AppResult<f64> {
|
||||
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&state.db).await?.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
pub async fn server_get_balance(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Plugin integrations: what game servers report about the plugins they run
|
||||
//! (LuckPerms ranks, WorldGuard regions, Spark performance, CoreProtect
|
||||
//! activity…), the LuckPerms ↔ panel group mapping, and the queue of events
|
||||
//! (level-ups, guild joins, achievements) handed back to game servers.
|
||||
|
||||
use crate::auth::AdminUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::routes::servers::{get_server, GameServer};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqliteConnection;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const MAX_REPORT_BYTES: usize = 1024 * 1024;
|
||||
const KNOWN: &[&str] = &["luckperms", "placeholderapi", "vault", "coreprotect", "worldguard", "spark", "compat"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// How LuckPerms groups and panel groups follow each other. LuckPerms stays
|
||||
/// authoritative for Minecraft permissions; this only moves *membership* of
|
||||
/// groups that an admin has explicitly mapped.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct IntegrationSettings {
|
||||
/// `off` (show ranks only), `game_to_panel`, `panel_to_game` or `both` (adds in both directions, never removes).
|
||||
pub luckperms_sync: String,
|
||||
}
|
||||
|
||||
impl Default for IntegrationSettings {
|
||||
fn default() -> Self {
|
||||
Self { luckperms_sync: "off".into() }
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_settings(conn: &mut SqliteConnection) -> AppResult<IntegrationSettings> {
|
||||
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'integrations'").fetch_optional(&mut *conn).await?;
|
||||
Ok(raw.and_then(|r| serde_json::from_str(&r).ok()).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
Ok(Json(json!({ "settings": load_settings(&mut conn).await? })))
|
||||
}
|
||||
|
||||
pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(s): Json<IntegrationSettings>) -> AppResult<Json<Value>> {
|
||||
if !matches!(s.luckperms_sync.as_str(), "off" | "game_to_panel" | "panel_to_game" | "both") {
|
||||
return Err(AppError::bad_request("luckperms_sync must be off, game_to_panel, panel_to_game or both"));
|
||||
}
|
||||
crate::store::kv_set(&state, "integrations", &s).await?;
|
||||
Ok(Json(json!({ "settings": s })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GroupMapping {
|
||||
/// The LuckPerms group this panel group follows. Empty clears the mapping.
|
||||
pub luckperms_group: String,
|
||||
}
|
||||
|
||||
pub async fn set_group_mapping(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>, Json(m): Json<GroupMapping>) -> AppResult<Json<Value>> {
|
||||
let lp = m.luckperms_group.trim();
|
||||
if lp.len() > 64 || !lp.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) {
|
||||
return Err(AppError::bad_request("LuckPerms group names are letters, digits, _ - and ."));
|
||||
}
|
||||
let done = sqlx::query("UPDATE groups SET luckperms_group = ? WHERE id = ?").bind(lp.to_lowercase()).bind(id).execute(&state.db).await?;
|
||||
if done.rows_affected() == 0 {
|
||||
return Err(AppError::not_found("group not found"));
|
||||
}
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LuckPerms planning (pure, so it can be tested without a database)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Plan {
|
||||
pub panel_add: Vec<i64>,
|
||||
pub panel_remove: Vec<i64>,
|
||||
pub game_add: Vec<String>,
|
||||
pub game_remove: Vec<String>,
|
||||
}
|
||||
|
||||
/// `mapped`: (panel group id, LuckPerms group). `member_of`: the panel groups the player is in.
|
||||
pub fn plan(mode: &str, lp_groups: &HashSet<String>, mapped: &[(i64, String)], member_of: &HashSet<i64>) -> Plan {
|
||||
let mut p = Plan::default();
|
||||
let (to_panel, to_game, remove) = match mode {
|
||||
"game_to_panel" => (true, false, true),
|
||||
"panel_to_game" => (false, true, true),
|
||||
"both" => (true, true, false),
|
||||
_ => return p,
|
||||
};
|
||||
for (gid, lp) in mapped {
|
||||
let in_game = lp_groups.contains(lp);
|
||||
let in_panel = member_of.contains(gid);
|
||||
match (in_game, in_panel) {
|
||||
(true, false) if to_panel => p.panel_add.push(*gid),
|
||||
(false, true) if to_panel && remove && mode == "game_to_panel" => p.panel_remove.push(*gid),
|
||||
(false, true) if to_game => p.game_add.push(lp.clone()),
|
||||
(true, false) if to_game && remove && mode == "panel_to_game" => p.game_remove.push(lp.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct LpPlayer {
|
||||
uuid: String,
|
||||
primary: String,
|
||||
display: String,
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
weight: i64,
|
||||
groups: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
}
|
||||
|
||||
fn clean(s: &str, max: usize) -> String {
|
||||
s.chars().filter(|c| !c.is_control() || *c == '\u{a7}').take(max).collect()
|
||||
}
|
||||
|
||||
/// Store reported ranks and work out group changes for the players in the report.
|
||||
async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Value) -> AppResult<Value> {
|
||||
let settings = load_settings(conn).await?;
|
||||
let players: Vec<LpPlayer> = serde_json::from_value(data.get("players").cloned().unwrap_or(json!([]))).unwrap_or_default();
|
||||
let mapped: Vec<(i64, String)> = sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?;
|
||||
let now = crate::db::now();
|
||||
let mut assign = Vec::new();
|
||||
for p in players.into_iter().take(2000) {
|
||||
let Some(uuid) = crate::yggdrasil::dashed(&p.uuid) else { continue };
|
||||
let groups: Vec<String> = p.groups.iter().map(|g| clean(g, 64).to_lowercase()).filter(|g| !g.is_empty()).take(64).collect();
|
||||
let perms: Vec<String> = p.permissions.iter().map(|x| clean(x, 100)).take(40).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO player_ranks (server_id, uuid, primary_group, display, prefix, suffix, groups, permissions, weight, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (server_id, uuid) DO UPDATE SET primary_group = excluded.primary_group, display = excluded.display,
|
||||
prefix = excluded.prefix, suffix = excluded.suffix, groups = excluded.groups, permissions = excluded.permissions,
|
||||
weight = excluded.weight, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(&uuid)
|
||||
.bind(clean(&p.primary, 64).to_lowercase())
|
||||
.bind(clean(&p.display, 64))
|
||||
.bind(clean(&p.prefix, 64))
|
||||
.bind(clean(&p.suffix, 64))
|
||||
.bind(serde_json::to_string(&groups)?)
|
||||
.bind(serde_json::to_string(&perms)?)
|
||||
.bind(p.weight.clamp(-1_000_000, 1_000_000))
|
||||
.bind(&now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
if settings.luckperms_sync == "off" || mapped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let user_id: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE uuid = ? AND status = 'active'").bind(&uuid).fetch_optional(&mut *conn).await?;
|
||||
let Some(user_id) = user_id else { continue };
|
||||
let member_of: HashSet<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&mut *conn).await?.into_iter().collect();
|
||||
let lp_set: HashSet<String> = groups.iter().cloned().collect();
|
||||
let plan = plan(&settings.luckperms_sync, &lp_set, &mapped, &member_of);
|
||||
for gid in &plan.panel_add {
|
||||
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(gid).execute(&mut *conn).await?;
|
||||
}
|
||||
for gid in &plan.panel_remove {
|
||||
sqlx::query("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?").bind(user_id).bind(gid).execute(&mut *conn).await?;
|
||||
}
|
||||
if !plan.game_add.is_empty() || !plan.game_remove.is_empty() {
|
||||
assign.push(json!({ "uuid": uuid, "add": plan.game_add, "remove": plan.game_remove }));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "mode": settings.luckperms_sync, "assign": assign }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Game server reports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Report {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
pub async fn server_report(GameServer(server): GameServer, State(state): State<AppState>, Json(r): Json<Report>) -> AppResult<Json<Value>> {
|
||||
let name = r.name.trim().to_lowercase();
|
||||
if !KNOWN.contains(&name.as_str()) {
|
||||
return Err(AppError::bad_request("unknown integration"));
|
||||
}
|
||||
let raw = serde_json::to_string(&r.data)?;
|
||||
if raw.len() > MAX_REPORT_BYTES {
|
||||
return Err(AppError::bad_request("report is too large"));
|
||||
}
|
||||
let mut tx = state.db.begin().await?;
|
||||
let config = if name == "luckperms" { apply_luckperms(&mut tx, server.id, &r.data).await? } else { json!({}) };
|
||||
// Player-specific rank rows are the stored form of the LuckPerms report; keep only the group list.
|
||||
let stored = if name == "luckperms" {
|
||||
json!({ "groups": r.data.get("groups").cloned().unwrap_or(json!([])), "players_reported": r.data.get("players").and_then(|p| p.as_array()).map_or(0, |a| a.len()) }).to_string()
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO server_integrations (server_id, name, version, data, updated_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (server_id, name) DO UPDATE SET version = excluded.version, data = excluded.data, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&name)
|
||||
.bind(clean(&r.version, 40))
|
||||
.bind(stored)
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({ "ok": true, "config": config })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn admin_server(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
get_server(&state, id).await?;
|
||||
let rows: Vec<(String, String, String, String)> =
|
||||
sqlx::query_as("SELECT name, version, data, updated_at FROM server_integrations WHERE server_id = ? ORDER BY name").bind(id).fetch_all(&state.db).await?;
|
||||
let list: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(name, version, data, updated_at)| json!({ "name": name, "version": version, "data": serde_json::from_str::<Value>(&data).unwrap_or(Value::Null), "updated_at": updated_at }))
|
||||
.collect();
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let settings = load_settings(&mut conn).await?;
|
||||
let ranks: Vec<(String, String, String, String, String, String, String, i64)> = sqlx::query_as(
|
||||
"SELECT r.uuid, COALESCE(u.username, r.uuid), r.primary_group, r.display, r.prefix, r.groups, r.permissions, r.weight
|
||||
FROM player_ranks r LEFT JOIN users u ON u.uuid = r.uuid WHERE r.server_id = ? ORDER BY r.weight DESC, 2 LIMIT 200",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"integrations": list,
|
||||
"settings": settings,
|
||||
"ranks": ranks.into_iter().map(|(uuid, name, primary, display, prefix, groups, perms, weight)| json!({
|
||||
"uuid": uuid, "name": name, "primary": primary, "display": display, "prefix": prefix, "weight": weight,
|
||||
"groups": serde_json::from_str::<Value>(&groups).unwrap_or(json!([])),
|
||||
"permissions": serde_json::from_str::<Value>(&perms).unwrap_or(json!([])),
|
||||
})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications: events the panel hands to game servers with their next sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Queue an event for every game server the player is online on (or for one
|
||||
/// specific server). Plugins turn these into Bukkit events.
|
||||
pub async fn notify(conn: &mut SqliteConnection, server_id: Option<i64>, kind: &str, uuid: &str, payload: Value) -> AppResult<()> {
|
||||
let servers: Vec<i64> = match server_id {
|
||||
Some(id) => vec![id],
|
||||
None => sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(uuid).fetch_all(&mut *conn).await?,
|
||||
};
|
||||
for id in servers {
|
||||
sqlx::query("INSERT INTO server_notifications (server_id, kind, uuid, payload, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(id)
|
||||
.bind(kind)
|
||||
.bind(uuid)
|
||||
.bind(payload.to_string())
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take (and delete) what is waiting for this server. Oldest first, bounded.
|
||||
pub async fn take_notifications(conn: &mut SqliteConnection, server_id: i64) -> AppResult<Vec<Value>> {
|
||||
let rows: Vec<(i64, String, String, String, String)> =
|
||||
sqlx::query_as("SELECT id, kind, uuid, payload, created_at FROM server_notifications WHERE server_id = ? ORDER BY id LIMIT 100").bind(server_id).fetch_all(&mut *conn).await?;
|
||||
if let Some(last) = rows.last().map(|r| r.0) {
|
||||
sqlx::query("DELETE FROM server_notifications WHERE server_id = ? AND id <= ?").bind(server_id).bind(last).execute(&mut *conn).await?;
|
||||
}
|
||||
// Stale entries (a server that was offline for days) aren't worth replaying.
|
||||
sqlx::query("DELETE FROM server_notifications WHERE created_at < ?").bind((chrono::Utc::now() - chrono::Duration::hours(6)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)).execute(&mut *conn).await?;
|
||||
Ok(rows.into_iter().map(|(_, kind, uuid, payload, at)| json!({ "kind": kind, "uuid": uuid, "data": serde_json::from_str::<Value>(&payload).unwrap_or(Value::Null), "at": at })).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn set(v: &[&str]) -> HashSet<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_modes_only_move_mapped_groups() {
|
||||
let mapped = vec![(1, "vip".to_string()), (2, "mod".to_string())];
|
||||
let none: HashSet<i64> = HashSet::new();
|
||||
let in_vip: HashSet<i64> = [1].into();
|
||||
|
||||
assert_eq!(plan("off", &set(&["vip"]), &mapped, &none), Plan::default());
|
||||
// LuckPerms is followed, in both directions, for mapped groups only.
|
||||
let p = plan("game_to_panel", &set(&["vip", "default"]), &mapped, &none);
|
||||
assert_eq!((p.panel_add, p.panel_remove), (vec![1], vec![]));
|
||||
let p = plan("game_to_panel", &set(&["default"]), &mapped, &in_vip);
|
||||
assert_eq!((p.panel_add, p.panel_remove), (vec![], vec![1]));
|
||||
// The panel leads the other way round.
|
||||
let p = plan("panel_to_game", &set(&[]), &mapped, &in_vip);
|
||||
assert_eq!((p.game_add, p.game_remove), (vec!["vip".to_string()], vec![]));
|
||||
let p = plan("panel_to_game", &set(&["mod"]), &mapped, &none);
|
||||
assert_eq!(p.game_remove, vec!["mod".to_string()]);
|
||||
// "both" only ever adds.
|
||||
let p = plan("both", &set(&["mod"]), &mapped, &in_vip);
|
||||
assert_eq!((p.panel_add, p.panel_remove, p.game_add, p.game_remove), (vec![2], vec![], vec!["vip".to_string()], vec![]));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ pub mod achievements;
|
||||
pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod connections;
|
||||
pub mod dev_api;
|
||||
pub mod economy;
|
||||
pub mod guild_bank;
|
||||
pub mod guilds;
|
||||
pub mod integrations;
|
||||
pub mod landing;
|
||||
pub mod livemap;
|
||||
pub mod leveling;
|
||||
@@ -165,6 +167,9 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/progression/players", get(progression::list_players))
|
||||
.route("/progression/players/{uuid}", get(progression::get_player))
|
||||
.route("/progression/players/{uuid}/adjust", post(progression::adjust_player))
|
||||
.route("/integrations/settings", get(integrations::get_settings).put(integrations::put_settings))
|
||||
.route("/groups/{id}/luckperms", put(integrations::set_group_mapping))
|
||||
.route("/servers/{id}/integrations", get(integrations::admin_server))
|
||||
.route("/guilds", get(guilds::admin_list_guilds))
|
||||
.route("/guilds/{id}", delete(guilds::admin_delete_guild))
|
||||
.layer(DefaultBodyLimit::max(upload_limit));
|
||||
@@ -182,6 +187,12 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/guilds/player", post(guilds::server_get_player_guild))
|
||||
.route("/guilds/create", post(guilds::server_create_guild))
|
||||
.route("/guilds/leave", post(guilds::server_guild_leave))
|
||||
.route("/integrations/report", post(integrations::server_report))
|
||||
.route("/player/info", post(dev_api::player_info))
|
||||
.route("/player/xp", post(dev_api::add_xp))
|
||||
.route("/player/quest-objective", post(dev_api::quest_objective))
|
||||
.route("/player/friends", post(dev_api::friends))
|
||||
.route("/guild/info", post(dev_api::guild))
|
||||
.route("/guilds/bank", post(guild_bank::server_bank_info))
|
||||
.route("/guilds/bank/transfer", post(guild_bank::server_bank_transfer))
|
||||
.route("/guilds/bank/credit", post(guild_bank::server_bank_credit))
|
||||
|
||||
@@ -578,7 +578,12 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
kick.push(json!({ "uuid": uuid, "message": verdict.message }));
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "ok": true, "kick": kick, "sync_interval_secs": SYNC_INTERVAL_SECS })))
|
||||
// Events for plugins to turn into their own (level-ups, achievements, guild changes).
|
||||
let notifications = {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
super::integrations::take_notifications(&mut conn, server.id).await?
|
||||
};
|
||||
Ok(Json(json!({ "ok": true, "kick": kick, "sync_interval_secs": SYNC_INTERVAL_SECS, "notifications": notifications })))
|
||||
}
|
||||
|
||||
async fn update_progression_for_delta(
|
||||
|
||||
Reference in new issue
Block a user