use room::ws_context::WsUserContext; use uuid::Uuid; use super::session::TransportSession; use super::types::{WsInMessage, WsOutEvent}; use crate::error::AppTransportError; use crate::event::{category, message, reaction}; fn service_err(op: &str, err: E) -> AppTransportError { tracing::warn!(error = %err, operation = %op, "WS service operation failed"); AppTransportError::Internal } pub struct MessageHandler; impl MessageHandler { pub async fn handle( session: &TransportSession, msg: WsInMessage, ) -> Result, AppTransportError> { match msg { WsInMessage::Ping => Ok(Some(WsOutEvent::Pong { protocol_version: super::types::WS_PROTOCOL_VERSION, })), WsInMessage::Subscribe { room } => { let sub = session .subscribe_room(room) .await .map_err(|e| service_err("subscribe_room", e))?; session.subscriptions.insert(room, sub); // Lazily spawn NATS broadcast workers for this room so that // messages from other users can be delivered in real time. // SPAWNED_ROOMS guard prevents duplicate spawns. session.service.room.spawn_room_workers(room); Ok(None) } WsInMessage::Unsubscribe { room } => { session.unsubscribe_room(room).await; Ok(None) } WsInMessage::TypingStart { room } => { session.broadcast_typing(room, "start").await; Ok(None) } WsInMessage::TypingStop { room } => { session.broadcast_typing(room, "stop").await; Ok(None) } WsInMessage::ReadReceipt { room, last_read_seq, } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_user_state_update_read_seq(room, last_read_seq, &ctx) .await .map_err(|e| service_err("room_user_state_update_read_seq", e))?; Ok(None) } WsInMessage::MessageList { room, before_seq, after_seq, limit, } => { let ctx = WsUserContext::new(session.user.user_id); let list = session .service .room .room_message_list(room, before_seq, after_seq, limit, &ctx) .await .map_err(|e| service_err("room_message_list", e))?; let resp = message::MessageListService { room, messages: list .messages .iter() .map(|m| message::MessageNewService { id: m.id, seq: m.seq, room: m.room, sender_type: m.sender_type.clone(), sender_id: m.sender_id, display_name: m.display_name.clone(), thread: m.thread, in_reply_to: m.in_reply_to, content: m.content.clone(), content_type: m.content_type.clone(), thinking_content: m.thinking_content.clone(), thinking_is_chunked: m.thinking_is_chunked, send_at: m.send_at, reactions: Some( m.reactions .iter() .map(|r| reaction::ReactionGroup { emoji: r.emoji.clone(), count: r.count as i64, reacted_by_me: r.reacted_by_me, users: r.users.iter().filter_map(|u| Uuid::parse_str(u).ok()).collect(), }) .collect(), ), }) .collect(), total: list.total, }; Ok(Some(WsOutEvent::MessageList { room_id: room, data: resp, })) } WsInMessage::MessageCreate { room, content, content_type, thread, in_reply_to, } => { let ctx = WsUserContext::new(session.user.user_id); let msg = session .service .room .room_message_create( room, room::RoomMessageCreateRequest { content, content_type, thread, in_reply_to, attachment_ids: vec![], }, &ctx, ) .await .map_err(|e| service_err("room_message_create", e))?; Ok(Some(WsOutEvent::MessageNew { room_id: room, data: message::MessageNewService { id: msg.id, seq: msg.seq, room: msg.room, sender_type: msg.sender_type, sender_id: msg.sender_id, display_name: msg.display_name, thread: msg.thread, in_reply_to: msg.in_reply_to, content: msg.content, content_type: msg.content_type, thinking_content: msg.thinking_content, thinking_is_chunked: false, send_at: msg.send_at, reactions: None, }, })) } WsInMessage::MessageUpdate { message, content } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_message_update(message, room::RoomMessageUpdateRequest { content }, &ctx) .await .map_err(|e| service_err("room_message_update", e))?; Ok(None) } WsInMessage::MessageRevoke { message } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_message_revoke(message, &ctx) .await .map_err(|e| service_err("room_message_revoke", e))?; Ok(None) } WsInMessage::RoomGet { room } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .require_room_access(room, ctx.user_id) .await .map_err(|e| service_err("require_room_access", e))?; let rm = session .service .room .find_room_or_404(room) .await .map_err(|e| service_err("find_room", e))?; Ok(Some(WsOutEvent::RoomCreated { room_id: rm.id, data: crate::event::rooms::RoomCreatedService { id: rm.id, project: rm.project, room_name: rm.room_name, public: rm.public, category: rm.category, created_by: rm.created_by, created_at: rm.created_at, }, })) } WsInMessage::RoomCreate { project, room_name, public, category, } => { let ctx = WsUserContext::new(session.user.user_id); let rm = session .service .room .room_create( project.to_string(), room::RoomCreateRequest { room_name, public, category, }, &ctx, ) .await .map_err(|e| service_err("room_create", e))?; Ok(Some(WsOutEvent::RoomCreated { room_id: rm.id, data: crate::event::rooms::RoomCreatedService { id: rm.id, project, room_name: rm.room_name, public: rm.public, category, created_by: session.user.user_id, created_at: rm.created_at, }, })) } WsInMessage::RoomUpdate { room, room_name, public, category, } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_update( room, room::RoomUpdateRequest { room_name, public, category, }, &ctx, ) .await .map_err(|e| service_err("room_update", e))?; Ok(None) } WsInMessage::RoomDelete { room } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_delete(room, &ctx) .await .map_err(|e| service_err("room_delete", e))?; Ok(None) } WsInMessage::CategoryCreate { project, name, position, } => { let ctx = WsUserContext::new(session.user.user_id); let cat = session .service .room .room_category_create( project.to_string(), room::RoomCategoryCreateRequest { name, position }, &ctx, ) .await .map_err(|e| service_err("room_category_create", e))?; Ok(Some(WsOutEvent::CategoryCreated { project, data: category::CategoryCreatedService { id: cat.id, project, name: cat.name, position: cat.position, created_by: session.user.user_id, created_at: cat.created_at, }, })) } WsInMessage::CategoryUpdate { id, name, position } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_category_update( id, room::RoomCategoryUpdateRequest { name, position }, &ctx, ) .await .map_err(|e| service_err("room_category_update", e))?; Ok(None) } WsInMessage::CategoryDelete { id } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_category_delete(id, &ctx) .await .map_err(|e| service_err("room_category_delete", e))?; Ok(None) } WsInMessage::AccessGrant { room, user } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_access_grant(room, user, &ctx) .await .map_err(|e| service_err("room_access_grant", e))?; Ok(None) } WsInMessage::AccessRevoke { room, user } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_access_revoke(room, user, &ctx) .await .map_err(|e| service_err("room_access_revoke", e))?; Ok(None) } WsInMessage::ReactionAdd { room, message, emoji, } => { let ctx = WsUserContext::new(session.user.user_id); let rxs = session .service .room .message_reaction_add(message, emoji, &ctx) .await .map_err(|e| service_err("message_reaction_add", e))?; Ok(Some(WsOutEvent::ReactionBatchUpdated { room_id: room, data: reaction::ReactionBatchUpdatedService { room: room, message: message, reactions: rxs .reactions .into_iter() .map(|g| reaction::ReactionGroup { emoji: g.emoji, count: g.count as i64, reacted_by_me: g.reacted_by_me, users: g .users .iter() .filter_map(|u| u.parse::().ok()) .collect(), }) .collect(), }, })) } WsInMessage::ReactionRemove { room, message, emoji, } => { let ctx = WsUserContext::new(session.user.user_id); let rxs = session .service .room .message_reaction_remove(message, emoji, &ctx) .await .map_err(|e| service_err("message_reaction_remove", e))?; Ok(Some(WsOutEvent::ReactionBatchUpdated { room_id: room, data: reaction::ReactionBatchUpdatedService { room: room, message: message, reactions: rxs .reactions .into_iter() .map(|g| reaction::ReactionGroup { emoji: g.emoji, count: g.count as i64, reacted_by_me: g.reacted_by_me, users: g .users .iter() .filter_map(|u| u.parse::().ok()) .collect(), }) .collect(), }, })) } WsInMessage::ThreadCreate { room, parent } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_thread_create( room, room::RoomThreadCreateRequest { parent_seq: parent }, &ctx, ) .await .map_err(|e| service_err("room_thread_create", e))?; Ok(None) } WsInMessage::ThreadResolve { thread_id } => { // Dummy implementation since backend thread.rs doesn't have resolve yet tracing::info!(%thread_id, "Thread resolved"); Ok(None) } WsInMessage::ThreadArchive { thread_id } => { // Dummy implementation since backend thread.rs doesn't have archive yet tracing::info!(%thread_id, "Thread archived"); Ok(None) } WsInMessage::PinAdd { room: _, message } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_pin_add(message, &ctx) .await .map_err(|e| service_err("room_pin_add", e))?; Ok(None) } WsInMessage::PinRemove { room: _, message } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_pin_remove(message, &ctx) .await .map_err(|e| service_err("room_pin_remove", e))?; Ok(None) } WsInMessage::DraftSave { room, content } => { let ctx = WsUserContext::new(session.user.user_id); let draft = session .service .room .draft_save(room, content, &ctx) .await .map_err(|e| service_err("draft_save", e))?; Ok(Some(WsOutEvent::DraftSaved { room_id: room, data: crate::event::draft::DraftSavedService { user_id: session.user.user_id, room: draft.room_id, content: draft.content, saved_at: draft.saved_at, }, })) } WsInMessage::DraftClear { room } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .draft_clear(room, &ctx) .await .map_err(|e| service_err("draft_clear", e))?; Ok(Some(WsOutEvent::DraftCleared { room_id: room, data: crate::event::draft::DraftClearedService { user_id: session.user.user_id, room, cleared_at: chrono::Utc::now(), }, })) } WsInMessage::NotificationMarkRead { id } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .notification_mark_read(id, &ctx) .await .map_err(|e| service_err("notification_mark_read", e))?; Ok(None) } WsInMessage::NotificationMarkAllRead { .. } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .notification_mark_all_read(&ctx) .await .map_err(|e| service_err("notification_mark_all_read", e))?; Ok(None) } WsInMessage::NotificationArchive { id } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .notification_archive(id, &ctx) .await .map_err(|e| service_err("notification_archive", e))?; Ok(None) } WsInMessage::Search { q, room, start_time, end_time, sender_id, content_type, limit, offset, } => { let ctx = WsUserContext::new(session.user.user_id); let room_id = room.unwrap_or_default(); // Assuming search requires a room for now let req = room::RoomMessageSearchRequest { q: q.clone(), start_time, end_time, sender_id, content_type, limit, offset, }; let res = session .service .room .room_message_search(room_id, req, &ctx) .await .map_err(|e| service_err("room_message_search", e))?; let resp = crate::event::search::SearchResultService { q, room: room_id, took_ms: 0, messages: res .messages .into_iter() .map(|m| crate::event::search::SearchMessageHitService { highlighted_content: m.highlighted_content.unwrap_or_default(), message: crate::event::message::MessageNewService { id: m.id, seq: m.seq, room: m.room, sender_type: m.sender_type, sender_id: m.sender_id, display_name: m.display_name, thread: m.thread, in_reply_to: m.in_reply_to, content: m.content, content_type: m.content_type, thinking_content: m.thinking_content, thinking_is_chunked: m.thinking_is_chunked, send_at: m.send_at, reactions: None, }, }) .collect(), total: res.total, }; Ok(Some(WsOutEvent::SearchResult { data: resp })) } WsInMessage::PresenceUpdate { status } => { // Get project context from session's subscribed rooms (first room's project) let project_id = session.get_current_project().await; // Convert transport status to room presence status let presence_status = match status { crate::event::presence::UserPresenceStatus::Online => room::presence::PresenceStatus::Online, crate::event::presence::UserPresenceStatus::Idle => room::presence::PresenceStatus::Idle, crate::event::presence::UserPresenceStatus::Dnd => room::presence::PresenceStatus::Dnd, crate::event::presence::UserPresenceStatus::Offline => room::presence::PresenceStatus::Offline, }; let event = session .service .room .set_user_presence(session.user.user_id, project_id, presence_status) .await; if let Some(evt) = event { // Convert to transport event type let transport_status = match evt.status { room::presence::PresenceStatus::Online => crate::event::presence::UserPresenceStatus::Online, room::presence::PresenceStatus::Idle => crate::event::presence::UserPresenceStatus::Idle, room::presence::PresenceStatus::Dnd => crate::event::presence::UserPresenceStatus::Dnd, room::presence::PresenceStatus::Offline => crate::event::presence::UserPresenceStatus::Offline, }; Ok(Some(WsOutEvent::PresenceChanged { data: crate::event::presence::PresenceChangedService { user: evt.user_id, project: evt.project_id, status: transport_status, last_seen_at: evt.last_seen_at, }, })) } else { Ok(None) } } WsInMessage::CustomStatusUpdate { emoji, text, expires_at, } => { let evt = session .service .room .set_custom_status(session.user.user_id, emoji.clone(), text.clone(), expires_at); if let Some(data) = evt { Ok(Some(WsOutEvent::CustomStatusUpdated { data: crate::event::presence::CustomStatusUpdatedService { user: data.user_id, emoji: data.emoji, text: data.text, expires_at: data.expires_at, }, })) } else { Ok(None) } } WsInMessage::InviteCreate { .. } => { tracing::info!("Invite create"); Ok(None) } WsInMessage::InviteAccept { .. } => { tracing::info!("Invite accept"); Ok(None) } WsInMessage::InviteRevoke { .. } => { tracing::info!("Invite revoke"); Ok(None) } WsInMessage::BanCreate { .. } => { tracing::info!("Ban create"); Ok(None) } WsInMessage::BanRemove { .. } => { tracing::info!("Ban remove"); Ok(None) } WsInMessage::VoiceJoin { room } => { tracing::info!(%room, "Voice join"); Ok(None) } WsInMessage::VoiceLeave { room } => { tracing::info!(%room, "Voice leave"); Ok(None) } WsInMessage::VoiceMute { room, muted } => { tracing::info!(%room, %muted, "Voice mute"); Ok(None) } WsInMessage::VoiceDeaf { room, deafened } => { tracing::info!(%room, %deafened, "Voice deaf"); Ok(None) } WsInMessage::ScreenShare { room, start } => { tracing::info!(%room, %start, "Screen share"); Ok(None) } WsInMessage::AiList { room } => { let ctx = WsUserContext::new(session.user.user_id); let ai_list = session .service .room .room_ai_list(room, &ctx) .await .map_err(|e| service_err("room_ai_list", e))?; let data = serde_json::to_value(ai_list).unwrap_or_default(); Ok(Some(WsOutEvent::Response { request_id: Uuid::nil(), data, })) } WsInMessage::AiUpsert { room, model, version, system_prompt, temperature, max_tokens, stream, } => { let ctx = WsUserContext::new(session.user.user_id); let req = room::RoomAiUpsertRequest { model, version, system_prompt, temperature, max_tokens, stream, history_limit: None, use_exact: None, think: None, min_score: None, agent_type: None, }; let ai_model = session .service .room .room_ai_upsert(room, req, &ctx) .await .map_err(|e| service_err("room_ai_upsert", e))?; let data = serde_json::to_value(ai_model).unwrap_or_default(); Ok(Some(WsOutEvent::Response { request_id: Uuid::nil(), data, })) } WsInMessage::AiDelete { room, agent_id } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_ai_delete(room, agent_id, &ctx) .await .map_err(|e| service_err("room_ai_delete", e))?; Ok(None) } WsInMessage::AiStop { room } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_ai_stop(room, &ctx) .await .map_err(|e| service_err("room_ai_stop", e))?; Ok(None) } WsInMessage::UserSummary { username } => { let ctx = session.to_session(); let summary = session .service .user_get_summary(ctx, username) .await .map_err(|e| service_err("user_get_summary", e))?; let data = serde_json::to_value(summary).unwrap_or_default(); Ok(Some(WsOutEvent::Response { request_id: Uuid::nil(), // Filled by ws_handler if rid exists data, })) } WsInMessage::StateSetReadSeq { room, last_read_seq, } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_user_state_update_read_seq(room, last_read_seq, &ctx) .await .map_err(|e| service_err("room_user_state_update_read_seq", e))?; Ok(None) } WsInMessage::StateUpdateDnd { room, do_not_disturb, dnd_start_hour, dnd_end_hour, } => { let ctx = WsUserContext::new(session.user.user_id); session .service .room .room_user_state_update_dnd( room, room::RoomUserStateUpdateDndRequest { do_not_disturb, dnd_start_hour, dnd_end_hour, }, &ctx, ) .await .map_err(|e| service_err("room_user_state_update_dnd", e))?; Ok(None) } } } }