Rework avatars.

Now we have a concept of an avatar at the server, realm, and scene level
and we have the groundwork for a realm store. New uesrs no longer props,
they get a default avatar. New system supports gender
{male,female,neutral} and {child,adult}.
This commit is contained in:
Evan Carroll 2026-01-22 21:04:27 -06:00
parent e4abdb183f
commit 6fb90e42c3
55 changed files with 7392 additions and 512 deletions

View file

@ -18,14 +18,33 @@ use tokio::sync::{broadcast, mpsc};
use uuid::Uuid;
use chattyness_db::{
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes, users},
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, ForcedAvatarReason, User},
queries::{avatars, channel_members, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
};
use chattyness_error::AppError;
use chattyness_shared::WebSocketConfig;
use crate::auth::AuthUser;
use chrono::Duration as ChronoDuration;
/// Parse a duration string like "30m", "2h", "7d" into a chrono::Duration.
fn parse_duration(s: &str) -> Option<ChronoDuration> {
let s = s.trim().to_lowercase();
if s.is_empty() {
return None;
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: i64 = num_str.parse().ok()?;
match unit {
"m" => Some(ChronoDuration::minutes(num)),
"h" => Some(ChronoDuration::hours(num)),
"d" => Some(ChronoDuration::days(num)),
_ => None,
}
}
/// Channel state for broadcasting updates.
pub struct ChannelState {
@ -271,8 +290,22 @@ async fn handle_socket(
}
tracing::info!("[WS] Channel joined");
// Check if scene has forced avatar
if let Ok(Some(scene_forced)) = realm_avatars::get_scene_forced_avatar(&pool, channel_id).await {
tracing::info!("[WS] Scene has forced avatar: {:?}", scene_forced.forced_avatar_id);
// Apply scene-forced avatar to user
if let Err(e) = realm_avatars::apply_scene_forced_avatar(
&pool,
user.id,
realm_id,
scene_forced.forced_avatar_id,
).await {
tracing::warn!("[WS] Failed to apply scene forced avatar: {:?}", e);
}
}
// Get initial state
let members = match get_members_with_avatars(&mut conn, channel_id, realm_id).await {
let members = match get_members_with_avatars(&mut conn, &pool, channel_id, realm_id).await {
Ok(m) => m,
Err(e) => {
tracing::error!("[WS] Failed to get members: {:?}", e);
@ -338,11 +371,13 @@ async fn handle_socket(
let member_display_name = member.display_name.clone();
// Broadcast join to others
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
// Use effective avatar resolution to handle priority chain:
// forced > custom > selected realm > selected server > realm default > server default
let avatar = avatars::get_effective_avatar_render_data(&pool, user.id, realm_id)
.await
.ok()
.flatten()
.map(|a| a.to_render_data())
.map(|(render_data, _source)| render_data)
.unwrap_or_default();
let join_msg = ServerMessage::MemberJoined {
member: ChannelMemberWithAvatar { member, avatar },
@ -458,6 +493,7 @@ async fn handle_socket(
};
let emotion_layer = match avatars::set_emotion(
&mut *recv_conn,
&pool,
user_id,
realm_id,
emotion_state,
@ -711,16 +747,16 @@ async fn handle_socket(
}
}
ClientMessage::SyncAvatar => {
// Fetch current avatar from database and broadcast to channel
match avatars::get_avatar_with_paths_conn(
&mut *recv_conn,
// Fetch current effective avatar from database and broadcast to channel
// Uses the priority chain: forced > custom > selected realm > selected server > realm default > server default
match avatars::get_effective_avatar_render_data(
&pool,
user_id,
realm_id,
)
.await
{
Ok(Some(avatar_with_paths)) => {
let render_data = avatar_with_paths.to_render_data();
Ok(Some((render_data, _source))) => {
#[cfg(debug_assertions)]
tracing::debug!(
"[WS] User {} syncing avatar to channel",
@ -1066,6 +1102,280 @@ async fn handle_socket(
}).await;
}
}
"dress" => {
// /mod dress [nick] [avatar-slug] [duration?]
// Duration format: 30m, 2h, 7d (minutes/hours/days)
if args.len() < 2 {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: "Usage: /mod dress [nick] [avatar-slug] [duration?]".to_string(),
}).await;
continue;
}
let target_nick = &args[0];
let avatar_slug = &args[1];
let duration_str = args.get(2).map(|s| s.as_str());
// Parse duration if provided
let duration = if let Some(dur_str) = duration_str {
match parse_duration(dur_str) {
Some(d) => Some(d),
None => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: "Invalid duration format. Use: 30m, 2h, 7d".to_string(),
}).await;
continue;
}
}
} else {
None // Permanent until undressed
};
// Find target user
if let Some((target_user_id, target_conn)) = ws_state
.find_user_by_display_name(realm_id, target_nick)
{
// Try realm avatars first, then server avatars
let avatar_result = realm_avatars::get_realm_avatar_by_slug(&pool, realm_id, avatar_slug).await;
let (avatar_render_data, avatar_id, source) = match avatar_result {
Ok(Some(realm_avatar)) => {
// Resolve realm avatar to render data
match realm_avatars::resolve_realm_avatar_to_render_data(&pool, &realm_avatar, EmotionState::default()).await {
Ok(render_data) => (render_data, realm_avatar.id, "realm"),
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to resolve avatar: {:?}", e),
}).await;
continue;
}
}
}
Ok(None) => {
// Try server avatars
match server_avatars::get_server_avatar_by_slug(&pool, avatar_slug).await {
Ok(Some(server_avatar)) => {
match server_avatars::resolve_server_avatar_to_render_data(&pool, &server_avatar, EmotionState::default()).await {
Ok(render_data) => (render_data, server_avatar.id, "server"),
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to resolve avatar: {:?}", e),
}).await;
continue;
}
}
}
Ok(None) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Avatar '{}' not found", avatar_slug),
}).await;
continue;
}
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to lookup avatar: {:?}", e),
}).await;
continue;
}
}
}
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to lookup avatar: {:?}", e),
}).await;
continue;
}
};
// Acquire connection and set RLS context for the update
let mut rls_conn = match pool.acquire().await {
Ok(c) => c,
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Database connection error: {:?}", e),
}).await;
continue;
}
};
if let Err(e) = set_rls_user_id(&mut rls_conn, user_id).await {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to set RLS context: {:?}", e),
}).await;
continue;
}
// Apply forced avatar using connection with RLS context
let apply_result = if source == "server" {
server_avatars::apply_forced_server_avatar(
&mut *rls_conn,
target_user_id,
realm_id,
avatar_id,
Some(user_id),
duration,
).await
} else {
realm_avatars::apply_forced_realm_avatar(
&mut *rls_conn,
target_user_id,
realm_id,
avatar_id,
Some(user_id),
duration,
).await
};
if let Err(e) = apply_result {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to apply forced avatar: {:?}", e),
}).await;
continue;
}
// Log moderation action
let metadata = serde_json::json!({
"avatar_slug": avatar_slug,
"avatar_id": avatar_id.to_string(),
"source": source,
"duration": duration_str,
});
let _ = moderation::log_moderation_action(
&pool,
realm_id,
user_id,
ActionType::DressUser,
Some(target_user_id),
&format!("Forced {} to wear avatar '{}'", target_nick, avatar_slug),
metadata,
).await;
// Send AvatarForced to target user
let _ = target_conn.direct_tx.send(ServerMessage::AvatarForced {
user_id: target_user_id,
avatar: avatar_render_data.clone(),
reason: ForcedAvatarReason::ModCommand,
forced_by: Some(mod_member.display_name.clone()),
}).await;
// Broadcast avatar update to channel
let _ = tx.send(ServerMessage::AvatarUpdated {
user_id: Some(target_user_id),
guest_session_id: None,
avatar: avatar_render_data,
});
let duration_msg = match duration_str {
Some(d) => format!(" for {}", d),
None => " permanently".to_string(),
};
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: true,
message: format!("Forced {} to wear '{}'{}", target_nick, avatar_slug, duration_msg),
}).await;
} else {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("User '{}' is not online in this realm", target_nick),
}).await;
}
}
"undress" => {
// /mod undress [nick]
if args.is_empty() {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: "Usage: /mod undress [nick]".to_string(),
}).await;
continue;
}
let target_nick = &args[0];
// Find target user
if let Some((target_user_id, target_conn)) = ws_state
.find_user_by_display_name(realm_id, target_nick)
{
// Acquire connection and set RLS context for the update
let mut rls_conn = match pool.acquire().await {
Ok(c) => c,
Err(e) => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Database connection error: {:?}", e),
}).await;
continue;
}
};
if let Err(e) = set_rls_user_id(&mut rls_conn, user_id).await {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to set RLS context: {:?}", e),
}).await;
continue;
}
// Clear forced avatar using connection with RLS context
if let Err(e) = server_avatars::clear_forced_avatar(&mut *rls_conn, target_user_id, realm_id).await {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("Failed to clear forced avatar: {:?}", e),
}).await;
continue;
}
// Get the user's original avatar
let original_avatar = match avatars::get_avatar_with_paths(&pool, target_user_id, realm_id).await {
Ok(Some(avatar)) => avatar.to_render_data(),
Ok(None) => AvatarRenderData::default(),
Err(_) => AvatarRenderData::default(),
};
// Log moderation action
let metadata = serde_json::json!({});
let _ = moderation::log_moderation_action(
&pool,
realm_id,
user_id,
ActionType::UndressUser,
Some(target_user_id),
&format!("Cleared forced avatar for {}", target_nick),
metadata,
).await;
// Send AvatarCleared to target user
let _ = target_conn.direct_tx.send(ServerMessage::AvatarCleared {
user_id: target_user_id,
avatar: original_avatar.clone(),
cleared_by: Some(mod_member.display_name.clone()),
}).await;
// Broadcast avatar update to channel
let _ = tx.send(ServerMessage::AvatarUpdated {
user_id: Some(target_user_id),
guest_session_id: None,
avatar: original_avatar,
});
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: true,
message: format!("Cleared forced avatar for {}", target_nick),
}).await;
} else {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
message: format!("User '{}' is not online in this realm", target_nick),
}).await;
}
}
_ => {
let _ = direct_tx.send(ServerMessage::ModCommandResult {
success: false,
@ -1233,23 +1543,24 @@ async fn handle_socket(
/// Helper: Get all channel members with their avatar render data.
async fn get_members_with_avatars(
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
pool: &PgPool,
channel_id: Uuid,
realm_id: Uuid,
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
// Get members first
let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?;
// Fetch avatar data for each member using full avatar with paths
// This avoids the CASE statement approach and handles all emotions correctly
// Fetch avatar data for each member using the effective avatar resolution
// This handles the priority chain: forced > custom > selected realm > selected server > realm default > server default
let mut result = Vec::with_capacity(members.len());
for member in members {
let avatar = if let Some(user_id) = member.user_id {
// Get full avatar and convert to render data for current emotion
avatars::get_avatar_with_paths_conn(&mut **conn, user_id, realm_id)
// Use the new effective avatar resolution which handles all priority levels
avatars::get_effective_avatar_render_data(pool, user_id, realm_id)
.await
.ok()
.flatten()
.map(|a| a.to_render_data())
.map(|(render_data, _source)| render_data)
.unwrap_or_default()
} else {
// Guest users don't have avatars