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:
parent
dfa38a1b49
commit
f143248636
1 changed files with 260 additions and 4 deletions
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue