1579 lines
86 KiB
Rust
1579 lines
86 KiB
Rust
//! WebSocket handler for channel presence.
|
|
//!
|
|
//! Handles real-time position updates, emotion changes, and member synchronization.
|
|
|
|
use axum::{
|
|
extract::{
|
|
FromRef, Path, State,
|
|
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade},
|
|
},
|
|
response::IntoResponse,
|
|
};
|
|
use dashmap::DashMap;
|
|
use futures::{SinkExt, StreamExt};
|
|
use sqlx::PgPool;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::sync::{broadcast, mpsc};
|
|
use uuid::Uuid;
|
|
|
|
use chattyness_db::{
|
|
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 {
|
|
/// Broadcast sender for this channel.
|
|
tx: broadcast::Sender<ServerMessage>,
|
|
}
|
|
|
|
/// Connection info for a connected user.
|
|
#[derive(Clone)]
|
|
pub struct UserConnection {
|
|
/// Direct message sender for this user.
|
|
pub direct_tx: mpsc::Sender<ServerMessage>,
|
|
/// Realm the user is in.
|
|
pub realm_id: Uuid,
|
|
/// Channel (scene) the user is in.
|
|
pub channel_id: Uuid,
|
|
/// User's display name.
|
|
pub display_name: String,
|
|
}
|
|
|
|
/// Global state for all WebSocket connections.
|
|
pub struct WebSocketState {
|
|
/// Map of channel_id -> ChannelState.
|
|
channels: DashMap<Uuid, Arc<ChannelState>>,
|
|
/// Map of user_id -> UserConnection for direct message routing.
|
|
users: DashMap<Uuid, UserConnection>,
|
|
}
|
|
|
|
impl Default for WebSocketState {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl WebSocketState {
|
|
/// Create a new WebSocket state.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
channels: DashMap::new(),
|
|
users: DashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Get or create a channel state.
|
|
fn get_or_create_channel(&self, channel_id: Uuid) -> Arc<ChannelState> {
|
|
self.channels
|
|
.entry(channel_id)
|
|
.or_insert_with(|| {
|
|
let (tx, _) = broadcast::channel(256);
|
|
Arc::new(ChannelState { tx })
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
/// Register a user connection for direct messaging.
|
|
pub fn register_user(
|
|
&self,
|
|
user_id: Uuid,
|
|
direct_tx: mpsc::Sender<ServerMessage>,
|
|
realm_id: Uuid,
|
|
channel_id: Uuid,
|
|
display_name: String,
|
|
) {
|
|
self.users.insert(
|
|
user_id,
|
|
UserConnection {
|
|
direct_tx,
|
|
realm_id,
|
|
channel_id,
|
|
display_name,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Unregister a user connection.
|
|
pub fn unregister_user(&self, user_id: Uuid) {
|
|
self.users.remove(&user_id);
|
|
}
|
|
|
|
/// Find a user by display name within a realm.
|
|
pub fn find_user_by_display_name(
|
|
&self,
|
|
realm_id: Uuid,
|
|
display_name: &str,
|
|
) -> Option<(Uuid, UserConnection)> {
|
|
for entry in self.users.iter() {
|
|
let (user_id, conn) = entry.pair();
|
|
if conn.realm_id == realm_id && conn.display_name.eq_ignore_ascii_case(display_name) {
|
|
return Some((*user_id, conn.clone()));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Get a user's connection info.
|
|
pub fn get_user(&self, user_id: Uuid) -> Option<UserConnection> {
|
|
self.users.get(&user_id).map(|r| r.clone())
|
|
}
|
|
}
|
|
|
|
/// WebSocket upgrade handler.
|
|
///
|
|
/// GET /api/realms/{slug}/channels/{channel_id}/ws
|
|
pub async fn ws_handler<S>(
|
|
Path((slug, channel_id)): Path<(String, Uuid)>,
|
|
auth_result: Result<AuthUser, crate::auth::AuthError>,
|
|
State(pool): State<PgPool>,
|
|
State(ws_state): State<Arc<WebSocketState>>,
|
|
State(ws_config): State<WebSocketConfig>,
|
|
ws: WebSocketUpgrade,
|
|
) -> Result<impl IntoResponse, AppError>
|
|
where
|
|
S: Send + Sync,
|
|
PgPool: FromRef<S>,
|
|
Arc<WebSocketState>: FromRef<S>,
|
|
WebSocketConfig: FromRef<S>,
|
|
{
|
|
// Log auth result before checking
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!(
|
|
"[WS] Connection attempt to {}/channels/{} - auth: {:?}",
|
|
slug,
|
|
channel_id,
|
|
auth_result
|
|
.as_ref()
|
|
.map(|a| a.0.id)
|
|
.map_err(|e| format!("{:?}", e))
|
|
);
|
|
|
|
let AuthUser(user) = auth_result.map_err(|e| {
|
|
tracing::warn!(
|
|
"[WS] Auth failed for {}/channels/{}: {:?}",
|
|
slug,
|
|
channel_id,
|
|
e
|
|
);
|
|
AppError::from(e)
|
|
})?;
|
|
|
|
// Verify realm exists
|
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
|
|
|
// Verify scene exists and belongs to this realm
|
|
// Note: Using scene_id as channel_id since channel_members uses scenes directly
|
|
let scene = scenes::get_scene_by_id(&pool, channel_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
|
|
|
if scene.realm_id != realm.id {
|
|
return Err(AppError::NotFound(
|
|
"Scene not found in this realm".to_string(),
|
|
));
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!(
|
|
"[WS] Upgrading connection for user {} to channel {}",
|
|
user.id,
|
|
channel_id
|
|
);
|
|
|
|
Ok(ws.on_upgrade(move |socket| {
|
|
handle_socket(
|
|
socket, user, channel_id, realm.id, pool, ws_state, ws_config,
|
|
)
|
|
}))
|
|
}
|
|
|
|
/// Set RLS context on a database connection.
|
|
async fn set_rls_user_id(
|
|
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
|
user_id: Uuid,
|
|
) -> Result<(), sqlx::Error> {
|
|
sqlx::query("SELECT public.set_current_user_id($1)")
|
|
.bind(user_id)
|
|
.execute(&mut **conn)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle an active WebSocket connection.
|
|
async fn handle_socket(
|
|
socket: WebSocket,
|
|
user: User,
|
|
channel_id: Uuid,
|
|
realm_id: Uuid,
|
|
pool: PgPool,
|
|
ws_state: Arc<WebSocketState>,
|
|
ws_config: WebSocketConfig,
|
|
) {
|
|
tracing::info!(
|
|
"[WS] handle_socket started for user {} channel {} realm {}",
|
|
user.id,
|
|
channel_id,
|
|
realm_id
|
|
);
|
|
|
|
// Acquire a dedicated connection for setup operations
|
|
let mut conn = match pool.acquire().await {
|
|
Ok(conn) => conn,
|
|
Err(e) => {
|
|
tracing::error!("[WS] Failed to acquire DB connection: {:?}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Set RLS context on this dedicated connection
|
|
if let Err(e) = set_rls_user_id(&mut conn, user.id).await {
|
|
tracing::error!(
|
|
"[WS] Failed to set RLS context for user {}: {:?}",
|
|
user.id,
|
|
e
|
|
);
|
|
return;
|
|
}
|
|
tracing::info!("[WS] RLS context set on dedicated connection");
|
|
|
|
let channel_state = ws_state.get_or_create_channel(channel_id);
|
|
let mut rx = channel_state.tx.subscribe();
|
|
|
|
let (mut sender, mut receiver) = socket.split();
|
|
|
|
// Ensure active avatar
|
|
tracing::info!("[WS] Ensuring active avatar...");
|
|
if let Err(e) = channel_members::ensure_active_avatar(&mut *conn, user.id, realm_id).await {
|
|
tracing::error!("[WS] Failed to ensure avatar for user {}: {:?}", user.id, e);
|
|
return;
|
|
}
|
|
tracing::info!("[WS] Avatar ensured");
|
|
|
|
// Join the channel
|
|
tracing::info!("[WS] Joining channel...");
|
|
if let Err(e) = channel_members::join_channel(&mut *conn, channel_id, user.id).await {
|
|
tracing::error!(
|
|
"[WS] Failed to join channel {} for user {}: {:?}",
|
|
channel_id,
|
|
user.id,
|
|
e
|
|
);
|
|
return;
|
|
}
|
|
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, &pool, channel_id, realm_id).await {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
tracing::error!("[WS] Failed to get members: {:?}", e);
|
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let member = match channel_members::get_channel_member(
|
|
&mut *conn, channel_id, user.id, realm_id,
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(m)) => m,
|
|
Ok(None) => {
|
|
tracing::error!("[WS] Failed to get member info for user {}", user.id);
|
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("[WS] Error getting member info: {:?}", e);
|
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Send welcome message with config
|
|
let welcome = ServerMessage::Welcome {
|
|
member: member.clone(),
|
|
members,
|
|
config: WsConfig {
|
|
ping_interval_secs: ws_config.client_ping_interval_secs,
|
|
},
|
|
};
|
|
if let Ok(json) = serde_json::to_string(&welcome) {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!("[WS->Client] {}", json);
|
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Send loose props sync
|
|
match loose_props::list_channel_loose_props(&mut *conn, channel_id).await {
|
|
Ok(props) => {
|
|
let props_sync = ServerMessage::LoosePropsSync { props };
|
|
if let Ok(json) = serde_json::to_string(&props_sync) {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!("[WS->Client] {}", json);
|
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("[WS] Failed to get loose props: {:?}", e);
|
|
}
|
|
}
|
|
|
|
// Save member display_name for user registration (before member is moved)
|
|
let member_display_name = member.display_name.clone();
|
|
|
|
// Broadcast join to others
|
|
// 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(|(render_data, _source)| render_data)
|
|
.unwrap_or_default();
|
|
let join_msg = ServerMessage::MemberJoined {
|
|
member: ChannelMemberWithAvatar { member, avatar },
|
|
};
|
|
let _ = channel_state.tx.send(join_msg);
|
|
|
|
let user_id = user.id;
|
|
let mut is_guest = user.is_guest();
|
|
let tx = channel_state.tx.clone();
|
|
|
|
// Acquire a second dedicated connection for the receive task
|
|
// This connection needs its own RLS context
|
|
let mut recv_conn = match pool.acquire().await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::error!("[WS] Failed to acquire recv connection: {:?}", e);
|
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user_id).await;
|
|
return;
|
|
}
|
|
};
|
|
if let Err(e) = set_rls_user_id(&mut recv_conn, user_id).await {
|
|
tracing::error!("[WS] Failed to set RLS on recv connection: {:?}", e);
|
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user_id).await;
|
|
return;
|
|
}
|
|
|
|
// Drop the setup connection - we'll use recv_conn for the receive task
|
|
// and pool for cleanup (leave_channel needs user_id match anyway)
|
|
drop(conn);
|
|
|
|
// Channel for sending direct messages (Pong, whispers) to client
|
|
let (direct_tx, mut direct_rx) = mpsc::channel::<ServerMessage>(16);
|
|
|
|
// Register user for direct message routing
|
|
ws_state.register_user(
|
|
user_id,
|
|
direct_tx.clone(),
|
|
realm_id,
|
|
channel_id,
|
|
member_display_name,
|
|
);
|
|
|
|
// Clone ws_state for use in recv_task
|
|
let ws_state_for_recv = ws_state.clone();
|
|
|
|
// Clone pool for use in recv_task (for teleport queries)
|
|
let pool_for_recv = pool.clone();
|
|
|
|
// Create recv timeout from config
|
|
let recv_timeout = Duration::from_secs(ws_config.recv_timeout_secs);
|
|
|
|
// Channel for sending close frame requests from recv_task to send_task
|
|
let (close_tx, mut close_rx) = mpsc::channel::<(u16, String)>(1);
|
|
|
|
// Spawn task to handle incoming messages from client
|
|
let close_tx_for_recv = close_tx.clone();
|
|
let recv_task = tokio::spawn(async move {
|
|
let close_tx = close_tx_for_recv;
|
|
let pool = pool_for_recv;
|
|
let ws_state = ws_state_for_recv;
|
|
let mut disconnect_reason = DisconnectReason::Graceful;
|
|
|
|
loop {
|
|
// Use timeout to detect connection loss
|
|
let msg_result = tokio::time::timeout(recv_timeout, receiver.next()).await;
|
|
|
|
match msg_result {
|
|
Ok(Some(Ok(msg))) => {
|
|
match msg {
|
|
Message::Text(text) => {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!("[WS<-Client] {}", text);
|
|
|
|
let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
match client_msg {
|
|
ClientMessage::UpdatePosition { x, y } => {
|
|
if let Err(e) = channel_members::update_position(
|
|
&mut *recv_conn,
|
|
channel_id,
|
|
user_id,
|
|
x,
|
|
y,
|
|
)
|
|
.await
|
|
{
|
|
#[cfg(debug_assertions)]
|
|
tracing::error!("[WS] Position update failed: {:?}", e);
|
|
continue;
|
|
}
|
|
let _ = tx.send(ServerMessage::PositionUpdated {
|
|
user_id,
|
|
x,
|
|
y,
|
|
});
|
|
}
|
|
ClientMessage::UpdateEmotion { emotion } => {
|
|
// Parse emotion name to EmotionState
|
|
let emotion_state = match emotion.parse::<EmotionState>() {
|
|
Ok(e) => e,
|
|
Err(_) => {
|
|
#[cfg(debug_assertions)]
|
|
tracing::warn!(
|
|
"[WS] Invalid emotion name: {}",
|
|
emotion
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
let emotion_layer = match avatars::set_emotion(
|
|
&mut *recv_conn,
|
|
&pool,
|
|
user_id,
|
|
realm_id,
|
|
emotion_state,
|
|
)
|
|
.await
|
|
{
|
|
Ok(layer) => layer,
|
|
Err(e) => {
|
|
#[cfg(debug_assertions)]
|
|
tracing::error!("[WS] Emotion update failed: {:?}", e);
|
|
continue;
|
|
}
|
|
};
|
|
let _ = tx.send(ServerMessage::EmotionUpdated {
|
|
user_id,
|
|
emotion,
|
|
emotion_layer,
|
|
});
|
|
}
|
|
ClientMessage::Ping => {
|
|
// Update last_moved_at to keep member alive for cleanup
|
|
let _ = channel_members::touch_member(
|
|
&mut *recv_conn,
|
|
channel_id,
|
|
user_id,
|
|
)
|
|
.await;
|
|
// Respond with pong directly (not broadcast)
|
|
let _ = direct_tx.send(ServerMessage::Pong).await;
|
|
}
|
|
ClientMessage::SendChatMessage {
|
|
content,
|
|
target_display_name,
|
|
} => {
|
|
// Validate message
|
|
if content.is_empty() || content.len() > 500 {
|
|
continue;
|
|
}
|
|
|
|
// Get member's current position and emotion
|
|
let member_info = channel_members::get_channel_member(
|
|
&mut *recv_conn,
|
|
channel_id,
|
|
user_id,
|
|
realm_id,
|
|
)
|
|
.await;
|
|
|
|
if let Ok(Some(member)) = member_info {
|
|
// Convert emotion index to name
|
|
let emotion_name =
|
|
EmotionState::from_index(member.current_emotion as u8)
|
|
.map(|e| e.to_string())
|
|
.unwrap_or_else(|| "neutral".to_string());
|
|
|
|
// Handle whisper (direct message) vs broadcast
|
|
if let Some(target_name) = target_display_name {
|
|
// Check if guest is trying to whisper
|
|
if is_guest {
|
|
let _ = direct_tx
|
|
.send(ServerMessage::Error {
|
|
code: "GUEST_FEATURE_DISABLED".to_string(),
|
|
message: "Private messaging is disabled for guests, please register first.".to_string(),
|
|
})
|
|
.await;
|
|
continue;
|
|
}
|
|
|
|
// Whisper: send directly to target user
|
|
if let Some((_target_user_id, target_conn)) = ws_state
|
|
.find_user_by_display_name(realm_id, &target_name)
|
|
{
|
|
// Determine if same scene
|
|
let is_same_scene =
|
|
target_conn.channel_id == channel_id;
|
|
|
|
let msg = ServerMessage::ChatMessageReceived {
|
|
message_id: Uuid::new_v4(),
|
|
user_id,
|
|
display_name: member.display_name.clone(),
|
|
content: content.clone(),
|
|
emotion: emotion_name.clone(),
|
|
x: member.position_x,
|
|
y: member.position_y,
|
|
timestamp: chrono::Utc::now()
|
|
.timestamp_millis(),
|
|
is_whisper: true,
|
|
is_same_scene,
|
|
};
|
|
|
|
// Send to target user
|
|
let _ =
|
|
target_conn.direct_tx.send(msg.clone()).await;
|
|
|
|
// Also send back to sender (so they see their own whisper)
|
|
// For sender, is_same_scene is always true (they see it as a bubble)
|
|
let sender_msg =
|
|
ServerMessage::ChatMessageReceived {
|
|
message_id: Uuid::new_v4(),
|
|
user_id,
|
|
display_name: member.display_name.clone(),
|
|
content,
|
|
emotion: emotion_name,
|
|
x: member.position_x,
|
|
y: member.position_y,
|
|
timestamp: chrono::Utc::now()
|
|
.timestamp_millis(),
|
|
is_whisper: true,
|
|
is_same_scene: true, // Sender always sees as bubble
|
|
};
|
|
let _ = direct_tx.send(sender_msg).await;
|
|
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!(
|
|
"[WS] Whisper from {} to {} (same_scene={})",
|
|
member.display_name,
|
|
target_name,
|
|
is_same_scene
|
|
);
|
|
} else {
|
|
// Target user not found - send error
|
|
let _ = direct_tx.send(ServerMessage::Error {
|
|
code: "WHISPER_TARGET_NOT_FOUND".to_string(),
|
|
message: format!("User '{}' is not online or not in this realm", target_name),
|
|
}).await;
|
|
}
|
|
} else {
|
|
// Broadcast: send to all users in the channel
|
|
let msg = ServerMessage::ChatMessageReceived {
|
|
message_id: Uuid::new_v4(),
|
|
user_id,
|
|
display_name: member.display_name.clone(),
|
|
content,
|
|
emotion: emotion_name,
|
|
x: member.position_x,
|
|
y: member.position_y,
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
is_whisper: false,
|
|
is_same_scene: true,
|
|
};
|
|
let _ = tx.send(msg);
|
|
}
|
|
}
|
|
}
|
|
ClientMessage::DropProp { inventory_item_id } => {
|
|
// Ensure instance exists for this scene (required for loose_props FK)
|
|
// In this system, channel_id = scene_id
|
|
if let Err(e) = loose_props::ensure_scene_instance(
|
|
&mut *recv_conn,
|
|
channel_id,
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
"[WS] Failed to ensure scene instance: {:?}",
|
|
e
|
|
);
|
|
}
|
|
|
|
// Get user's current position for random offset
|
|
let member_info = channel_members::get_channel_member(
|
|
&mut *recv_conn,
|
|
channel_id,
|
|
user_id,
|
|
realm_id,
|
|
)
|
|
.await;
|
|
|
|
if let Ok(Some(member)) = member_info {
|
|
// Generate random offset (within ~50 pixels)
|
|
let offset_x = (rand::random::<f64>() - 0.5) * 100.0;
|
|
let offset_y = (rand::random::<f64>() - 0.5) * 100.0;
|
|
let pos_x = member.position_x + offset_x;
|
|
let pos_y = member.position_y + offset_y;
|
|
|
|
match loose_props::drop_prop_to_canvas(
|
|
&mut *recv_conn,
|
|
inventory_item_id,
|
|
user_id,
|
|
channel_id,
|
|
pos_x,
|
|
pos_y,
|
|
)
|
|
.await
|
|
{
|
|
Ok(prop) => {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!(
|
|
"[WS] User {} dropped prop {} at ({}, {})",
|
|
user_id,
|
|
prop.id,
|
|
pos_x,
|
|
pos_y
|
|
);
|
|
let _ =
|
|
tx.send(ServerMessage::PropDropped { prop });
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("[WS] Drop prop failed: {:?}", e);
|
|
let (code, message) = match &e {
|
|
chattyness_error::AppError::Forbidden(msg) => (
|
|
"PROP_NOT_DROPPABLE".to_string(),
|
|
msg.clone(),
|
|
),
|
|
chattyness_error::AppError::NotFound(msg) => {
|
|
("PROP_NOT_FOUND".to_string(), msg.clone())
|
|
}
|
|
_ => (
|
|
"DROP_FAILED".to_string(),
|
|
format!("{:?}", e),
|
|
),
|
|
};
|
|
let _ =
|
|
tx.send(ServerMessage::Error { code, message });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ClientMessage::PickUpProp { loose_prop_id } => {
|
|
match loose_props::pick_up_loose_prop(
|
|
&mut *recv_conn,
|
|
loose_prop_id,
|
|
user_id,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_inventory_item) => {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!(
|
|
"[WS] User {} picked up prop {}",
|
|
user_id,
|
|
loose_prop_id
|
|
);
|
|
let _ = tx.send(ServerMessage::PropPickedUp {
|
|
prop_id: loose_prop_id,
|
|
picked_up_by_user_id: user_id,
|
|
});
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("[WS] Pick up prop failed: {:?}", e);
|
|
let _ = tx.send(ServerMessage::Error {
|
|
code: "PICKUP_FAILED".to_string(),
|
|
message: format!("{:?}", e),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
ClientMessage::SyncAvatar => {
|
|
// 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((render_data, _source))) => {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!(
|
|
"[WS] User {} syncing avatar to channel",
|
|
user_id
|
|
);
|
|
let _ = tx.send(ServerMessage::AvatarUpdated {
|
|
user_id,
|
|
avatar: render_data,
|
|
});
|
|
}
|
|
Ok(None) => {
|
|
#[cfg(debug_assertions)]
|
|
tracing::warn!(
|
|
"[WS] No avatar found for user {} to sync",
|
|
user_id
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("[WS] Avatar sync failed: {:?}", e);
|
|
}
|
|
}
|
|
}
|
|
ClientMessage::Teleport { scene_id } => {
|
|
// Validate teleport permission and scene
|
|
// 1. Check realm allows user teleport
|
|
let realm = match realms::get_realm_by_id(
|
|
&pool,
|
|
realm_id,
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(r)) => r,
|
|
Ok(None) => {
|
|
let _ = direct_tx.send(ServerMessage::Error {
|
|
code: "REALM_NOT_FOUND".to_string(),
|
|
message: "Realm not found".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("[WS] Teleport realm lookup failed: {:?}", e);
|
|
let _ = direct_tx.send(ServerMessage::Error {
|
|
code: "TELEPORT_FAILED".to_string(),
|
|
message: "Failed to verify teleport permission".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if !realm.allow_user_teleport {
|
|
let _ = direct_tx.send(ServerMessage::Error {
|
|
code: "TELEPORT_DISABLED".to_string(),
|
|
message: "Teleporting is not enabled for this realm".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
|
|
// 2. Validate scene exists, belongs to realm, and is not hidden
|
|
let scene = match scenes::get_scene_by_id(&pool, scene_id).await {
|
|
Ok(Some(s)) => s,
|
|
Ok(None) => {
|
|
let _ = direct_tx.send(ServerMessage::Error {
|
|
code: "SCENE_NOT_FOUND".to_string(),
|
|
message: "Scene not found".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("[WS] Teleport scene lookup failed: {:?}", e);
|
|
let _ = direct_tx.send(ServerMessage::Error {
|
|
code: "TELEPORT_FAILED".to_string(),
|
|
message: "Failed to verify scene".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if scene.realm_id != realm_id {
|
|
let _ = direct_tx.send(ServerMessage::Error {
|
|
code: "SCENE_NOT_IN_REALM".to_string(),
|
|
message: "Scene does not belong to this realm".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
|
|
if scene.is_hidden {
|
|
let _ = direct_tx.send(ServerMessage::Error {
|
|
code: "SCENE_HIDDEN".to_string(),
|
|
message: "Cannot teleport to a hidden scene".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
|
|
// 3. Send approval - client will disconnect and reconnect
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!(
|
|
"[WS] User {} teleporting to scene {} ({})",
|
|
user_id,
|
|
scene.name,
|
|
scene.slug
|
|
);
|
|
let _ = direct_tx.send(ServerMessage::TeleportApproved {
|
|
scene_id: scene.id,
|
|
scene_slug: scene.slug,
|
|
}).await;
|
|
}
|
|
ClientMessage::ModCommand { subcommand, args } => {
|
|
// Check if user is a moderator
|
|
let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await {
|
|
Ok(result) => result,
|
|
Err(e) => {
|
|
tracing::error!("[WS] Failed to check moderator status: {:?}", e);
|
|
false
|
|
}
|
|
};
|
|
|
|
if !is_mod {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: "You do not have moderator permissions".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
|
|
// Get moderator's current scene info and display name
|
|
let mod_member = match channel_members::get_channel_member(
|
|
&mut *recv_conn,
|
|
channel_id,
|
|
user_id,
|
|
realm_id,
|
|
).await {
|
|
Ok(Some(m)) => m,
|
|
Ok(None) | Err(_) => {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: "Failed to get moderator info".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Get moderator's current scene details
|
|
let mod_scene = match scenes::get_scene_by_id(&pool, channel_id).await {
|
|
Ok(Some(s)) => s,
|
|
Ok(None) | Err(_) => {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: "Failed to get scene info".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
match subcommand.as_str() {
|
|
"summon" => {
|
|
if args.is_empty() {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: "Usage: /mod summon [nick|*]".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
|
|
let target = &args[0];
|
|
|
|
if target == "*" {
|
|
// Summon all users in the realm
|
|
let mut summoned_count = 0;
|
|
let mut target_ids = Vec::new();
|
|
|
|
// Iterate all connected users in this realm
|
|
for entry in ws_state.users.iter() {
|
|
let (target_user_id, target_conn) = entry.pair();
|
|
if target_conn.realm_id == realm_id && *target_user_id != user_id {
|
|
// Send Summoned message to each user
|
|
let summon_msg = ServerMessage::Summoned {
|
|
scene_id: mod_scene.id,
|
|
scene_slug: mod_scene.slug.clone(),
|
|
summoned_by: mod_member.display_name.clone(),
|
|
};
|
|
if target_conn.direct_tx.send(summon_msg).await.is_ok() {
|
|
summoned_count += 1;
|
|
target_ids.push(*target_user_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log the action
|
|
let metadata = serde_json::json!({
|
|
"scene_id": mod_scene.id,
|
|
"scene_slug": mod_scene.slug,
|
|
"summoned_count": summoned_count,
|
|
});
|
|
let _ = moderation::log_moderation_action(
|
|
&pool,
|
|
realm_id,
|
|
user_id,
|
|
ActionType::SummonAll,
|
|
None,
|
|
&format!("Summoned {} users to scene {}", summoned_count, mod_scene.name),
|
|
metadata,
|
|
).await;
|
|
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: true,
|
|
message: format!("Summoned {} users to {}", summoned_count, mod_scene.name),
|
|
}).await;
|
|
} else {
|
|
// Summon specific user by display name
|
|
if let Some((target_user_id, target_conn)) = ws_state
|
|
.find_user_by_display_name(realm_id, target)
|
|
{
|
|
if target_user_id == user_id {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: "You cannot summon yourself".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
|
|
// Send Summoned message to target
|
|
let summon_msg = ServerMessage::Summoned {
|
|
scene_id: mod_scene.id,
|
|
scene_slug: mod_scene.slug.clone(),
|
|
summoned_by: mod_member.display_name.clone(),
|
|
};
|
|
if target_conn.direct_tx.send(summon_msg).await.is_ok() {
|
|
// Log the action
|
|
let metadata = serde_json::json!({
|
|
"scene_id": mod_scene.id,
|
|
"scene_slug": mod_scene.slug,
|
|
"target_display_name": target,
|
|
});
|
|
let _ = moderation::log_moderation_action(
|
|
&pool,
|
|
realm_id,
|
|
user_id,
|
|
ActionType::Summon,
|
|
Some(target_user_id),
|
|
&format!("Summoned {} to scene {}", target, mod_scene.name),
|
|
metadata,
|
|
).await;
|
|
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: true,
|
|
message: format!("Summoned {} to {}", target, mod_scene.name),
|
|
}).await;
|
|
} else {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: format!("Failed to send summon to {}", target),
|
|
}).await;
|
|
}
|
|
} else {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: format!("User '{}' is not online in this realm", target),
|
|
}).await;
|
|
}
|
|
}
|
|
}
|
|
"teleport" => {
|
|
if args.len() < 2 {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: "Usage: /mod teleport [nick] [slug]".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
|
|
let target_nick = &args[0];
|
|
let target_slug = &args[1];
|
|
|
|
// Look up the target scene by slug
|
|
let target_scene = match scenes::get_scene_by_slug(&pool, realm_id, target_slug).await {
|
|
Ok(Some(s)) => s,
|
|
Ok(None) => {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: format!("Scene '{}' not found", target_slug),
|
|
}).await;
|
|
continue;
|
|
}
|
|
Err(_) => {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: "Failed to look up scene".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Find target user by display name
|
|
if let Some((target_user_id, target_conn)) = ws_state
|
|
.find_user_by_display_name(realm_id, target_nick)
|
|
{
|
|
if target_user_id == user_id {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: "You cannot teleport yourself".to_string(),
|
|
}).await;
|
|
continue;
|
|
}
|
|
|
|
// Send Summoned message to target user with the specified scene
|
|
let teleport_msg = ServerMessage::Summoned {
|
|
scene_id: target_scene.id,
|
|
scene_slug: target_scene.slug.clone(),
|
|
summoned_by: mod_member.display_name.clone(),
|
|
};
|
|
if target_conn.direct_tx.send(teleport_msg).await.is_ok() {
|
|
// Log the action
|
|
let metadata = serde_json::json!({
|
|
"scene_id": target_scene.id,
|
|
"scene_slug": target_scene.slug,
|
|
"target_display_name": target_nick,
|
|
});
|
|
let _ = moderation::log_moderation_action(
|
|
&pool,
|
|
realm_id,
|
|
user_id,
|
|
ActionType::Teleport,
|
|
Some(target_user_id),
|
|
&format!("Teleported {} to scene {}", target_nick, target_scene.name),
|
|
metadata,
|
|
).await;
|
|
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: true,
|
|
message: format!("Teleported {} to {}", target_nick, target_scene.name),
|
|
}).await;
|
|
} else {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: format!("Failed to send teleport to {}", target_nick),
|
|
}).await;
|
|
}
|
|
} else {
|
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
success: false,
|
|
message: format!("User '{}' is not online in this realm", target_nick),
|
|
}).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: target_user_id,
|
|
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: target_user_id,
|
|
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,
|
|
message: format!("Unknown mod command: {}", subcommand),
|
|
}).await;
|
|
}
|
|
}
|
|
}
|
|
ClientMessage::RefreshIdentity => {
|
|
// Fetch updated user info from database
|
|
match users::get_user_by_id(&pool, user_id).await {
|
|
Ok(Some(updated_user)) => {
|
|
// Update the is_guest flag - critical for allowing
|
|
// newly registered users to send whispers
|
|
is_guest = updated_user.is_guest();
|
|
let display_name = updated_user.display_name.clone();
|
|
|
|
tracing::info!(
|
|
"[WS] User {} refreshed identity: display_name={}, is_guest={}",
|
|
user_id,
|
|
display_name,
|
|
is_guest
|
|
);
|
|
|
|
// Update WebSocket state with the new display name
|
|
// This is critical for whispers and mod commands to find
|
|
// the user by their new name after registration
|
|
if let Some(conn) = ws_state.get_user(user_id) {
|
|
ws_state.register_user(
|
|
user_id,
|
|
conn.direct_tx.clone(),
|
|
conn.realm_id,
|
|
conn.channel_id,
|
|
display_name.clone(),
|
|
);
|
|
}
|
|
|
|
// Broadcast identity update to all channel members
|
|
let _ = tx.send(ServerMessage::MemberIdentityUpdated {
|
|
user_id,
|
|
display_name,
|
|
is_guest,
|
|
});
|
|
}
|
|
Ok(None) => {
|
|
tracing::warn!("[WS] RefreshIdentity: user {} not found", user_id);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("[WS] RefreshIdentity failed for user {}: {:?}", user_id, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Message::Close(close_frame) => {
|
|
// Check close code for scene change or logout
|
|
if let Some(CloseFrame { code, .. }) = close_frame {
|
|
if code == close_codes::SCENE_CHANGE {
|
|
disconnect_reason = DisconnectReason::SceneChange;
|
|
} else if code == close_codes::LOGOUT {
|
|
// Explicit logout - treat as graceful disconnect
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!("[WS] User {} logged out", user_id);
|
|
disconnect_reason = DisconnectReason::Graceful;
|
|
} else {
|
|
disconnect_reason = DisconnectReason::Graceful;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
_ => {
|
|
// Ignore binary, ping, pong messages
|
|
}
|
|
}
|
|
}
|
|
Ok(Some(Err(e))) => {
|
|
// WebSocket error
|
|
tracing::warn!("[WS] Connection error: {:?}", e);
|
|
disconnect_reason = DisconnectReason::Timeout;
|
|
break;
|
|
}
|
|
Ok(None) => {
|
|
// Stream ended gracefully
|
|
break;
|
|
}
|
|
Err(_) => {
|
|
// Timeout elapsed - connection likely lost
|
|
tracing::info!("[WS] Connection timeout for user {}", user_id);
|
|
// Send close frame with timeout code so client can attempt silent reconnection
|
|
let _ = close_tx
|
|
.send((close_codes::SERVER_TIMEOUT, "timeout".to_string()))
|
|
.await;
|
|
// Brief delay to allow close frame to be sent
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
disconnect_reason = DisconnectReason::Timeout;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Return the connection and disconnect reason for cleanup
|
|
(recv_conn, disconnect_reason)
|
|
});
|
|
|
|
// Spawn task to forward broadcasts, direct messages, and close frames to this client
|
|
let send_task = tokio::spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
// Handle close frame requests (from timeout)
|
|
Some((code, reason)) = close_rx.recv() => {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!("[WS->Client] Sending close frame: code={}, reason={}", code, reason);
|
|
let close_frame = CloseFrame {
|
|
code,
|
|
reason: reason.into(),
|
|
};
|
|
let _ = sender.send(Message::Close(Some(close_frame))).await;
|
|
break;
|
|
}
|
|
// Handle broadcast messages
|
|
Ok(msg) = rx.recv() => {
|
|
if let Ok(json) = serde_json::to_string(&msg) {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!("[WS->Client] {}", json);
|
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Handle direct messages (Pong)
|
|
Some(msg) = direct_rx.recv() => {
|
|
if let Ok(json) = serde_json::to_string(&msg) {
|
|
#[cfg(debug_assertions)]
|
|
tracing::debug!("[WS->Client] {}", json);
|
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else => break
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wait for either task to complete, track disconnect reason
|
|
let disconnect_reason = tokio::select! {
|
|
recv_result = recv_task => {
|
|
// recv_task finished, get connection and reason back for cleanup
|
|
if let Ok((mut cleanup_conn, reason)) = recv_result {
|
|
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
|
reason
|
|
} else {
|
|
// Task panicked, use pool (RLS may fail but try anyway)
|
|
let _ = channel_members::leave_channel(&pool, channel_id, user_id).await;
|
|
DisconnectReason::Timeout
|
|
}
|
|
}
|
|
_ = send_task => {
|
|
// send_task finished first (likely client disconnect), need to acquire a new connection for cleanup
|
|
if let Ok(mut cleanup_conn) = pool.acquire().await {
|
|
let _ = set_rls_user_id(&mut cleanup_conn, user_id).await;
|
|
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
|
}
|
|
DisconnectReason::Graceful
|
|
}
|
|
};
|
|
|
|
// Unregister user from direct message routing
|
|
ws_state.unregister_user(user_id);
|
|
|
|
tracing::info!(
|
|
"[WS] User {} disconnected from channel {} (reason: {:?})",
|
|
user_id,
|
|
channel_id,
|
|
disconnect_reason
|
|
);
|
|
|
|
// Broadcast departure with reason
|
|
let _ = channel_state.tx.send(ServerMessage::MemberLeft {
|
|
user_id,
|
|
reason: disconnect_reason,
|
|
});
|
|
}
|
|
|
|
/// 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 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 {
|
|
// All members now have a user_id (guests are regular users with the 'guest' tag)
|
|
// Use the effective avatar resolution which handles all priority levels
|
|
let avatar = avatars::get_effective_avatar_render_data(pool, member.user_id, realm_id)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.map(|(render_data, _source)| render_data)
|
|
.unwrap_or_default();
|
|
result.push(ChannelMemberWithAvatar { member, avatar });
|
|
}
|
|
|
|
Ok(result)
|
|
}
|