feat: setpl and inviteall apply space-wide with smart filtering
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 39s
Lint / Secret scan (gitleaks) (push) Successful in 4s

!setpl now iterates every Space room via the hierarchy API and updates
the power_levels state event in each. Setting a user to the room's
users_default cleans up the explicit entry rather than leaving a
stale PL0. Rooms where the bot lacks permission are counted and
reported but don't block the rest.

!inviteall skips rooms with join_rule=invite (Management, Cool Kids,
Spam and Stuff) — only public/restricted rooms get the invite. Also
skips rooms where the target is already a member.

_get_space_room_ids() fetches the Space child list via the v1 hierarchy
API with pagination support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:42:12 -04:00
parent 789db82d9f
commit 72577dedf7
+116 -19
View File
@@ -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'<font color="#22c55e"><strong>✅ Invites sent</strong></font><br>'
f'<strong>{name}</strong> invited to <strong>{len(sent)}</strong> public room(s)'
+ (f'<br><em>{len(skipped)} private/invite-only rooms skipped</em>' 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.")
return
current_pl.setdefault("users", {})[target] = new_level
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.")
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
updated: list[str] = []
skipped: list[str] = []
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'<font color="#22c55e"><strong>✅ Power level updated</strong></font><br>'
f'<strong>{name}</strong> → PL{new_level} in <strong>{len(updated)}</strong> room(s)'
+ (f'<br><em>⚠️ {len(skipped)} room(s) skipped (bot lacks permission)</em>' if skipped else ""),
)