Merge branch 'feature/smart-pl-syncing' into develop

This commit is contained in:
William Kray
2025-04-04 10:31:30 -07:00
+247 -49
View File
@@ -66,6 +66,26 @@ class CommunityBot(Plugin):
self._redaction_tasks.cancel() self._redaction_tasks.cancel()
await super().stop() await super().stop()
async def user_permitted(self, user_id: UserID, min_level: int = 50) -> bool:
"""Check if a user has sufficient power level in the parent room.
Args:
user_id: The Matrix ID of the user to check
min_level: Minimum required power level (default 50 for moderator)
Returns:
bool: True if user has sufficient power level
"""
try:
power_levels = await self.client.get_state_event(
self.config["parent_room"], EventType.ROOM_POWER_LEVELS
)
user_level = power_levels.get_user_level(user_id)
return user_level >= min_level
except Exception as e:
self.log.error(f"Failed to check user power level: {e}")
return False
async def _redaction_loop(self) -> None: async def _redaction_loop(self) -> None:
while True: while True:
try: try:
@@ -536,6 +556,82 @@ class CommunityBot(Plugin):
except Exception as e: except Exception as e:
self.log.error(e) self.log.error(e)
@event.on(EventType.ROOM_POWER_LEVELS)
async def sync_power_levels(self, evt: StateEvent) -> None:
# Only care about changes in the parent room
if evt.room_id != self.config['parent_room']:
return
# Get the changed user and their new power level
try:
old_levels = evt.prev_content.get("users", {})
new_levels = evt.content.get("users", {})
# Find which user's power level changed
changed_users = {}
for user, new_level in new_levels.items():
if user not in old_levels or old_levels[user] != new_level:
changed_users[user] = new_level
if not changed_users:
return
# Get all rooms in the space
space_rooms = await self.client.get_joined_rooms()
success_rooms = []
failed_rooms = []
# Apply the same power level changes to each room
for room_id in space_rooms:
if room_id == self.config['parent_room']:
continue
try:
roomname = (await self.client.get_state_event(room_id, "m.room.name"))["name"]
except:
self.log.warning(f"Unable to get room name for {room_id}")
# Get current power levels
try:
# Get current power levels
current_pl = await self.client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
# Update existing power levels object with new levels
users = current_pl.get("users", {})
for user, level in changed_users.items():
users[user] = level
current_pl["users"] = users
# Send updated power levels
try:
await self.client.send_state_event(room_id, EventType.ROOM_POWER_LEVELS, current_pl)
success_rooms.append(roomname or room_id)
except Exception as e:
self.log.error(f"Failed to send power levels to {roomname or room_id}: {e}")
failed_rooms.append(roomname or room_id)
time.sleep(self.config['sleep'])
except Exception as e:
self.log.warning(f"Failed to update power levels in {room_id}: {e}")
failed_rooms.append(room_id)
# Send notification if configured
if self.config["notification_room"]:
changes = ", ".join([f"{user}{level}" for user, level in changed_users.items()])
notification = f"Power level changes ({changes}) propagated from parent room:<br>"
notification += f"Succeeded in: <code>{', '.join(success_rooms)}</code><br>"
if failed_rooms:
notification += f"Failed in: <code>{', '.join(failed_rooms)}</code>"
await self.client.send_notice(
self.config["notification_room"],
html=notification
)
except Exception as e:
self.log.error(f"Error syncing power levels: {e}")
@event.on(InternalEventType.JOIN) @event.on(InternalEventType.JOIN)
async def newjoin(self, evt:StateEvent) -> None: async def newjoin(self, evt:StateEvent) -> None:
@@ -581,9 +677,7 @@ class CommunityBot(Plugin):
#self.log.debug(f"DEBUGDEBUG user {evt.sender} has power level {user_level}") #self.log.debug(f"DEBUGDEBUG user {evt.sender} has power level {user_level}")
if self.flag_message(evt): if self.flag_message(evt):
# do we need to redact? # do we need to redact?
if evt.sender not in self.config['admins'] and \ if not await self.user_permitted(evt.sender) and \
evt.sender not in self.config['moderators'] and \
user_level < self.config['uncensor_pl'] and \
evt.sender != self.client.mxid and \ evt.sender != self.client.mxid and \
self.censor_room(evt): self.censor_room(evt):
try: try:
@@ -593,9 +687,7 @@ class CommunityBot(Plugin):
if evt.content.msgtype in {MessageType.TEXT, MessageType.NOTICE, MessageType.EMOTE}: if evt.content.msgtype in {MessageType.TEXT, MessageType.NOTICE, MessageType.EMOTE}:
if self.flag_instaban(evt): if self.flag_instaban(evt):
# do we need to redact? # do we need to redact?
if evt.sender not in self.config['admins'] and \ if not await self.user_permitted(evt.sender) and \
evt.sender not in self.config['moderators'] and \
user_level < self.config['uncensor_pl'] and \
evt.sender != self.client.mxid and \ evt.sender != self.client.mxid and \
self.censor_room(evt): self.censor_room(evt):
try: try:
@@ -655,7 +747,46 @@ class CommunityBot(Plugin):
@community.subcommand("sync", help="update the activity tracker with the current space members \ @community.subcommand("sync", help="update the activity tracker with the current space members \
in case they are missing") in case they are missing")
async def sync_space_members(self, evt: MessageEvent) -> None: async def sync_space_members(self, evt: MessageEvent) -> None:
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
# check config values for admins and moderators. if they have a lower PL in the parent room,
# attempt to update the parent room with their appropriate admin/mod status
# we can skip all of this logic if those config values are empty
# this logic helps migrate explicit configuration to the parent-room inheritance model
if not self.config["admins"] and not self.config["moderators"]:
self.log.info("no admins or moderators configured, skipping power level sync")
pass
else:
power_levels = await self.client.get_state_event(self.config["parent_room"], EventType.ROOM_POWER_LEVELS)
users = power_levels.get("users", {})
for user in self.config["admins"]:
if user not in users or users.get(user) < 100:
# update the users object in-place
users[user] = 100
for user in self.config["moderators"]:
if user not in users or users.get(user) < 50:
# update the users object in-place
users[user] = 50
try:
# update full powerlevels object with updated user object
power_levels["users"] = users
await self.client.send_state_event(self.config["parent_room"], EventType.ROOM_POWER_LEVELS, power_levels)
# if updating was successful, let's go ahead and clear out the values in the config
self.config["admins"] = []
self.config["moderators"] = []
# and save the config to the file
self.config.save()
self.log.debug("successfully migrated admin/mod config to parent room")
except Exception as e:
self.log.error(f"Failed to send power levels to {self.config["parent_room"]}: {e}")
await evt.respond(f"Failed to send power levels to {self.config["parent_room"]}: {e}")
if not self.config["track_users"]: if not self.config["track_users"]:
await evt.respond("user tracking is disabled") await evt.respond("user tracking is disabled")
return return
@@ -665,14 +796,15 @@ class CommunityBot(Plugin):
added_str = "<br />".join(results['added']) added_str = "<br />".join(results['added'])
dropped_str = "<br />".join(results['dropped']) dropped_str = "<br />".join(results['dropped'])
await evt.respond(f"Added: {added_str}<br /><br />Dropped: {dropped_str}", allow_html=True) await evt.respond(f"Added: {added_str}<br /><br />Dropped: {dropped_str}", allow_html=True)
else:
await evt.reply("lol you don't have permission to do that")
@community.subcommand("ignore", help="exclude a specific matrix ID from inactivity tracking") @community.subcommand("ignore", help="exclude a specific matrix ID from inactivity tracking")
@command.argument("mxid", "full matrix ID", required=True) @command.argument("mxid", "full matrix ID", required=True)
async def ignore_inactivity(self, evt: MessageEvent, mxid: UserID) -> None: async def ignore_inactivity(self, evt: MessageEvent, mxid: UserID) -> None:
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
if not self.config["track_users"]: if not self.config["track_users"]:
await evt.reply("user tracking is disabled") await evt.reply("user tracking is disabled")
return return
@@ -685,13 +817,14 @@ class CommunityBot(Plugin):
await evt.react("") await evt.react("")
except Exception as e: except Exception as e:
await evt.respond(f"{e}") await evt.respond(f"{e}")
else:
await evt.reply("lol you don't have permission to set that")
@community.subcommand("unignore", help="re-enable activity tracking for a specific matrix ID") @community.subcommand("unignore", help="re-enable activity tracking for a specific matrix ID")
@command.argument("mxid", "full matrix ID", required=True) @command.argument("mxid", "full matrix ID", required=True)
async def unignore_inactivity(self, evt: MessageEvent, mxid: UserID) -> None: async def unignore_inactivity(self, evt: MessageEvent, mxid: UserID) -> None:
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
if not self.config["track_users"]: if not self.config["track_users"]:
await evt.reply("user tracking is disabled") await evt.reply("user tracking is disabled")
return return
@@ -704,12 +837,13 @@ class CommunityBot(Plugin):
await evt.react("") await evt.react("")
except Exception as e: except Exception as e:
await evt.respond(f"{e}") await evt.respond(f"{e}")
else:
await evt.reply("lol you don't have permission to set that")
@community.subcommand("report", help='generate a list of matrix IDs that have been inactive') @community.subcommand("report", help='generate a list of matrix IDs that have been inactive')
async def get_report(self, evt: MessageEvent) -> None: async def get_report(self, evt: MessageEvent) -> None:
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
if not self.config["track_users"]: if not self.config["track_users"]:
await evt.reply("user tracking is disabled") await evt.reply("user tracking is disabled")
return return
@@ -729,7 +863,10 @@ class CommunityBot(Plugin):
@community.subcommand("purge", help='kick users for excessive inactivity') @community.subcommand("purge", help='kick users for excessive inactivity')
async def kick_users(self, evt: MessageEvent) -> None: async def kick_users(self, evt: MessageEvent) -> None:
await evt.mark_read() await evt.mark_read()
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
msg = await evt.respond("starting the purge...") msg = await evt.respond("starting the purge...")
report = await self.generate_report() report = await self.generate_report()
purgeable = report['kick_inactive'] purgeable = report['kick_inactive']
@@ -769,15 +906,15 @@ class CommunityBot(Plugin):
# sync our database after we've made changes to room memberships # sync our database after we've made changes to room memberships
await self.do_sync() await self.do_sync()
else:
await evt.reply("lol you don't have permission to do that")
@community.subcommand("kick", help='kick a specific user from the community and all rooms') @community.subcommand("kick", help='kick a specific user from the community and all rooms')
@command.argument("mxid", "full matrix ID", required=True) @command.argument("mxid", "full matrix ID", required=True)
async def kick_user(self, evt: MessageEvent, mxid: UserID) -> None: async def kick_user(self, evt: MessageEvent, mxid: UserID) -> None:
await evt.mark_read() await evt.mark_read()
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
user = mxid user = mxid
msg = await evt.respond("starting the purge...") msg = await evt.respond("starting the purge...")
roomlist = await self.get_space_roomlist() roomlist = await self.get_space_roomlist()
@@ -815,15 +952,15 @@ class CommunityBot(Plugin):
# sync our database after we've made changes to room memberships # sync our database after we've made changes to room memberships
await self.do_sync() await self.do_sync()
else:
await evt.reply("lol you don't have permission to do that")
@community.subcommand("ban", help='kick and ban a specific user from the community and all rooms') @community.subcommand("ban", help='kick and ban a specific user from the community and all rooms')
@command.argument("mxid", "full matrix ID", required=True) @command.argument("mxid", "full matrix ID", required=True)
async def ban_user(self, evt: MessageEvent, mxid: UserID) -> None: async def ban_user(self, evt: MessageEvent, mxid: UserID) -> None:
await evt.mark_read() await evt.mark_read()
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
user = mxid user = mxid
msg = await evt.respond("starting the ban...") msg = await evt.respond("starting the ban...")
results_map = await self.ban_this_user(user, all_rooms=True) results_map = await self.ban_this_user(user, all_rooms=True)
@@ -837,15 +974,15 @@ class CommunityBot(Plugin):
# sync our database after we've made changes to room memberships # sync our database after we've made changes to room memberships
await self.do_sync() await self.do_sync()
else:
await evt.reply("lol you don't have permission to do that")
@community.subcommand("unban", help='unban a specific user from the community and all rooms') @community.subcommand("unban", help='unban a specific user from the community and all rooms')
@command.argument("mxid", "full matrix ID", required=True) @command.argument("mxid", "full matrix ID", required=True)
async def unban_user(self, evt: MessageEvent, mxid: UserID) -> None: async def unban_user(self, evt: MessageEvent, mxid: UserID) -> None:
await evt.mark_read() await evt.mark_read()
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
user = mxid user = mxid
msg = await evt.respond("starting the unban...") msg = await evt.respond("starting the unban...")
roomlist = await self.get_space_roomlist() roomlist = await self.get_space_roomlist()
@@ -883,15 +1020,15 @@ class CommunityBot(Plugin):
# sync our database after we've made changes to room memberships # sync our database after we've made changes to room memberships
await self.do_sync() await self.do_sync()
else:
await evt.reply("lol you don't have permission to do that")
@community.subcommand("redact", help="redact messages from a specific user (optionally in a specific room)") @community.subcommand("redact", help="redact messages from a specific user (optionally in a specific room)")
@command.argument("mxid", "full matrix ID", required=True) @command.argument("mxid", "full matrix ID", required=True)
@command.argument("room", "room ID", required=False) @command.argument("room", "room ID", required=False)
async def mark_for_redaction(self, evt: MessageEvent, mxid: UserID, room: str) -> None: async def mark_for_redaction(self, evt: MessageEvent, mxid: UserID, room: str) -> None:
await evt.mark_read() await evt.mark_read()
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
if room: if room:
if room.startswith('#'): if room.startswith('#'):
room_id = await self.client.resolve_room_alias(room) room_id = await self.client.resolve_room_alias(room)
@@ -910,8 +1047,6 @@ class CommunityBot(Plugin):
room_id room_id
) )
await evt.respond(f"Queued {len(messages)} messages for redaction in {room_id}") await evt.respond(f"Queued {len(messages)} messages for redaction in {room_id}")
else:
await evt.reply("lol you don't have permission to do that")
async def create_room(self, roomname: str, evt: MessageEvent = None) -> None: async def create_room(self, roomname: str, evt: MessageEvent = None) -> None:
"""Create a new room and add it to the parent space. """Create a new room and add it to the parent space.
@@ -935,10 +1070,17 @@ class CommunityBot(Plugin):
# Set bot PL higher than admin so we can kick old admins if needed # Set bot PL higher than admin so we can kick old admins if needed
pl_override = {"users": {self.client.mxid: 1000}} pl_override = {"users": {self.client.mxid: 1000}}
for u in self.config['admins']:
pl_override["users"][u] = 100 # Get power levels from parent room
for u in self.config['moderators']: parent_power_levels = await self.client.get_state_event(
pl_override["users"][u] = 50 self.config["parent_room"],
EventType.ROOM_POWER_LEVELS
)
# Copy power levels from parent room for all users
for user, level in parent_power_levels.users.items():
if user != self.client.mxid: # Skip bot's power level
pl_override["users"][user] = level
if evt: if evt:
mymsg = await evt.respond(f"creating {sanitized_name}, give me a minute...") mymsg = await evt.respond(f"creating {sanitized_name}, give me a minute...")
@@ -996,17 +1138,70 @@ class CommunityBot(Plugin):
use `--encrypt` to ensure it is encrypted at creation time even if that isnt my default \ use `--encrypt` to ensure it is encrypted at creation time even if that isnt my default \
setting.') setting.')
else: else:
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await self.create_room(roomname, evt) await evt.reply("You don't have permission to use this command")
else: return
await evt.reply("you're not the boss of me!")
encrypted_flag_regex = re.compile(r'(\s+|^)-+encrypt(ed)?\s?')
force_encryption = bool(encrypted_flag_regex.search(roomname))
try:
if force_encryption:
roomname = encrypted_flag_regex.sub('', roomname)
sanitized_name = re.sub(r"[^a-zA-Z0-9]", '', roomname).lower()
invitees = self.config['invitees']
parent_room = self.config['parent_room']
## homeserver is derived from maubot's client instance since this is the user that will create the room
server = self.client.parse_user_id(self.client.mxid)[1]
# set bot PL higher than admin so we can kick old admins if needed
pl_override = {"users": {self.client.mxid: 1000}}
for u in self.config['admins']:
pl_override["users"][u] = 100
for u in self.config['moderators']:
pl_override["users"][u] = 50
pl_json = json.dumps(pl_override)
mymsg = await evt.respond(f"creating {sanitized_name}, give me a minute...")
#self.log.info(mymsg)
room_id = await self.client.create_room(alias_localpart=sanitized_name, name=roomname,
invitees=invitees, power_level_override=pl_override)
time.sleep(self.config['sleep'])
await evt.respond(f"updating room states...", edits=mymsg)
parent_event_content = json.dumps({'auto_join': False, 'suggested': False, 'via': [server]})
child_event_content = json.dumps({'canonical': True, 'via': [server]})
join_rules_content = json.dumps({'join_rule': 'restricted', 'allow': [{'type': 'm.room_membership',
'room_id': parent_room}]})
await self.client.send_state_event(parent_room, 'm.space.child', parent_event_content, state_key=room_id)
time.sleep(self.config['sleep'])
await self.client.send_state_event(room_id, 'm.space.parent', child_event_content, state_key=parent_room)
time.sleep(self.config['sleep'])
await self.client.send_state_event(room_id, 'm.room.join_rules', join_rules_content, state_key="")
time.sleep(self.config['sleep'])
if self.config["encrypt"] or force_encryption:
encryption_content = json.dumps({"algorithm": "m.megolm.v1.aes-sha2"})
await self.client.send_state_event(room_id, 'm.room.encryption', encryption_content,
state_key="")
await evt.respond(f"encrypting room...", edits=mymsg)
time.sleep(self.config['sleep'])
await evt.respond(f"room created and updated, alias is #{sanitized_name}:{server}", edits=mymsg)
except Exception as e:
await evt.respond(f"i tried, but something went wrong: \"{e}\"", edits=mymsg)
@community.subcommand("archive", help="archive a room") @community.subcommand("archive", help="archive a room")
@command.argument("room", required=False) @command.argument("room", required=False)
async def archive_room(self, evt: MessageEvent, room: str) -> None: async def archive_room(self, evt: MessageEvent, room: str) -> None:
await evt.mark_read() await evt.mark_read()
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
if not room: if not room:
room_id = evt.room_id room_id = evt.room_id
self.log.debug(f"DEBUG room we are archiving is {room_id}") self.log.debug(f"DEBUG room we are archiving is {room_id}")
@@ -1038,7 +1233,10 @@ class CommunityBot(Plugin):
async def replace_room(self, evt: MessageEvent, room: str) -> None: async def replace_room(self, evt: MessageEvent, room: str) -> None:
await evt.mark_read() await evt.mark_read()
if evt.sender in self.config["admins"] or evt.sender in self.config["moderators"]: if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
if not room: if not room:
room = evt.room_id room = evt.room_id
# first we need to get relevant room state of the room we want to replace # first we need to get relevant room state of the room we want to replace
@@ -1202,7 +1400,10 @@ class CommunityBot(Plugin):
@community.subcommand("setpower", help="set power levels according to the community configuration") @community.subcommand("setpower", help="set power levels according to the community configuration")
async def set_powerlevels(self, evt: MessageEvent,) -> None: async def set_powerlevels(self, evt: MessageEvent,) -> None:
await evt.mark_read() await evt.mark_read()
if evt.sender in self.config["admins"]: if not await self.user_permitted(evt.sender, min_level=100):
await evt.reply("You don't have permission to use this command")
return
msg = await evt.respond("truing up power levels, this could take a minute...") msg = await evt.respond("truing up power levels, this could take a minute...")
admins = self.config['admins'] admins = self.config['admins']
moderators = self.config['moderators'] moderators = self.config['moderators']
@@ -1262,9 +1463,6 @@ class CommunityBot(Plugin):
# sync our database after we've made changes to room memberships # sync our database after we've made changes to room memberships
await self.do_sync() await self.do_sync()
else:
await evt.reply("lol you don't have permission to do that")