diff --git a/matrixbot/commands.py b/matrixbot/commands.py
index 5c01180..c6ff0fa 100644
--- a/matrixbot/commands.py
+++ b/matrixbot/commands.py
@@ -139,7 +139,7 @@ async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str):
]),
("π² Random", ["flip", "roll", "random", "champion", "agent"]),
("π₯οΈ Server", ["minecraft", "ping", "health"]),
- ("π§ Management (PL50+)", ["mkroom", "roominfo", "topic", "invite", "setpl"]),
+ ("π§ Management (PL50+)", ["mkroom", "roominfo", "topic", "invite", "inviteall", "setpl"]),
]
plain_lines = ["LotusBot Commands"]
@@ -3731,6 +3731,31 @@ async def _put_state(client: AsyncClient, room_id: str, event_type: str, content
return await _mx(client, "put", path, content)
+async def _get_space_room_ids(client: AsyncClient) -> list[str]:
+ """Return all room IDs that are direct children of the Lotus Guild Space."""
+ rooms: list[str] = []
+ next_batch: str | None = None
+ while True:
+ path = (
+ f"/_matrix/client/v1/rooms/{_url_quote(_MGMT_SPACE_ID)}/hierarchy"
+ f"?limit=50&max_depth=1"
+ + (f"&from={_url_quote(next_batch)}" if next_batch else "")
+ )
+ try:
+ data = await _mx(client, "get", path)
+ except Exception as e:
+ logger.error("space hierarchy fetch error: %s", e)
+ break
+ for room in data.get("rooms", []):
+ rid = room.get("room_id", "")
+ if rid and rid != _MGMT_SPACE_ID:
+ rooms.append(rid)
+ next_batch = data.get("next_batch")
+ if not next_batch:
+ break
+ return rooms
+
+
@command("mkroom", "Create a new room using #general as the template (PL50+)")
async def cmd_mkroom(client: AsyncClient, room_id: str, sender: str, args: str):
if not is_elevated(client, room_id, sender):
@@ -3896,7 +3921,59 @@ async def cmd_invite(client: AsyncClient, room_id: str, sender: str, args: str):
await send_text(client, room_id, "β Failed to send invite.")
-@command("setpl", "Set a user's power level (PL50+) β !setpl @user <0-100>")
+@command("inviteall", "Invite a user to all public/restricted Space rooms β skips private channels (PL50+)")
+async def cmd_inviteall(client: AsyncClient, room_id: str, sender: str, args: str):
+ if not is_elevated(client, room_id, sender):
+ await send_text(client, room_id, "β This command requires power level 50 or higher.")
+ return
+
+ target = args.strip()
+ if not target or not target.startswith("@"):
+ await send_text(client, room_id, f"Usage: {BOT_PREFIX}inviteall @user:server")
+ return
+
+ name = target.split(":")[0].lstrip("@")
+ await send_text(client, room_id, f"β³ Inviting {name} to public/restricted Space roomsβ¦")
+
+ space_rooms = await _get_space_room_ids(client)
+ if not space_rooms:
+ await send_text(client, room_id, "β Couldn't fetch Space room list.")
+ return
+
+ sent: list[str] = []
+ skipped: list[str] = []
+
+ for target_room_id in space_rooms:
+ try:
+ r = client.rooms.get(target_room_id)
+ # Skip invite-only rooms β those are intentionally private
+ if r and getattr(r, "join_rule", "invite") == "invite":
+ skipped.append(target_room_id)
+ continue
+ # Skip rooms the user is already in
+ if r and target in r.users:
+ skipped.append(target_room_id)
+ continue
+ result = await _mx(client, "post",
+ f"/_matrix/client/v3/rooms/{_url_quote(target_room_id)}/invite",
+ {"user_id": target})
+ if "errcode" in result:
+ skipped.append(target_room_id)
+ else:
+ sent.append(target_room_id)
+ except Exception:
+ skipped.append(target_room_id)
+
+ skip_note = f" ({len(skipped)} private/inaccessible rooms skipped)" if skipped else ""
+ await send_html(client, room_id,
+ f"β
Sent {len(sent)} invite(s) to {name}.{skip_note}",
+ f'β
Invites sent
'
+ f'{name} invited to {len(sent)} public room(s)'
+ + (f'
βΉοΈ {len(skipped)} private/invite-only rooms skipped' if skipped else ""),
+ )
+
+
+@command("setpl", "Set a user's power level across all Space rooms (PL50+) β !setpl @user <0-100>")
async def cmd_setpl(client: AsyncClient, room_id: str, sender: str, args: str):
if not is_elevated(client, room_id, sender):
await send_text(client, room_id, "β This command requires power level 50 or higher.")
@@ -3925,21 +4002,41 @@ async def cmd_setpl(client: AsyncClient, room_id: str, sender: str, args: str):
f"β You can't set a power level higher than your own ({sender_level}).")
return
- # Fetch current power_levels state and patch it
- current_pl = await _get_state(client, room_id, "m.room.power_levels")
- if not current_pl:
- await send_text(client, room_id, "β Couldn't fetch current power levels.")
+ name = target.split(":")[0].lstrip("@")
+ await send_text(client, room_id,
+ f"β³ Applying PL{new_level} to {name} across all Space roomsβ¦")
+
+ space_rooms = await _get_space_room_ids(client)
+ if not space_rooms:
+ await send_text(client, room_id, "β Couldn't fetch Space room list.")
return
- current_pl.setdefault("users", {})[target] = new_level
+ updated: list[str] = []
+ skipped: list[str] = []
- try:
- result = await _put_state(client, room_id, "m.room.power_levels", current_pl)
- if "errcode" in result:
- await send_text(client, room_id, f"β Failed: {result.get('error', result['errcode'])}")
- return
- name = target.split(":")[0].lstrip("@")
- await send_text(client, room_id, f"β
{name}'s power level set to {new_level}.")
- except Exception as e:
- logger.error("setpl error: %s", e, exc_info=True)
- await send_text(client, room_id, "β Failed to update power level.")
+ for target_room_id in space_rooms:
+ current_pl = await _get_state(client, target_room_id, "m.room.power_levels")
+ if not current_pl:
+ skipped.append(target_room_id)
+ continue
+ users = current_pl.setdefault("users", {})
+ if new_level == current_pl.get("users_default", 0):
+ users.pop(target, None) # remove explicit entry β they'll use the room default
+ else:
+ users[target] = new_level
+ try:
+ result = await _put_state(client, target_room_id, "m.room.power_levels", current_pl)
+ if "errcode" in result:
+ skipped.append(target_room_id)
+ else:
+ updated.append(target_room_id)
+ except Exception:
+ skipped.append(target_room_id)
+
+ skip_note = f" ({len(skipped)} skipped β bot lacks permission)" if skipped else ""
+ await send_html(client, room_id,
+ f"β
{name} is now PL{new_level} in {len(updated)} Space room(s).{skip_note}",
+ f'β
Power level updated
'
+ f'{name} β PL{new_level} in {len(updated)} room(s)'
+ + (f'
β οΈ {len(skipped)} room(s) skipped (bot lacks permission)' if skipped else ""),
+ )