use api::users::UserDto; use models::chat::messages::{self, ChatMessageType}; use models::chat::{read_states, room_seq, room_user_settings}; use models::room::rooms::{self, RoomKind}; use sea_orm::sea_query::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::ChatError; use crate::services::service::ChatService; use crate::share::authz; use crate::share::lookup::{lookup_space_by_name, require_room_readable, require_space_member}; use crate::share::notify::next_room_seq; use crate::share::realtime::RoomEvent; #[derive(Debug, Clone, Serialize, ToSchema)] pub struct RoomBriefDto { pub id: Uuid, pub space_id: Option, pub owner_id: i64, pub owner: Option, pub kind: RoomKind, pub name: String, pub topic: Option, pub icon: Option, pub color: Option, pub position: i64, pub is_private: bool, pub nsfw: bool, pub rate_limit_per_user: Option, pub last_message_id: Option, pub unread_count: i64, pub unread_mentions: i64, pub favorite: bool, pub muted: bool, pub hidden: bool, pub member_count: i64, pub created_at: OffsetDateTime, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateRoomCmd { #[serde(default)] pub name: Option, #[serde(default)] pub topic: Option, #[serde(default)] pub is_private: Option, #[serde(default)] pub nsfw: Option, #[serde(default)] pub rate_limit_per_user: Option, #[serde(default)] pub position: Option, #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")] pub icon: Option>, #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")] pub color: Option>, } impl ChatService { pub async fn list_space_rooms( &self, caller: i64, space_name: &str, ) -> Result, ChatError> { let space = lookup_space_by_name(self, space_name).await?; let member = require_space_member(self, &space, caller).await?; let rooms = rooms::Entity::find() .filter(rooms::Column::SpaceId.eq(space.id())) .filter(rooms::Column::IsActive.eq(true)) .order_by_asc(rooms::Column::Position) .all(self.database.read()) .await?; let room_ids: Vec = rooms.iter().map(|r| r.id).collect(); let my_rooms: std::collections::HashSet = models::room::room_members::Entity::find() .filter(models::room::room_members::Column::UserId.eq(caller)) .filter(models::room::room_members::Column::RoomId.is_in(room_ids.clone())) .filter(models::room::room_members::Column::LeftAt.is_null()) .all(self.database.read()) .await? .into_iter() .map(|row| row.room_id) .collect(); let settings = room_user_settings::Entity::find() .filter(room_user_settings::Column::UserId.eq(caller)) .filter(room_user_settings::Column::RoomId.is_in(room_ids.clone())) .all(self.database.read()) .await?; let settings_map: std::collections::HashMap = settings.into_iter().map(|s| (s.room_id, s)).collect(); let read = read_states::Entity::find() .filter(read_states::Column::UserId.eq(caller)) .filter(read_states::Column::RoomId.is_in(room_ids.clone())) .all(self.database.read()) .await?; let read_map: std::collections::HashMap)> = read .into_iter() .map(|r| (r.room_id, (r.last_read_seq, r.last_viewed_at))) .collect(); let seqs = room_seq::Entity::find() .filter(room_seq::Column::RoomId.is_in(room_ids.clone())) .all(self.database.read()) .await?; let seq_map: std::collections::HashMap = seqs.into_iter().map(|s| (s.room_id, s.seq)).collect(); let mention_rows = models::users::user_recent_mentions::Entity::find() .filter(models::users::user_recent_mentions::Column::UserId.eq(caller)) .filter(models::users::user_recent_mentions::Column::ChannelId.is_in(room_ids.clone())) .select_only() .column(models::users::user_recent_mentions::Column::ChannelId) .column(models::users::user_recent_mentions::Column::CreatedAt) .into_tuple::<(Uuid, OffsetDateTime)>() .all(self.database.read()) .await?; let mut mentions_by_room: std::collections::HashMap> = std::collections::HashMap::new(); for (channel_id, created_at) in mention_rows { mentions_by_room .entry(channel_id) .or_default() .push(created_at); } let space_member_count = space.members(caller).await?.len() as i64; let owner_ids: Vec = rooms .iter() .map(|r| r.owner_id) .collect::>() .into_iter() .collect(); let owner_map = self.audience.load_user_briefs(&owner_ids).await?; let mut result = Vec::new(); for room in rooms { let visible = !room.is_private || room.owner_id == caller || my_rooms.contains(&room.id); if !visible { continue; } let (last_read, last_viewed) = read_map.get(&room.id).copied().unwrap_or((0, None)); let tip = seq_map.get(&room.id).copied().unwrap_or(0); let unread_count = std::cmp::max(tip - last_read, 0); let unread_mentions = mentions_by_room .get(&room.id) .map(|list| match last_viewed { Some(at) => list.iter().filter(|c| **c > at).count() as i64, None => list.len() as i64, }) .unwrap_or(0); let setting = settings_map.get(&room.id); if member.hide_muted_channels && setting.is_some_and(|s| crate::share::notify::mute_active(s.muted, s.mute_until)) { continue; } result.push(RoomBriefDto { id: room.id, owner_id: room.owner_id, owner: owner_map.get(&room.owner_id).cloned(), space_id: room.space_id, kind: room.kind, name: room.name, topic: room.topic, icon: room.icon, color: room.color, position: room.position, is_private: room.is_private, nsfw: room.nsfw, rate_limit_per_user: room.rate_limit_per_user, last_message_id: room.last_message_id, unread_count, unread_mentions, favorite: setting.is_some_and(|s| s.favorite), muted: setting.is_some_and(|s| s.muted), hidden: setting.is_some_and(|s| s.hidden), member_count: space_member_count, created_at: room.created_at, }); } Ok(result) } pub async fn get_room(&self, caller: i64, room_id: Uuid) -> Result { let ctx = require_room_readable(self, room_id, caller).await?; let room = ctx.room; let tip = room_seq::Entity::find_by_id(room.id) .one(self.database.read()) .await? .map(|s| s.seq) .unwrap_or(0); let last_read = read_states::Entity::find_by_id((caller, room.id)) .one(self.database.read()) .await? .map(|r| r.last_read_seq) .unwrap_or(0); let setting = room_user_settings::Entity::find_by_id((caller, room.id)) .one(self.database.read()) .await?; let member_count = match &ctx.space { Some(space) => space.members(caller).await?.len() as i64, None => self.room_member_count(room.id).await? as i64, }; let owner_map = self.audience.load_user_briefs(&[room.owner_id]).await?; Ok(RoomBriefDto { id: room.id, owner_id: room.owner_id, owner: owner_map.get(&room.owner_id).cloned(), space_id: room.space_id, kind: room.kind, name: room.name, topic: room.topic, icon: room.icon, color: room.color, position: room.position, is_private: room.is_private, nsfw: room.nsfw, rate_limit_per_user: room.rate_limit_per_user, last_message_id: room.last_message_id, unread_count: std::cmp::max(tip - last_read, 0), unread_mentions: 0, favorite: setting.as_ref().is_some_and(|s| s.favorite), muted: setting.as_ref().is_some_and(|s| s.muted), hidden: setting.as_ref().is_some_and(|s| s.hidden), member_count, created_at: room.created_at, }) } pub async fn update_room( &self, caller: i64, room_id: Uuid, cmd: UpdateRoomCmd, ) -> Result { let ctx = require_room_readable(self, room_id, caller).await?; // Anything beyond a plain topic tweak on a DM is a management operation. let needs_manage = ctx.space.is_some() && (cmd.name.is_some() || cmd.is_private.is_some() || cmd.position.is_some() || cmd.topic.is_some() || cmd.nsfw.is_some() || cmd.rate_limit_per_user.is_some()); if needs_manage { match &ctx.space { Some(space) => { if !authz::can_manage_space(self, space, caller).await? { return Err(ChatError::Forbidden); } } None => return Err(ChatError::Forbidden), } } if ctx.space.is_none() && (cmd.name.is_some() || cmd.is_private.is_some()) { return Err(ChatError::Forbidden); } if let Some(rate) = cmd.rate_limit_per_user && !(0..=3600).contains(&rate) { return Err(ChatError::BadRequest( "rate_limit_per_user must be within 0..=3600".to_string(), )); } let now = OffsetDateTime::now_utc(); let room = ctx.room.clone(); let mut am: rooms::ActiveModel = room.clone().into(); let mut topic_changed: Option<(Option, String)> = None; if let Some(name) = cmd.name { let name = name.trim().to_string(); if name.is_empty() || name.chars().count() > 100 { return Err(ChatError::BadRequest("invalid room name".to_string())); } let lower = name.to_lowercase(); if !lower .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(ChatError::BadRequest("invalid room name".to_string())); } am.name = Set(name.clone()); am.lower_name = Set(lower); } if let Some(topic) = cmd.topic { if topic.chars().count() > self.settings.chat.max_topic_len { return Err(ChatError::BadRequest("topic too long".to_string())); } if topic != room.topic.clone().unwrap_or_default() { topic_changed = Some((room.topic.clone(), topic.clone())); } let stored = if topic.is_empty() { None } else { Some(topic) }; am.topic = Set(stored); } if let Some(is_private) = cmd.is_private { am.is_private = Set(is_private); } if let Some(nsfw) = cmd.nsfw { am.nsfw = Set(nsfw); } if let Some(rate) = cmd.rate_limit_per_user { am.rate_limit_per_user = Set(Some(rate)); } if let Some(position) = cmd.position { if position < 0 { return Err(ChatError::BadRequest( "position must be non-negative".to_string(), )); } am.position = Set(position); } if let Some(icon) = cmd.icon { am.icon = Set(icon); } if let Some(color) = cmd.color { am.color = Set(color); } am.updated_at = Set(now); am.update(self.database.write()).await?; if let Some((_old, new_topic)) = topic_changed { let body = format!("set the channel topic to: {new_topic}"); let _ = self .post_system_message(caller, room_id, ChatMessageType::ChannelTopic, body) .await; } let event = RoomEvent::RoomUpdated { room_id }; self.audience.realtime.publish_room(&event).await; self.get_room(caller, room_id).await } pub(crate) async fn post_system_message( &self, author: i64, room_id: Uuid, mtype: ChatMessageType, content: String, ) -> Result<(), ChatError> { let now = OffsetDateTime::now_utc(); let message_id = Uuid::now_v7(); let seq = self .database .primary() .transaction::<_, i64, ChatError>(move |txn| { Box::pin(async move { let seq = next_room_seq(txn, room_id).await?; messages::ActiveModel { id: Set(message_id), room_id: Set(room_id), author_id: Set(author), author_type: Set(models::chat::messages::AuthorType::User), webhook_id: Set(None), thread_id: Set(None), thread_broadcast: Set(false), reply_to_id: Set(None), forwarded_from_id: Set(None), message_type: Set(mtype), content: Set(content), mentions: Set(serde_json::json!([])), mention_everyone: Set(false), mention_group_ids: Set(serde_json::json!([])), attachments: Set(serde_json::json!([])), embeds: Set(serde_json::json!([])), flags: Set(0), nonce: Set(None), pinned: Set(false), pinned_by: Set(None), pinned_at: Set(None), edited_at: Set(None), deleted_at: Set(None), seq: Set(seq), created_at: Set(now), } .insert(txn) .await?; rooms::Entity::update_many() .filter(rooms::Column::Id.eq(room_id)) .col_expr(rooms::Column::LastMessageId, Expr::value(message_id)) .col_expr(rooms::Column::UpdatedAt, Expr::value(now)) .exec(txn) .await?; Ok(seq) }) }) .await?; let row = messages::Entity::find_by_id(message_id) .one(self.database.read()) .await?; if let Some(row) = row { let items = self.build_message_dto(vec![row], author).await?; if let Some(dto) = items.into_iter().next() { let value = serde_json::to_value(&dto)?; crate::share::notify::publish_message_event(self, room_id, seq, value).await; } } Ok(()) } } #[derive(Clone, Serialize)] pub struct SpaceContext { pub id: Uuid, pub lower_name: String, pub name: String, pub avatar_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateRoomCmd { pub name: String, #[serde(default)] pub kind: RoomKind, #[serde(default)] pub topic: Option, #[serde(default)] pub is_private: bool, #[serde(default)] pub nsfw: bool, #[serde(default)] pub user_limit: Option, #[serde(default)] pub rate_limit_per_user: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct RoomDto { pub id: Uuid, pub space_id: Option, pub kind: RoomKind, pub name: String, pub topic: Option, pub icon: Option, pub color: Option, pub position: i64, pub owner_id: i64, pub is_private: bool, pub nsfw: bool, pub user_limit: Option, pub rate_limit_per_user: Option, pub is_active: bool, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl From for RoomDto { fn from(r: rooms::Model) -> Self { Self { id: r.id, space_id: r.space_id, kind: r.kind, name: r.name, topic: r.topic, icon: r.icon, color: r.color, position: r.position, owner_id: r.owner_id, is_private: r.is_private, nsfw: r.nsfw, user_limit: r.user_limit, rate_limit_per_user: r.rate_limit_per_user, is_active: r.is_active, created_at: r.created_at, updated_at: r.updated_at, } } } async fn room(tx: &impl ConnectionTrait, id: Uuid) -> Result { rooms::Entity::find_by_id(id) .one(tx) .await? .filter(|room| room.is_active) .map(RoomDto::from) .ok_or(ChatError::NotFound) } async fn create_space_room( tx: &impl ConnectionTrait, space: &SpaceContext, actor: i64, cmd: CreateRoomCmd, ) -> Result { if !matches!( cmd.kind, RoomKind::GuildRoom | RoomKind::Text | RoomKind::Category | RoomKind::News | RoomKind::Voice | RoomKind::Stage ) { return Err(ChatError::BadRequest("unsupported room kind: only guild_room, text, category, news, voice and stage can be created".to_owned())); } let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > 100 { return Err(ChatError::BadRequest( "room name is empty or too long".to_owned(), )); } let lower = name.to_lowercase(); if !lower .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(ChatError::BadRequest( "invalid room name: must contain only lowercase letters, digits, and hyphens" .to_owned(), )); } if rooms::Entity::find() .filter(rooms::Column::SpaceId.eq(space.id)) .filter(rooms::Column::LowerName.eq(&lower)) .one(tx) .await? .is_some() { return Err(ChatError::Conflict(format!( "room name {lower} already exists in this space" ))); } let position = rooms::Entity::find() .filter(rooms::Column::SpaceId.eq(space.id)) .order_by_desc(rooms::Column::Position) .one(tx) .await? .map(|row| row.position + 1) .unwrap_or(0); let now = OffsetDateTime::now_utc(); let created = rooms::ActiveModel { id: Set(Uuid::now_v7()), space_id: Set(Some(space.id)), kind: Set(cmd.kind), lower_name: Set(lower), name: Set(name.to_owned()), topic: Set(cmd.topic), icon: Set(None), color: Set(None), position: Set(position), owner_id: Set(actor), is_private: Set(cmd.is_private), nsfw: Set(cmd.nsfw), user_limit: Set(cmd.user_limit), rate_limit_per_user: Set(cmd.rate_limit_per_user), bitrate: Set(None), last_message_id: Set(None), is_active: Set(true), created_at: Set(now), updated_at: Set(now), } .insert(tx) .await?; Ok(created.into()) } async fn deactivate_space_room( tx: &impl ConnectionTrait, space: &SpaceContext, actor: i64, room_id: Uuid, ) -> Result<(), ChatError> { let row = rooms::Entity::find_by_id(room_id) .one(tx) .await? .filter(|row| row.is_active && row.space_id == Some(space.id)) .ok_or(ChatError::NotFound)?; let detail = room_detail(&row); let mut active: rooms::ActiveModel = row.into(); active.is_active = Set(false); active.updated_at = Set(OffsetDateTime::now_utc()); active.update(tx).await?; events::emit( tx, events::EventKind::ChatRoomUpdate, events::Labels::new() .with(events::label::ROOM_ID, room_id) .with(events::label::ACTOR_USER_ID, actor), serde_json::json!({ "room_id": room_id, "deleted": true, "room": detail, "space": space, }), ) .await?; Ok(()) } fn room_detail(room: &rooms::Model) -> serde_json::Value { serde_json::json!({ "id": room.id, "space_id": room.space_id, "kind": room.kind, "name": room.name, "topic": room.topic }) } impl ChatService { pub async fn create_room( &self, caller: i64, space_name: &str, cmd: CreateRoomCmd, ) -> Result { self.space .allow_room_creation(caller) .await .map_err(|error| match error { space::errors::SpaceError::RateLimited(message) => ChatError::RateLimited(message), other => ChatError::Internal(other.to_string()), })?; let space = lookup_space_by_name(self, space_name).await?; if !authz::can_manage_space(self, &space, caller).await? { return Err(ChatError::Forbidden); } let context = SpaceContext { id: space.id(), lower_name: space.slug().to_owned(), name: space.name().to_owned(), avatar_url: space.avatar_url().await?, }; let created = self .database .write() .transaction::<_, RoomDto, ChatError>(move |tx| { Box::pin(async move { create_space_room(tx, &context, caller, cmd).await }) }) .await?; Ok(created) } pub async fn delete_room(&self, caller: i64, room_id: Uuid) -> Result<(), ChatError> { let room = room(self.database.read(), room_id).await?; let space_id = room.space_id.ok_or(ChatError::NotFound)?; let space = self.participants.access.by_id(space_id).await?; if !authz::can_manage_space(self, &space, caller).await? { return Err(ChatError::Forbidden); } let context = SpaceContext { id: space.id(), lower_name: space.slug().to_owned(), name: space.name().to_owned(), avatar_url: space.avatar_url().await?, }; self.database .write() .transaction::<_, (), ChatError>(move |tx| { Box::pin(async move { deactivate_space_room(tx, &context, caller, room_id).await }) }) .await?; Ok(()) } }