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) <noreply@anthropic.com>
This commit is contained in:
ember33 2026-03-17 17:13:59 +01:00
parent dfa38a1b49
commit f143248636

View file

@ -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<globals::Service>,
state_accessor: Dep<rooms::state_accessor::Service>,
#[allow(dead_code)]
state_cache: Dep<rooms::state_cache::Service>,
state: Dep<rooms::state::Service>,
#[allow(dead_code)]
@ -56,6 +60,7 @@ impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
globals: args.depend::<globals::Service>("globals"),
state_accessor: args
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
state_cache: args.depend::<rooms::state_cache::Service>("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<OwnedRoomId> {
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<OwnedUserId> = 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<OwnedRoomId> = 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<OwnedRoomId> = 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(())
}