From f1432486362409fb31d16d7f4714b03a717929f4 Mon Sep 17 00:00:00 2001 From: ember33 Date: Tue, 17 Mar 2026 17:13:59 +0100 Subject: [PATCH] feat(spaces): add power level sync, auto-join, and auto-kick methods - sync_power_levels(): Overrides child room PLs with Space role PLs - auto_join_qualifying_rooms(): Joins user to all rooms they qualify for - kick_unqualified_from_rooms(): Kicks user from rooms they no longer qualify for - Adds globals dep for server_user access Co-Authored-By: Claude Opus 4.6 (1M context) --- src/service/rooms/roles/mod.rs | 264 ++++++++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 4 deletions(-) diff --git a/src/service/rooms/roles/mod.rs b/src/service/rooms/roles/mod.rs index 155f9909..1bc146cb 100644 --- a/src/service/rooms/roles/mod.rs +++ b/src/service/rooms/roles/mod.rs @@ -5,7 +5,7 @@ use std::{ }; use async_trait::async_trait; -use conduwuit::{Event, Result, Server, implement}; +use conduwuit::{Event, Result, Server, debug_warn, implement, matrix::pdu::PduBuilder, warn}; use conduwuit_core::{ matrix::space_roles::{ RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, @@ -18,15 +18,19 @@ use conduwuit_core::{ }; use futures::{StreamExt, TryFutureExt}; use ruma::{ - OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId, + Int, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId, events::{ StateEventType, + room::{ + member::{MembershipState, RoomMemberEventContent}, + power_levels::RoomPowerLevelsEventContent, + }, space::child::SpaceChildEventContent, }, }; use tokio::sync::RwLock; -use crate::{Dep, rooms}; +use crate::{Dep, globals, rooms}; pub struct Service { services: Services, @@ -42,8 +46,8 @@ pub struct Service { } struct Services { + globals: Dep, state_accessor: Dep, - #[allow(dead_code)] state_cache: Dep, state: Dep, #[allow(dead_code)] @@ -56,6 +60,7 @@ impl crate::Service for Service { fn build(args: crate::Args<'_>) -> Result> { Ok(Arc::new(Self { services: Services { + globals: args.depend::("globals"), state_accessor: args .depend::("rooms::state_accessor"), state_cache: args.depend::("rooms::state_cache"), @@ -278,3 +283,254 @@ pub async fn user_qualifies_for_room( pub async fn get_parent_space(&self, room_id: &RoomId) -> Option { self.room_to_space.read().await.get(room_id).cloned() } + +/// Synchronize power levels in a child room based on Space roles. +/// This overrides per-room power levels with Space-granted levels. +#[implement(Service)] +pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Result { + if !self.is_enabled() { + return Ok(()); + } + + // 1. Get current power levels for the room + let mut power_levels_content: RoomPowerLevelsEventContent = self + .services + .state_accessor + .room_state_get_content(room_id, &StateEventType::RoomPowerLevels, "") + .await + .unwrap_or_default(); + + // 2. Get all members of the room + let members: Vec = self + .services + .state_cache + .room_members(room_id) + .map(ToOwned::to_owned) + .collect() + .await; + + // 3. For each member, check their space role power level + let mut changed = false; + for user_id in &members { + if let Some(space_pl) = self.get_user_power_level(space_id, user_id).await { + let space_pl_int = Int::new_saturating(space_pl); + let current_pl = power_levels_content + .users + .get(user_id) + .copied() + .unwrap_or(power_levels_content.users_default); + + // 4. If the space PL differs from room PL, update it + if current_pl != space_pl_int { + power_levels_content + .users + .insert(user_id.clone(), space_pl_int); + changed = true; + } + } + } + + // 5. If changed, send updated power levels event + if changed { + let state_lock = self.services.state.mutex.lock(room_id).await; + let server_user = self.services.globals.server_user.as_ref(); + + self.services + .timeline + .build_and_append_pdu( + PduBuilder::state(String::new(), &power_levels_content), + server_user, + Some(room_id), + &state_lock, + ) + .await?; + } + + Ok(()) +} + +/// Auto-join a user to all qualifying child rooms of a Space. +/// +/// Iterates over all child rooms in the `room_to_space` reverse index that +/// belong to the given space, checks whether the user qualifies via their +/// assigned roles, and force-joins them if they are not already a member. +#[implement(Service)] +pub async fn auto_join_qualifying_rooms( + &self, + space_id: &RoomId, + user_id: &UserId, +) -> Result { + if !self.is_enabled() { + return Ok(()); + } + + // Get all child rooms from the room_to_space reverse index + // Filter to children of this space + let child_rooms: Vec = self + .room_to_space + .read() + .await + .iter() + .filter(|(_, parent)| **parent == *space_id) + .map(|(child, _)| child.clone()) + .collect(); + + let server_user = self.services.globals.server_user.as_ref(); + + for child_room_id in &child_rooms { + // Skip if already joined + if self + .services + .state_cache + .is_joined(user_id, child_room_id) + .await + { + continue; + } + + // Check if user qualifies + if !self + .user_qualifies_for_room(space_id, child_room_id, user_id) + .await + { + continue; + } + + let state_lock = self.services.state.mutex.lock(child_room_id).await; + + // First invite the user (server user as sender) + if let Err(e) = self + .services + .timeline + .build_and_append_pdu( + PduBuilder::state( + user_id.to_string(), + &RoomMemberEventContent::new(MembershipState::Invite), + ), + server_user, + Some(child_room_id), + &state_lock, + ) + .await + { + debug_warn!( + "Failed to invite {user_id} to {child_room_id} during auto-join: {e}" + ); + continue; + } + + // Then join (user as sender) + if let Err(e) = self + .services + .timeline + .build_and_append_pdu( + PduBuilder::state( + user_id.to_string(), + &RoomMemberEventContent::new(MembershipState::Join), + ), + user_id, + Some(child_room_id), + &state_lock, + ) + .await + { + warn!("Failed to auto-join {user_id} to {child_room_id}: {e}"); + } + } + + Ok(()) +} + +/// Remove a user from all child rooms they no longer qualify for. +/// +/// Iterates over child rooms that have role requirements for the given +/// space, checks whether the user still qualifies, and kicks them with a +/// reason if they do not. +#[implement(Service)] +pub async fn kick_unqualified_from_rooms( + &self, + space_id: &RoomId, + user_id: &UserId, +) -> Result { + if !self.is_enabled() { + return Ok(()); + } + + // Get child rooms that have requirements + let child_rooms: Vec = self + .room_requirements + .read() + .await + .get(space_id) + .map(|reqs| reqs.keys().cloned().collect()) + .unwrap_or_default(); + + let server_user = self.services.globals.server_user.as_ref(); + + for child_room_id in &child_rooms { + // Skip if not joined + if !self + .services + .state_cache + .is_joined(user_id, child_room_id) + .await + { + continue; + } + + // Check if user still qualifies + if self + .user_qualifies_for_room(space_id, child_room_id, user_id) + .await + { + continue; + } + + // Get existing member event content for the kick + let member_content = match self + .services + .state_accessor + .get_member(child_room_id, user_id) + .await + { + | Ok(event) => event, + | Err(_) => { + debug_warn!( + "Could not get member event for {user_id} in {child_room_id}, skipping kick" + ); + continue; + }, + }; + + let state_lock = self.services.state.mutex.lock(child_room_id).await; + + // Kick the user by setting membership to Leave with a reason + if let Err(e) = self + .services + .timeline + .build_and_append_pdu( + PduBuilder::state( + user_id.to_string(), + &RoomMemberEventContent { + membership: MembershipState::Leave, + reason: Some("No longer has required Space roles".into()), + is_direct: None, + join_authorized_via_users_server: None, + third_party_invite: None, + ..member_content + }, + ), + server_user, + Some(child_room_id), + &state_lock, + ) + .await + { + warn!( + "Failed to kick {user_id} from {child_room_id} for missing roles: {e}" + ); + } + } + + Ok(()) +}