Discord community: role sync, announcements webhook, launcher invite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
11 files changed
+513
-2
No files matched your search
@@ -252,6 +252,9 @@ pub struct Group {
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
luckperms_group: String,
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
discord_role: String,
|
||||
}
|
||||
fn default_color() -> String {
|
||||
"#7c5cff".into()
|
||||
@@ -260,7 +263,7 @@ fn default_color() -> String {
|
||||
pub async fn list_groups(_: AdminUser, State(state): State<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, g.luckperms_group FROM groups g ORDER BY g.name",
|
||||
"SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members, g.luckperms_group, g.discord_role FROM groups g ORDER BY g.name",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?,
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
//! Discord community features on top of account linking: role <-> group
|
||||
//! sync, an invite link for launchers, and optional webhook announcements.
|
||||
//!
|
||||
//! Everything here is opt-in and needs the bot token and server ID saved in
|
||||
//! Settings. Sync only ever *adds* membership, in the direction the admin picks.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::routes::connections::ConnectionsSettings;
|
||||
use crate::state::AppState;
|
||||
use crate::store;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
const API: &str = "https://discord.com/api/v10";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct DiscordSettings {
|
||||
/// `off`, `discord_to_panel`, `panel_to_discord` or `both`.
|
||||
pub role_sync: String,
|
||||
/// Shown to players in the launcher.
|
||||
pub invite_url: String,
|
||||
/// Announcements go here. Empty turns them off.
|
||||
pub webhook_url: String,
|
||||
pub notify_achievements: bool,
|
||||
pub notify_guilds: bool,
|
||||
pub notify_members: bool,
|
||||
}
|
||||
|
||||
impl Default for DiscordSettings {
|
||||
fn default() -> Self {
|
||||
Self { role_sync: "off".into(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load(state: &AppState) -> AppResult<DiscordSettings> {
|
||||
store::kv_get(state, "discord_settings").await
|
||||
}
|
||||
|
||||
pub fn valid_webhook(url: &str) -> bool {
|
||||
url.is_empty()
|
||||
|| ["https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/", "https://ptb.discord.com/api/webhooks/", "https://canary.discord.com/api/webhooks/"]
|
||||
.iter()
|
||||
.any(|p| url.starts_with(p))
|
||||
}
|
||||
|
||||
pub fn valid_invite(url: &str) -> bool {
|
||||
url.is_empty() || ["https://discord.gg/", "https://discord.com/invite/", "https://discordapp.com/invite/"].iter().any(|p| url.starts_with(p))
|
||||
}
|
||||
|
||||
fn view(s: &DiscordSettings) -> Value {
|
||||
json!({
|
||||
"role_sync": s.role_sync, "invite_url": s.invite_url,
|
||||
"webhook_set": !s.webhook_url.is_empty(),
|
||||
"notify_achievements": s.notify_achievements, "notify_guilds": s.notify_guilds, "notify_members": s.notify_members,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let conn: ConnectionsSettings = store::kv_get(&state, "connections_settings").await?;
|
||||
let linked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_connections WHERE provider = 'discord'").fetch_one(&state.db).await?;
|
||||
let mut v = view(&load(&state).await?);
|
||||
v["bot_ready"] = json!(!conn.discord_bot_token.is_empty() && !conn.discord_guild_id.is_empty());
|
||||
v["linked_accounts"] = json!(linked);
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SettingsInput {
|
||||
role_sync: String,
|
||||
invite_url: String,
|
||||
/// Omitted or empty keeps the saved webhook; `"-"` clears it.
|
||||
#[serde(default)]
|
||||
webhook_url: String,
|
||||
notify_achievements: bool,
|
||||
notify_guilds: bool,
|
||||
notify_members: bool,
|
||||
}
|
||||
|
||||
pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i): Json<SettingsInput>) -> AppResult<Json<Value>> {
|
||||
if !["off", "discord_to_panel", "panel_to_discord", "both"].contains(&i.role_sync.as_str()) {
|
||||
return Err(AppError::bad_request("unknown role sync mode"));
|
||||
}
|
||||
let old = load(&state).await?;
|
||||
let webhook = match i.webhook_url.trim() {
|
||||
"" => old.webhook_url.clone(),
|
||||
"-" => String::new(),
|
||||
w => w.to_string(),
|
||||
};
|
||||
let invite = i.invite_url.trim().to_string();
|
||||
if !valid_webhook(&webhook) {
|
||||
return Err(AppError::bad_request("that is not a Discord webhook address"));
|
||||
}
|
||||
if !valid_invite(&invite) {
|
||||
return Err(AppError::bad_request("invite links look like https://discord.gg/yourcode"));
|
||||
}
|
||||
let next = DiscordSettings {
|
||||
role_sync: i.role_sync,
|
||||
invite_url: invite,
|
||||
webhook_url: webhook,
|
||||
notify_achievements: i.notify_achievements,
|
||||
notify_guilds: i.notify_guilds,
|
||||
notify_members: i.notify_members,
|
||||
};
|
||||
store::kv_set(&state, "discord_settings", &next).await?;
|
||||
Ok(Json(view(&next)))
|
||||
}
|
||||
|
||||
/// What launchers show: the invite link, and whether the account is linked.
|
||||
pub async fn public_info(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<Value>> {
|
||||
let s = load(&state).await?;
|
||||
let linked: Option<(String, String)> =
|
||||
sqlx::query_as("SELECT provider_id, display_name FROM account_connections WHERE user_id = ? AND provider = 'discord'").bind(user.id).fetch_optional(&state.db).await?;
|
||||
Ok(Json(json!({ "invite_url": s.invite_url, "linked": linked.is_some(), "display_name": linked.map(|l| l.1) })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RoleMapping {
|
||||
pub discord_role: String,
|
||||
}
|
||||
|
||||
pub async fn set_role_mapping(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>, Json(m): Json<RoleMapping>) -> AppResult<Json<Value>> {
|
||||
let role = m.discord_role.trim();
|
||||
if !role.bytes().all(|b| b.is_ascii_digit()) || role.len() > 24 {
|
||||
return Err(AppError::bad_request("Discord role IDs are numbers"));
|
||||
}
|
||||
let done = sqlx::query("UPDATE groups SET discord_role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?;
|
||||
if done.rows_affected() == 0 {
|
||||
return Err(AppError::not_found("group not found"));
|
||||
}
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// What to change for one person. Only ever additions.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Plan {
|
||||
pub add_groups: Vec<i64>,
|
||||
pub add_roles: Vec<String>,
|
||||
}
|
||||
|
||||
/// `mapping` is (group id, discord role id); `member_roles` are the Discord roles
|
||||
/// the person has; `groups` the panel groups they are in.
|
||||
pub fn plan(mode: &str, mapping: &[(i64, String)], member_roles: &[String], groups: &[i64]) -> Plan {
|
||||
let mut p = Plan::default();
|
||||
let from_discord = matches!(mode, "discord_to_panel" | "both");
|
||||
let to_discord = matches!(mode, "panel_to_discord" | "both");
|
||||
for (group, role) in mapping.iter().filter(|(_, r)| !r.is_empty()) {
|
||||
let has_role = member_roles.contains(role);
|
||||
let in_group = groups.contains(group);
|
||||
if from_discord && has_role && !in_group {
|
||||
p.add_groups.push(*group);
|
||||
}
|
||||
if to_discord && in_group && !has_role {
|
||||
p.add_roles.push(role.clone());
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn bot_request(state: &AppState, method: reqwest::Method, url: String, token: &str) -> reqwest::RequestBuilder {
|
||||
state.http.request(method, url).header("Authorization", format!("Bot {token}")).timeout(Duration::from_secs(10))
|
||||
}
|
||||
|
||||
pub async fn roles(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let c: ConnectionsSettings = store::kv_get(&state, "connections_settings").await?;
|
||||
if c.discord_bot_token.is_empty() || c.discord_guild_id.is_empty() {
|
||||
return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first"));
|
||||
}
|
||||
let resp = bot_request(&state, reqwest::Method::GET, format!("{API}/guilds/{}/roles", c.discord_guild_id), &c.discord_bot_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| AppError::bad_request("could not reach Discord"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(AppError::bad_request("Discord refused the bot token or server ID"));
|
||||
}
|
||||
let list: Vec<Value> = resp.json().await.map_err(|_| AppError::bad_request("unexpected Discord response"))?;
|
||||
let mut out: Vec<Value> = list
|
||||
.into_iter()
|
||||
.filter(|r| r["name"] != "@everyone" && !r["managed"].as_bool().unwrap_or(false))
|
||||
.map(|r| json!({ "id": r["id"], "name": r["name"], "color": r["color"], "position": r["position"] }))
|
||||
.collect();
|
||||
out.sort_by_key(|r| std::cmp::Reverse(r["position"].as_i64().unwrap_or(0)));
|
||||
Ok(Json(json!(out)))
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct SyncReport {
|
||||
pub checked: u32,
|
||||
pub not_in_server: u32,
|
||||
pub groups_added: u32,
|
||||
pub roles_added: u32,
|
||||
pub failed: u32,
|
||||
}
|
||||
|
||||
/// Sync every linked account. Safe to run repeatedly.
|
||||
pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
let settings = load(state).await?;
|
||||
let mut report = SyncReport::default();
|
||||
if settings.role_sync == "off" {
|
||||
return Ok(report);
|
||||
}
|
||||
let c: ConnectionsSettings = store::kv_get(state, "connections_settings").await?;
|
||||
if c.discord_bot_token.is_empty() || c.discord_guild_id.is_empty() {
|
||||
return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first"));
|
||||
}
|
||||
let mapping: Vec<(i64, String)> = sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
|
||||
if mapping.is_empty() {
|
||||
return Ok(report);
|
||||
}
|
||||
let linked: Vec<(i64, String)> = sqlx::query_as("SELECT user_id, provider_id FROM account_connections WHERE provider = 'discord'").fetch_all(&state.db).await?;
|
||||
for (user_id, discord_id) in linked {
|
||||
report.checked += 1;
|
||||
let resp = bot_request(state, reqwest::Method::GET, format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id), &c.discord_bot_token).send().await;
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
report.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
report.not_in_server += 1;
|
||||
continue;
|
||||
}
|
||||
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
// Discord asked us to slow down; the next run finishes the rest.
|
||||
report.failed += 1;
|
||||
break;
|
||||
}
|
||||
let Ok(member) = resp.json::<Value>().await else {
|
||||
report.failed += 1;
|
||||
continue;
|
||||
};
|
||||
let roles: Vec<String> = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
|
||||
let groups: Vec<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
|
||||
let p = plan(&settings.role_sync, &mapping, &roles, &groups);
|
||||
for g in p.add_groups {
|
||||
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(g).execute(&state.db).await?;
|
||||
report.groups_added += 1;
|
||||
}
|
||||
for role in p.add_roles {
|
||||
let done = bot_request(state, reqwest::Method::PUT, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token)
|
||||
.header("Content-Length", "0")
|
||||
.send()
|
||||
.await;
|
||||
match done {
|
||||
Ok(r) if r.status().is_success() => report.roles_added += 1,
|
||||
_ => report.failed += 1,
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub async fn sync_now(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<SyncReport>> {
|
||||
if load(&state).await?.role_sync == "off" {
|
||||
return Err(AppError::bad_request("turn on a role sync mode first"));
|
||||
}
|
||||
Ok(Json(sync_all(&state).await?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Announcements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Cursor {
|
||||
achievements: String,
|
||||
guilds: String,
|
||||
members: String,
|
||||
}
|
||||
|
||||
async fn post_webhook(state: &AppState, url: &str, title: &str, text: &str, color: u32) {
|
||||
let body = json!({ "username": "SCOPENET", "allowed_mentions": { "parse": [] }, "embeds": [{ "title": title, "description": text, "color": color }] });
|
||||
if let Err(e) = state.http.post(url).json(&body).timeout(Duration::from_secs(10)).send().await {
|
||||
tracing::debug!("discord webhook failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn clip(s: &str) -> String {
|
||||
s.chars().take(200).collect()
|
||||
}
|
||||
|
||||
/// Post what happened since the last look. The first run starts from "now".
|
||||
pub async fn announce(state: &AppState) -> AppResult<u32> {
|
||||
let s = load(state).await?;
|
||||
if s.webhook_url.is_empty() || !valid_webhook(&s.webhook_url) {
|
||||
return Ok(0);
|
||||
}
|
||||
let now = crate::db::now();
|
||||
let mut cursor: Cursor = store::kv_get(state, "discord_cursor").await?;
|
||||
if cursor.achievements.is_empty() {
|
||||
cursor = Cursor { achievements: now.clone(), guilds: now.clone(), members: now.clone() };
|
||||
store::kv_set(state, "discord_cursor", &cursor).await?;
|
||||
return Ok(0);
|
||||
}
|
||||
let mut sent = 0;
|
||||
if s.notify_achievements {
|
||||
let rows: Vec<(String, String, String)> = sqlx::query_as(
|
||||
"SELECT COALESCE(u.username, ua.user_uuid), a.title, ua.unlocked_at FROM user_achievements ua
|
||||
JOIN achievements a ON a.id = ua.achievement_id LEFT JOIN users u ON u.uuid = ua.user_uuid
|
||||
WHERE ua.unlocked_at > ? ORDER BY ua.unlocked_at LIMIT 10",
|
||||
)
|
||||
.bind(&cursor.achievements)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (who, title, at) in rows {
|
||||
post_webhook(state, &s.webhook_url, "Achievement unlocked", &format!("**{}** earned **{}**", clip(&who), clip(&title)), 0xfcd34d).await;
|
||||
cursor.achievements = at;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
if s.notify_guilds {
|
||||
let rows: Vec<(String, String, String)> =
|
||||
sqlx::query_as("SELECT name, tag, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?;
|
||||
for (name, tag, at) in rows {
|
||||
post_webhook(state, &s.webhook_url, "New guild", &format!("**{}** [{}] was founded", clip(&name), clip(&tag)), 0x22d3ee).await;
|
||||
cursor.guilds = at;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
if s.notify_members {
|
||||
let rows: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.members).fetch_all(&state.db).await?;
|
||||
for (name, at) in rows {
|
||||
post_webhook(state, &s.webhook_url, "New player", &format!("Welcome **{}**!", clip(&name)), 0x6ee7b7).await;
|
||||
cursor.members = at;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
store::kv_set(state, "discord_cursor", &cursor).await?;
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
pub async fn test_webhook(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let s = load(&state).await?;
|
||||
if s.webhook_url.is_empty() {
|
||||
return Err(AppError::bad_request("save a webhook first"));
|
||||
}
|
||||
post_webhook(&state, &s.webhook_url, "SCOPENET is connected", "Announcements will appear in this channel.", 0x8b6cff).await;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Background loop: announcements every 30 s, role sync every 15 min.
|
||||
pub fn spawn_worker(state: AppState) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = 0u64;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
if let Err(e) = announce(&state).await {
|
||||
tracing::debug!("discord announce: {e:?}");
|
||||
}
|
||||
tick += 1;
|
||||
if tick % 30 == 0 {
|
||||
if let Err(e) = sync_all(&state).await {
|
||||
tracing::debug!("discord sync: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn map() -> Vec<(i64, String)> {
|
||||
vec![(1, "100".into()), (2, "200".into()), (3, String::new())]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_changes_nothing() {
|
||||
assert_eq!(plan("off", &map(), &["100".into()], &[2]), Plan::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discord_roles_add_panel_groups() {
|
||||
let p = plan("discord_to_panel", &map(), &["100".into(), "999".into()], &[]);
|
||||
assert_eq!(p, Plan { add_groups: vec![1], add_roles: vec![] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_groups_add_discord_roles_and_unmapped_are_ignored() {
|
||||
let p = plan("panel_to_discord", &map(), &[], &[2, 3]);
|
||||
assert_eq!(p, Plan { add_groups: vec![], add_roles: vec!["200".into()] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_never_removes() {
|
||||
let p = plan("both", &map(), &["100".into()], &[2]);
|
||||
assert_eq!(p, Plan { add_groups: vec![1], add_roles: vec!["200".into()] });
|
||||
// Already in sync: nothing to do.
|
||||
assert_eq!(plan("both", &map(), &["100".into()], &[1]), Plan::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_and_invite_addresses_are_checked() {
|
||||
assert!(valid_webhook("https://discord.com/api/webhooks/1/abc"));
|
||||
assert!(!valid_webhook("https://evil.example/api/webhooks/1"));
|
||||
assert!(valid_invite("https://discord.gg/abc"));
|
||||
assert!(!valid_invite("http://discord.gg/abc"));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod dev_api;
|
||||
pub mod economy;
|
||||
pub mod guild_bank;
|
||||
pub mod guilds;
|
||||
pub mod discord;
|
||||
pub mod integrations;
|
||||
pub mod landing;
|
||||
pub mod livemap;
|
||||
@@ -40,6 +41,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/auth/discord/start", get(connections::discord_start))
|
||||
.route("/auth/discord/callback", get(connections::discord_callback))
|
||||
.route("/auth/discord/poll", get(connections::discord_poll))
|
||||
.route("/account/discord", get(discord::public_info))
|
||||
.route("/account/connections/discord", get(connections::my_discord).delete(connections::unlink_discord))
|
||||
.route("/account/profile", get(account::profile))
|
||||
.route("/account/stats", get(servers::account_player_stats))
|
||||
@@ -169,6 +171,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/progression/players/{uuid}/adjust", post(progression::adjust_player))
|
||||
.route("/integrations/settings", get(integrations::get_settings).put(integrations::put_settings))
|
||||
.route("/groups/{id}/luckperms", put(integrations::set_group_mapping))
|
||||
.route("/groups/{id}/discord-role", put(discord::set_role_mapping))
|
||||
.route("/discord", get(discord::get_settings).put(discord::put_settings))
|
||||
.route("/discord/roles", get(discord::roles))
|
||||
.route("/discord/sync", post(discord::sync_now))
|
||||
.route("/discord/test", post(discord::test_webhook))
|
||||
.route("/servers/{id}/integrations", get(integrations::admin_server))
|
||||
.route("/guilds", get(guilds::admin_list_guilds))
|
||||
.route("/guilds/{id}", delete(guilds::admin_delete_guild))
|
||||
|
||||
Reference in new issue
Block a user