feat: management commands for PL50+ users
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 42s
Lint / Secret scan (gitleaks) (push) Successful in 5s

Five new commands, all gated behind is_elevated() (power level >= 50):

!mkroom <name>   — clone #general's power levels, join rules, encryption,
                   history visibility, and avatar into a fresh v12 room,
                   auto-adds it to the Lotus Guild Space, and invites
                   the caller.

!roominfo        — show room display name, ID, member count, join rule,
                   encryption status, and all users with PL > 0.

!topic [text]    — set or clear the current room's topic.

!invite @user    — invite any Matrix user to the current room.

!setpl @user <n> — update a user's power level (0-100); cannot exceed
                   the caller's own level.

Also adds urllib.parse.quote and MATRIX_HOMESERVER to imports, and
adds a "Management (PL50+)" section to !help.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:34:08 -04:00
parent 66136ff2f7
commit 789db82d9f
+255 -1
View File
@@ -7,6 +7,7 @@ import logging
from collections import Counter
from datetime import datetime
from pathlib import Path
from urllib.parse import quote as _url_quote
import aiohttp
@@ -18,7 +19,8 @@ from config import (
MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX, ADMIN_USERS,
OLLAMA_URL, OLLAMA_MODEL, CREATIVE_MODEL, ASK_MODEL, COOLDOWN_SECONDS,
MINECRAFT_RCON_HOST, MINECRAFT_RCON_PORT, MINECRAFT_RCON_PASSWORD,
RCON_TIMEOUT, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH, MATRIX_USER_ID,
RCON_TIMEOUT, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
MATRIX_USER_ID, MATRIX_HOMESERVER,
)
logger = logging.getLogger("matrixbot")
@@ -137,6 +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"]),
]
plain_lines = ["LotusBot Commands"]
@@ -3689,3 +3692,254 @@ async def cmd_da(client: AsyncClient, room_id: str, sender: str, args: str):
await asyncio.sleep(3)
await _tduel_next_question(client, room_id)
# ===========================================================================
# Management Commands (PL 50+)
# ===========================================================================
_MGMT_SPACE_ID = "!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc"
_MGMT_TEMPLATE_ID = "!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0" # #general
_MGMT_SERVER_NAME = MATRIX_HOMESERVER.replace("https://", "").replace("http://", "")
async def _mx(client: AsyncClient, method: str, path: str, body: dict | None = None) -> dict:
"""Authenticated Matrix Client-Server API request."""
url = f"{MATRIX_HOMESERVER}{path}"
headers = {
"Authorization": f"Bearer {client.access_token}",
"Content-Type": "application/json",
}
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with getattr(session, method)(url, json=body, headers=headers) as resp:
return await resp.json()
async def _get_state(client: AsyncClient, room_id: str, event_type: str, state_key: str = "") -> dict | None:
"""Fetch a single state event content; returns None on error."""
try:
path = f"/_matrix/client/v3/rooms/{_url_quote(room_id)}/state/{_url_quote(event_type)}/{_url_quote(state_key)}"
data = await _mx(client, "get", path)
return None if "errcode" in data else data
except Exception:
return None
async def _put_state(client: AsyncClient, room_id: str, event_type: str, content: dict, state_key: str = "") -> dict:
path = f"/_matrix/client/v3/rooms/{_url_quote(room_id)}/state/{_url_quote(event_type)}/{_url_quote(state_key)}"
return await _mx(client, "put", path, content)
@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):
await send_text(client, room_id, "⛔ This command requires power level 50 or higher.")
return
name = sanitize_input(args.strip())
if not name:
await send_text(client, room_id, f"Usage: {BOT_PREFIX}mkroom <room name>")
return
await send_text(client, room_id, f"🏗️ Creating room '{name}' from the #general template…")
# Fetch template state from #general
power_levels = await _get_state(client, _MGMT_TEMPLATE_ID, "m.room.power_levels")
join_rules = await _get_state(client, _MGMT_TEMPLATE_ID, "m.room.join_rules")
history_vis = await _get_state(client, _MGMT_TEMPLATE_ID, "m.room.history_visibility")
guest_access = await _get_state(client, _MGMT_TEMPLATE_ID, "m.room.guest_access")
encryption = await _get_state(client, _MGMT_TEMPLATE_ID, "m.room.encryption")
avatar = await _get_state(client, _MGMT_TEMPLATE_ID, "m.room.avatar")
initial_state = []
if power_levels:
initial_state.append({"type": "m.room.power_levels", "content": power_levels})
if join_rules:
initial_state.append({"type": "m.room.join_rules", "content": join_rules})
if history_vis:
initial_state.append({"type": "m.room.history_visibility", "content": history_vis})
if guest_access:
initial_state.append({"type": "m.room.guest_access", "content": guest_access})
if encryption:
initial_state.append({"type": "m.room.encryption", "content": encryption})
if avatar and avatar.get("url"):
initial_state.append({"type": "m.room.avatar", "content": avatar})
try:
result = await _mx(client, "post", "/_matrix/client/v3/createRoom", {
"name": name,
"room_version": "12",
"preset": "private_chat",
"initial_state": initial_state,
})
if "errcode" in result:
await send_text(client, room_id, f"❌ Room creation failed: {result.get('error', result['errcode'])}")
return
new_room_id = result["room_id"]
# Add the new room to the Space as a child
await _put_state(client, _MGMT_SPACE_ID, "m.space.child", {
"via": [_MGMT_SERVER_NAME],
"suggested": False,
}, new_room_id)
# Invite the requesting user (bot is already in the room as creator)
await _mx(client, "post",
f"/_matrix/client/v3/rooms/{_url_quote(new_room_id)}/invite",
{"user_id": sender})
await send_html(client, room_id,
f"✅ Room '{name}' created!\nRoom ID: {new_room_id}\nAdded to the Space and invited you.",
f'<font color="#22c55e"><strong>✅ Room created: {name}</strong></font><br>'
f'<code>{new_room_id}</code><br>'
f'Added to the Lotus Guild Space — invite yourself or others with <code>!invite @user</code> in the new room.',
)
except Exception as e:
logger.error("mkroom error: %s", e, exc_info=True)
await send_text(client, room_id, "❌ An error occurred while creating the room.")
@command("roominfo", "Show info about the current room (PL50+)")
async def cmd_roominfo(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
room = client.rooms.get(room_id)
if not room:
await send_text(client, room_id, "Room not found in local state.")
return
pl = room.power_levels
elevated = sorted(
[(uid, lvl) for uid, lvl in pl.users.items() if lvl > 0],
key=lambda x: x[1], reverse=True,
)
el_lines = [f" PL{lvl}: {uid.split(':')[0].lstrip('@')}" for uid, lvl in elevated]
join_state = await _get_state(client, room_id, "m.room.join_rules")
join_rule = join_state.get("join_rule", "unknown") if join_state else "unknown"
enc = await _get_state(client, room_id, "m.room.encryption")
encrypted = "yes" if enc else "no"
member_count = len(room.users)
plain = (
f"📋 Room Info\n"
f"Name: {room.display_name}\n"
f"ID: {room_id}\n"
f"Members: {member_count}\n"
f"Join rule: {join_rule}\n"
f"Encrypted: {encrypted}\n"
f"Elevated users:\n" + ("\n".join(el_lines) if el_lines else " (none)")
)
html = (
f'<font color="#a855f7"><strong>📋 Room Info</strong></font><br>'
f'<strong>Name:</strong> {room.display_name}<br>'
f'<strong>ID:</strong> <code>{room_id}</code><br>'
f'<strong>Members:</strong> {member_count}<br>'
f'<strong>Join rule:</strong> {join_rule}<br>'
f'<strong>Encrypted:</strong> {encrypted}<br>'
f'<strong>Elevated users:</strong><ul>'
+ "".join(f"<li>PL{lvl}: {uid.split(':')[0].lstrip('@')}</li>" for uid, lvl in elevated)
+ ("</ul>" if elevated else "<li>(none)</li></ul>")
)
await send_html(client, room_id, plain, html)
@command("topic", "Set the room topic (PL50+) — leave blank to clear")
async def cmd_topic(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
topic = sanitize_input(args.strip())
try:
result = await _put_state(client, room_id, "m.room.topic", {"topic": topic})
if "errcode" in result:
await send_text(client, room_id, f"❌ Failed: {result.get('error', result['errcode'])}")
return
if topic:
await send_text(client, room_id, f"✅ Topic set to: {topic}")
else:
await send_text(client, room_id, "✅ Topic cleared.")
except Exception as e:
logger.error("topic error: %s", e, exc_info=True)
await send_text(client, room_id, "❌ Failed to set topic.")
@command("invite", "Invite a user to this room (PL50+)")
async def cmd_invite(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}invite @user:server")
return
try:
result = await _mx(client, "post",
f"/_matrix/client/v3/rooms/{_url_quote(room_id)}/invite",
{"user_id": target})
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"✅ Invited {name} to the room.")
except Exception as e:
logger.error("invite error: %s", e, exc_info=True)
await send_text(client, room_id, "❌ Failed to send invite.")
@command("setpl", "Set a user's power level (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.")
return
parts = args.strip().split()
if len(parts) < 2 or not parts[0].startswith("@"):
await send_text(client, room_id, f"Usage: {BOT_PREFIX}setpl @user <level>")
return
target = parts[0]
try:
new_level = int(parts[1])
except ValueError:
await send_text(client, room_id, "Power level must be a number.")
return
if not 0 <= new_level <= 100:
await send_text(client, room_id, "Power level must be between 0 and 100.")
return
room = client.rooms.get(room_id)
sender_level = room.power_levels.get_user_level(sender) if room else 0
if new_level > sender_level:
await send_text(client, room_id,
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.")