feat: add /mod summon

This commit is contained in:
Evan Carroll 2026-01-20 21:48:04 -06:00
parent 864cfaec54
commit 45a7e44b3a
11 changed files with 598 additions and 5 deletions

View file

@ -7,6 +7,7 @@ edition.workspace = true
chattyness-error = { workspace = true, optional = true }
chattyness-shared = { workspace = true, optional = true }
serde.workspace = true
serde_json = { workspace = true, optional = true }
uuid.workspace = true
chrono.workspace = true
@ -17,4 +18,4 @@ rand = { workspace = true, optional = true }
[features]
default = []
ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared"]
ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared", "dep:serde_json"]

View file

@ -209,6 +209,42 @@ impl std::str::FromStr for RealmRole {
}
}
/// Moderation action type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "action_type", rename_all = "snake_case")
)]
#[serde(rename_all = "snake_case")]
pub enum ActionType {
Warning,
Mute,
Kick,
Ban,
Unban,
PropRemoval,
MessageDeletion,
Summon,
SummonAll,
}
impl std::fmt::Display for ActionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ActionType::Warning => write!(f, "warning"),
ActionType::Mute => write!(f, "mute"),
ActionType::Kick => write!(f, "kick"),
ActionType::Ban => write!(f, "ban"),
ActionType::Unban => write!(f, "unban"),
ActionType::PropRemoval => write!(f, "prop_removal"),
ActionType::MessageDeletion => write!(f, "message_deletion"),
ActionType::Summon => write!(f, "summon"),
ActionType::SummonAll => write!(f, "summon_all"),
}
}
}
/// Scene dimension mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]

View file

@ -7,6 +7,7 @@ pub mod guests;
pub mod inventory;
pub mod loose_props;
pub mod memberships;
pub mod moderation;
pub mod owner;
pub mod props;
pub mod realms;

View file

@ -199,3 +199,33 @@ pub async fn is_member(pool: &PgPool, user_id: Uuid, realm_id: Uuid) -> Result<b
Ok(exists.0)
}
/// Check if a user is a moderator (server-level or realm-level).
///
/// Returns true if the user has any of:
/// - Server staff role: owner, admin, or moderator (from `server.staff`)
/// - Realm role: owner or moderator (from `realm.memberships`)
pub async fn is_moderator(pool: &PgPool, user_id: Uuid, realm_id: Uuid) -> Result<bool, AppError> {
// Check server-level staff first (owner, admin, moderator can moderate any realm)
if let Some(server_role) = get_user_staff_role(pool, user_id).await? {
match server_role {
ServerRole::Owner | ServerRole::Admin | ServerRole::Moderator => return Ok(true),
}
}
// Check realm-level role (owner or moderator for this specific realm)
let realm_mod: (bool,) = sqlx::query_as(
r#"
SELECT EXISTS(
SELECT 1 FROM realm.memberships
WHERE user_id = $1 AND realm_id = $2 AND role IN ('owner', 'moderator')
)
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_one(pool)
.await?;
Ok(realm_mod.0)
}

View file

@ -0,0 +1,77 @@
//! Moderation-related database queries.
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::ActionType;
use chattyness_error::AppError;
/// Log a moderation action to the realm audit log.
pub async fn log_moderation_action(
pool: &PgPool,
realm_id: Uuid,
moderator_id: Uuid,
action_type: ActionType,
target_user_id: Option<Uuid>,
reason: &str,
metadata: serde_json::Value,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO realm.moderation_actions (
realm_id,
action_type,
target_user_id,
moderator_id,
reason,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
)
.bind(realm_id)
.bind(action_type)
.bind(target_user_id)
.bind(moderator_id)
.bind(reason)
.bind(metadata)
.execute(pool)
.await?;
Ok(())
}
/// Log a moderation action using a connection (for RLS support).
pub async fn log_moderation_action_conn(
conn: &mut sqlx::PgConnection,
realm_id: Uuid,
moderator_id: Uuid,
action_type: ActionType,
target_user_id: Option<Uuid>,
reason: &str,
metadata: serde_json::Value,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO realm.moderation_actions (
realm_id,
action_type,
target_user_id,
moderator_id,
reason,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
)
.bind(realm_id)
.bind(action_type)
.bind(target_user_id)
.bind(moderator_id)
.bind(reason)
.bind(metadata)
.execute(conn)
.await?;
Ok(())
}

View file

@ -90,6 +90,14 @@ pub enum ClientMessage {
/// Scene ID to teleport to.
scene_id: Uuid,
},
/// Moderator command (only processed if sender is a moderator).
ModCommand {
/// Subcommand name ("summon", "avatar", "teleport", "ban", etc.).
subcommand: String,
/// Arguments for the subcommand.
args: Vec<String>,
},
}
/// Server-to-client WebSocket messages.
@ -234,4 +242,22 @@ pub enum ServerMessage {
/// Scene slug for URL.
scene_slug: String,
},
/// User has been summoned by a moderator - triggers teleport.
Summoned {
/// Scene ID to teleport to.
scene_id: Uuid,
/// Scene slug for URL.
scene_slug: String,
/// Display name of the moderator who summoned.
summoned_by: String,
},
/// Result of a moderator command.
ModCommandResult {
/// Whether the command succeeded.
success: bool,
/// Human-readable result message.
message: String,
},
}