feat: management polish, !cancel, !wordlestats, welcome fixes
- Add !cancel command (anyone cancels own blackjack; PL50+ clears all room games) - Add !wordlestats top-level command (wraps wordle stats function) - Add !cleanwelcome admin command to purge stale welcome DM records - !help now hides management section from sub-PL50 users, hides !health from non-admins - !announce uses nio room cache for join_rule instead of an API call per room - Fix _INVITEALL_BLOCKED comment (Commands is knock-gated, not restricted) - welcome.py: skip duplicate DM if a pending welcome already exists for the user - welcome.py: add clean_stale_dm_messages() helper - welcome.py: replace no-op post_welcome_message with log_ready() - bot.py: update import/call to match welcome.py rename Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+401
-8
@@ -14,7 +14,8 @@ import aiohttp
|
||||
from nio import AsyncClient
|
||||
|
||||
from utils import send_text, send_html, send_reaction, edit_html, sanitize_input
|
||||
from wordle import handle_wordle
|
||||
from wordle import handle_wordle, wordle_stats as _wordle_stats
|
||||
from welcome import clean_stale_dm_messages
|
||||
from config import (
|
||||
MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX, ADMIN_USERS,
|
||||
OLLAMA_URL, OLLAMA_MODEL, CREATIVE_MODEL, ASK_MODEL, COOLDOWN_SECONDS,
|
||||
@@ -122,10 +123,11 @@ def is_elevated(client: AsyncClient, room_id: str, user_id: str, min_level: int
|
||||
|
||||
@command("help", "Show all available commands")
|
||||
async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
elevated = is_elevated(client, room_id, sender)
|
||||
categories = [
|
||||
("🤖 AI / Fun", ["ask", "fortune", "8ball", "roast", "story", "debate"]),
|
||||
("🎮 Games", [
|
||||
"wordle", "trivia", "rps", "poll", "hangman", "guess",
|
||||
"wordle", "wordlestats", "trivia", "rps", "poll", "hangman", "guess",
|
||||
"scramble", "wyr", "riddle",
|
||||
"numguess", "ng",
|
||||
"wordchain", "wc", "endwc",
|
||||
@@ -136,11 +138,16 @@ async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
"ttt", "move",
|
||||
"blackjack", "hit", "stand",
|
||||
"triviaduel", "da",
|
||||
"cancel",
|
||||
]),
|
||||
("🎲 Random", ["flip", "roll", "random", "champion", "agent"]),
|
||||
("🖥️ Server", ["minecraft", "ping", "health"]),
|
||||
("🔧 Management (PL50+)", ["mkroom", "roominfo", "topic", "invite", "inviteall", "setpl"]),
|
||||
("🖥️ Server", ["minecraft", "ping"] + (["health"] if sender in ADMIN_USERS else [])),
|
||||
]
|
||||
if elevated:
|
||||
categories.append(("🔧 Management (PL50+)", [
|
||||
"mkroom", "roominfo", "roomname", "topic", "invite", "inviteall",
|
||||
"setpl", "kick", "purge", "members", "whois", "announce", "syncspace",
|
||||
] + (["cleanwelcome"] if sender in ADMIN_USERS else [])))
|
||||
|
||||
plain_lines = ["LotusBot Commands"]
|
||||
html_parts = ['<font color="#a855f7"><strong>🌸 LotusBot — Commands</strong></font>']
|
||||
@@ -1391,6 +1398,11 @@ async def cmd_wordle(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
await handle_wordle(client, room_id, sender, args)
|
||||
|
||||
|
||||
@command("wordlestats", "Show your Wordle statistics")
|
||||
async def cmd_wordlestats(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
await _wordle_stats(client, room_id, sender)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hangman
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2717,6 +2729,8 @@ async def cmd_acronym(client: AsyncClient, room_id: str, sender: str, args: str)
|
||||
"\n".join(lines_plain), "".join(lines_html))
|
||||
if hasattr(resp, "event_id"):
|
||||
_ACRONYM_POLL_IDS[resp.event_id] = room_id
|
||||
for i in range(min(len(entries), 9)):
|
||||
await send_reaction(client, room_id, resp.event_id, _NUMBER_EMOJI_LIST[i])
|
||||
|
||||
await asyncio.sleep(30)
|
||||
|
||||
@@ -3005,11 +3019,9 @@ def record_nhie_reaction(event_id: str, sender: str, key: str) -> None:
|
||||
if not poll:
|
||||
return
|
||||
if key == "🙋":
|
||||
poll["have"].discard(sender)
|
||||
poll["have"].add(sender)
|
||||
poll["never"].discard(sender)
|
||||
elif key == "🙅":
|
||||
poll["never"].discard(sender)
|
||||
poll["never"].add(sender)
|
||||
poll["have"].discard(sender)
|
||||
|
||||
@@ -3071,6 +3083,8 @@ async def cmd_nhie(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
|
||||
event_id = resp.event_id
|
||||
_NHIE_POLLS[event_id] = {"room_id": room_id, "have": set(), "never": set()}
|
||||
await send_reaction(client, room_id, event_id, "🙋")
|
||||
await send_reaction(client, room_id, event_id, "🙅")
|
||||
|
||||
async def _reveal():
|
||||
await asyncio.sleep(30)
|
||||
@@ -3175,6 +3189,8 @@ async def cmd_hottake(client: AsyncClient, room_id: str, sender: str, args: str)
|
||||
|
||||
event_id = resp.event_id
|
||||
_HOTTAKE_POLLS[event_id] = {"room_id": room_id, "agree": set(), "disagree": set()}
|
||||
await send_reaction(client, room_id, event_id, "🔥")
|
||||
await send_reaction(client, room_id, event_id, "💧")
|
||||
|
||||
async def _reveal():
|
||||
await asyncio.sleep(30)
|
||||
@@ -3694,6 +3710,66 @@ async def cmd_da(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
await _tduel_next_question(client, room_id)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Utility Commands
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@command("cancel", "Cancel the active game in this room — anyone can cancel their own blackjack; PL50+ cancels all")
|
||||
async def cmd_cancel(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
cancelled: list[str] = []
|
||||
|
||||
# Anyone can cancel their own blackjack
|
||||
bj_room = _BLACKJACK_GAMES.get(room_id, {})
|
||||
if sender in bj_room:
|
||||
del bj_room[sender]
|
||||
if not bj_room:
|
||||
_BLACKJACK_GAMES.pop(room_id, None)
|
||||
cancelled.append("Blackjack")
|
||||
|
||||
if not is_elevated(client, room_id, sender):
|
||||
if cancelled:
|
||||
await send_text(client, room_id, f"✅ Your Blackjack game has been cancelled.")
|
||||
else:
|
||||
await send_text(client, room_id,
|
||||
"No active game to cancel. (Cancelling room games requires PL50+.)")
|
||||
return
|
||||
|
||||
# PL50+ — clear all room-wide active games
|
||||
_room_games: list[tuple[dict, str]] = [
|
||||
(_HANGMAN_GAMES, "Hangman"),
|
||||
(_SCRAMBLE_GAMES, "Scramble"),
|
||||
(_RIDDLE_ACTIVE, "Riddle"),
|
||||
(_NUMGUESS_GAMES, "Number Guess"),
|
||||
(_WORDCHAIN_GAMES, "Word Chain"),
|
||||
(_ACRONYM_GAMES, "Acronym"),
|
||||
(_TWENTYQ_GAMES, "20 Questions"),
|
||||
(_TTT_GAMES, "Tic-Tac-Toe"),
|
||||
(_TRIVIADUEL_GAMES, "Trivia Duel"),
|
||||
]
|
||||
for game_dict, name in _room_games:
|
||||
if room_id in game_dict:
|
||||
del game_dict[room_id]
|
||||
cancelled.append(name)
|
||||
|
||||
# Clear remaining blackjack games (other players' games in this room)
|
||||
if room_id in _BLACKJACK_GAMES:
|
||||
n = len(_BLACKJACK_GAMES.pop(room_id))
|
||||
label = f"Blackjack ({n} player{'s' if n != 1 else ''})"
|
||||
if "Blackjack" in cancelled:
|
||||
cancelled[cancelled.index("Blackjack")] = label
|
||||
else:
|
||||
cancelled.append(label)
|
||||
|
||||
if cancelled:
|
||||
await send_html(client, room_id,
|
||||
f"🛑 Cancelled: {', '.join(cancelled)}",
|
||||
f'<font color="#f59e0b"><strong>🛑 Cancelled:</strong></font> {", ".join(cancelled)}',
|
||||
)
|
||||
else:
|
||||
await send_text(client, room_id, "No active games to cancel in this room.")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Management Commands (PL 50+)
|
||||
# ===========================================================================
|
||||
@@ -3703,8 +3779,8 @@ _MGMT_TEMPLATE_ID = "!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0" # #general
|
||||
_MGMT_SERVER_NAME = MATRIX_HOMESERVER.replace("https://", "").replace("http://", "")
|
||||
|
||||
# Rooms excluded from !inviteall regardless of their join rule.
|
||||
# Commands is restricted (not invite-only) but gives access to bot management,
|
||||
# so new members should only be added there deliberately.
|
||||
# Commands uses join_rule=knock (not invite-only) so the standard filter misses it;
|
||||
# access should be granted deliberately by admins, not bulk-invited.
|
||||
_INVITEALL_BLOCKED: set[str] = {
|
||||
"!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s", # #commands
|
||||
}
|
||||
@@ -4051,3 +4127,320 @@ async def cmd_setpl(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
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 ""),
|
||||
)
|
||||
|
||||
|
||||
@command("kick", "Kick a user from this room (PL50+) — !kick @user [reason]")
|
||||
async def cmd_kick(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
if not is_elevated(client, room_id, sender):
|
||||
await send_text(client, room_id, "⛔ Requires PL50+.")
|
||||
return
|
||||
|
||||
parts = args.strip().split(None, 1)
|
||||
if not parts or not parts[0].startswith("@"):
|
||||
await send_text(client, room_id, "Usage: !kick @user [reason]")
|
||||
return
|
||||
|
||||
target = parts[0]
|
||||
reason = parts[1] if len(parts) > 1 else "Kicked by moderator"
|
||||
|
||||
if target == MATRIX_USER_ID:
|
||||
await send_text(client, room_id, "I can't kick myself.")
|
||||
return
|
||||
|
||||
room = client.rooms.get(room_id)
|
||||
sender_level = room.power_levels.get_user_level(sender) if room else 0
|
||||
target_level = room.power_levels.get_user_level(target) if room else 0
|
||||
if target_level >= sender_level:
|
||||
await send_text(client, room_id, f"⛔ Can't kick someone with equal or higher power level (PL{target_level}).")
|
||||
return
|
||||
|
||||
result = await _mx(client, "post",
|
||||
f"/_matrix/client/v3/rooms/{_url_quote(room_id)}/kick",
|
||||
{"user_id": target, "reason": reason},
|
||||
)
|
||||
|
||||
if "errcode" in result:
|
||||
await send_text(client, room_id, f"❌ Kick failed: {result.get('error', result['errcode'])}")
|
||||
else:
|
||||
name = target.split(":")[0].lstrip("@")
|
||||
await send_html(client, room_id,
|
||||
f"👢 {name} has been kicked. Reason: {reason}",
|
||||
f'<font color="#f59e0b"><strong>👢 {name} kicked</strong></font> — {reason}',
|
||||
)
|
||||
|
||||
|
||||
@command("purge", "Kick a user from every Space room (PL100) — !purge @user [reason]")
|
||||
async def cmd_purge(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
if not is_elevated(client, room_id, sender, min_level=100):
|
||||
await send_text(client, room_id, "⛔ Requires PL100.")
|
||||
return
|
||||
|
||||
parts = args.strip().split(None, 1)
|
||||
if not parts or not parts[0].startswith("@"):
|
||||
await send_text(client, room_id, "Usage: !purge @user [reason]")
|
||||
return
|
||||
|
||||
target = parts[0]
|
||||
reason = parts[1] if len(parts) > 1 else "Purged from Space"
|
||||
|
||||
if target == MATRIX_USER_ID:
|
||||
await send_text(client, room_id, "I can't purge myself.")
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
name = target.split(":")[0].lstrip("@")
|
||||
await send_text(client, room_id, f"⏳ Purging {name} from {len(space_rooms)} Space room(s)…")
|
||||
|
||||
kicked: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
for rid in space_rooms:
|
||||
result = await _mx(client, "post",
|
||||
f"/_matrix/client/v3/rooms/{_url_quote(rid)}/kick",
|
||||
{"user_id": target, "reason": reason},
|
||||
)
|
||||
if "errcode" in result:
|
||||
skipped.append(rid)
|
||||
else:
|
||||
kicked.append(rid)
|
||||
|
||||
skip_note = f" ({len(skipped)} skipped — not in room or bot lacks permission)" if skipped else ""
|
||||
await send_html(client, room_id,
|
||||
f"✅ {name} purged from {len(kicked)} room(s).{skip_note}",
|
||||
f'<font color="#ef4444"><strong>🚫 {name} purged</strong></font><br>'
|
||||
f'Kicked from <strong>{len(kicked)}</strong> Space room(s)'
|
||||
+ (f'<br><em>⚠️ {len(skipped)} skipped</em>' if skipped else ""),
|
||||
)
|
||||
|
||||
|
||||
@command("members", "List room members, optionally filtered by power level (PL50+) — !members [--elevated]")
|
||||
async def cmd_members(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
if not is_elevated(client, room_id, sender):
|
||||
await send_text(client, room_id, "⛔ Requires PL50+.")
|
||||
return
|
||||
|
||||
elevated_only = "--elevated" in args.lower()
|
||||
room = client.rooms.get(room_id)
|
||||
if not room:
|
||||
await send_text(client, room_id, "❌ Room data unavailable.")
|
||||
return
|
||||
|
||||
members = []
|
||||
for uid, member in room.users.items():
|
||||
if uid == MATRIX_USER_ID:
|
||||
continue
|
||||
pl = room.power_levels.get_user_level(uid)
|
||||
if elevated_only and pl < 50:
|
||||
continue
|
||||
members.append((uid, pl, member.display_name or uid.split(":")[0].lstrip("@")))
|
||||
|
||||
if not members:
|
||||
label = "elevated members" if elevated_only else "members"
|
||||
await send_text(client, room_id, f"No {label} found.")
|
||||
return
|
||||
|
||||
members.sort(key=lambda x: (-x[1], x[2].lower()))
|
||||
|
||||
plain_lines = [f"Members ({len(members)}):"]
|
||||
html_rows = []
|
||||
for uid, pl, display in members:
|
||||
pl_badge = f"PL{pl}" if pl > 0 else "PL0"
|
||||
plain_lines.append(f" {display} ({pl_badge})")
|
||||
color = "#a855f7" if pl >= 100 else "#f59e0b" if pl >= 50 else "#94a3b8"
|
||||
html_rows.append(f'<li><strong>{display}</strong> — <font color="{color}">{pl_badge}</font></li>')
|
||||
|
||||
title = "Elevated Members" if elevated_only else "Members"
|
||||
await send_html(client, room_id,
|
||||
"\n".join(plain_lines),
|
||||
f'<strong>{title} ({len(members)})</strong><ul>{"".join(html_rows)}</ul>',
|
||||
)
|
||||
|
||||
|
||||
@command("whois", "Show a user's power level across all Space rooms (PL50+) — !whois @user")
|
||||
async def cmd_whois(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
if not is_elevated(client, room_id, sender):
|
||||
await send_text(client, room_id, "⛔ Requires PL50+.")
|
||||
return
|
||||
|
||||
target = args.strip().split()[0] if args.strip() else ""
|
||||
if not target.startswith("@"):
|
||||
await send_text(client, room_id, "Usage: !whois @user")
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
name = target.split(":")[0].lstrip("@")
|
||||
entries: list[tuple[str, int]] = []
|
||||
|
||||
for rid in space_rooms:
|
||||
room = client.rooms.get(rid)
|
||||
if not room:
|
||||
continue
|
||||
if target not in room.users:
|
||||
continue
|
||||
pl = room.power_levels.get_user_level(target)
|
||||
entries.append((room.display_name or rid, pl))
|
||||
|
||||
if not entries:
|
||||
await send_text(client, room_id, f"{name} is not in any cached Space room.")
|
||||
return
|
||||
|
||||
entries.sort(key=lambda x: x[0].lower())
|
||||
|
||||
plain_lines = [f"@{name} across {len(entries)} room(s):"]
|
||||
html_rows = []
|
||||
for rname, pl in entries:
|
||||
plain_lines.append(f" {rname}: PL{pl}")
|
||||
color = "#a855f7" if pl >= 100 else "#f59e0b" if pl >= 50 else "#94a3b8"
|
||||
html_rows.append(f'<li>{rname} — <font color="{color}">PL{pl}</font></li>')
|
||||
|
||||
await send_html(client, room_id,
|
||||
"\n".join(plain_lines),
|
||||
f'<strong>@{name}</strong> — power levels across Space<ul>{"".join(html_rows)}</ul>',
|
||||
)
|
||||
|
||||
|
||||
@command("announce", "Broadcast a message to all public Space rooms (PL100) — !announce <message>")
|
||||
async def cmd_announce(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
if not is_elevated(client, room_id, sender, min_level=100):
|
||||
await send_text(client, room_id, "⛔ Requires PL100.")
|
||||
return
|
||||
|
||||
message = args.strip()
|
||||
if not message:
|
||||
await send_text(client, room_id, "Usage: !announce <message>")
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
sender_name = sender.split(":")[0].lstrip("@")
|
||||
announcement = f"📢 Announcement from {sender_name}:\n\n{message}"
|
||||
announcement_html = (
|
||||
f'<font color="#a855f7"><strong>📢 Announcement from {sender_name}</strong></font><br><br>{message}'
|
||||
)
|
||||
|
||||
sent: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
for rid in space_rooms:
|
||||
if rid == room_id:
|
||||
continue # Don't announce in the room where the command was issued
|
||||
r = client.rooms.get(rid)
|
||||
if r and getattr(r, "join_rule", None) == "invite":
|
||||
skipped.append(rid)
|
||||
continue
|
||||
try:
|
||||
await send_html(client, rid, announcement, announcement_html)
|
||||
sent.append(rid)
|
||||
except Exception:
|
||||
skipped.append(rid)
|
||||
|
||||
await send_html(client, room_id,
|
||||
f"✅ Announced to {len(sent)} room(s). {len(skipped)} skipped (private).",
|
||||
f'<font color="#22c55e"><strong>✅ Announced to {len(sent)} room(s)</strong></font>'
|
||||
+ (f'<br><em>{len(skipped)} private room(s) skipped</em>' if skipped else ""),
|
||||
)
|
||||
|
||||
|
||||
@command("roomname", "Rename the current room (PL50+) — !roomname <new name>")
|
||||
async def cmd_roomname(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
if not is_elevated(client, room_id, sender):
|
||||
await send_text(client, room_id, "⛔ Requires PL50+.")
|
||||
return
|
||||
|
||||
new_name = args.strip()
|
||||
if not new_name:
|
||||
await send_text(client, room_id, "Usage: !roomname <new name>")
|
||||
return
|
||||
|
||||
if len(new_name) > 100:
|
||||
await send_text(client, room_id, "Room name must be 100 characters or fewer.")
|
||||
return
|
||||
|
||||
result = await _put_state(client, room_id, "m.room.name", {"name": new_name})
|
||||
if "errcode" in result:
|
||||
await send_text(client, room_id, f"❌ Failed: {result.get('error', result['errcode'])}")
|
||||
else:
|
||||
await send_html(client, room_id,
|
||||
f'✅ Room renamed to "{new_name}".',
|
||||
f'<font color="#22c55e"><strong>✅ Room renamed</strong></font> → {new_name}',
|
||||
)
|
||||
|
||||
|
||||
@command("syncspace", "Push #general's power levels to every Space room (PL100)")
|
||||
async def cmd_syncspace(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
if not is_elevated(client, room_id, sender, min_level=100):
|
||||
await send_text(client, room_id, "⛔ Requires PL100.")
|
||||
return
|
||||
|
||||
source_pl = await _get_state(client, _MGMT_TEMPLATE_ID, "m.room.power_levels")
|
||||
if not source_pl:
|
||||
await send_text(client, room_id, "❌ Couldn't read power levels from the template room.")
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
await send_text(client, room_id, f"⏳ Syncing power levels to {len(space_rooms)} Space room(s)…")
|
||||
|
||||
updated: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
for rid in space_rooms:
|
||||
if rid == _MGMT_TEMPLATE_ID:
|
||||
continue # Source room — skip to avoid a no-op write
|
||||
current_pl = await _get_state(client, rid, "m.room.power_levels")
|
||||
if not current_pl:
|
||||
skipped.append(rid)
|
||||
continue
|
||||
merged = dict(current_pl)
|
||||
merged["users"] = dict(source_pl.get("users", {}))
|
||||
merged["users_default"] = source_pl.get("users_default", 0)
|
||||
merged["events_default"] = source_pl.get("events_default", 0)
|
||||
merged["state_default"] = source_pl.get("state_default", 50)
|
||||
merged["ban"] = source_pl.get("ban", 50)
|
||||
merged["kick"] = source_pl.get("kick", 50)
|
||||
merged["redact"] = source_pl.get("redact", 50)
|
||||
merged["invite"] = source_pl.get("invite", 0)
|
||||
merged["events"] = dict(source_pl.get("events", {}))
|
||||
try:
|
||||
result = await _put_state(client, rid, "m.room.power_levels", merged)
|
||||
if "errcode" in result:
|
||||
skipped.append(rid)
|
||||
else:
|
||||
updated.append(rid)
|
||||
except Exception:
|
||||
skipped.append(rid)
|
||||
|
||||
skip_note = f" ({len(skipped)} skipped — bot lacks permission)" if skipped else ""
|
||||
await send_html(client, room_id,
|
||||
f"✅ Power levels synced to {len(updated)} room(s).{skip_note}",
|
||||
f'<font color="#22c55e"><strong>✅ Space sync complete</strong></font><br>'
|
||||
f'Power levels applied to <strong>{len(updated)}</strong> room(s)'
|
||||
+ (f'<br><em>⚠️ {len(skipped)} skipped (bot lacks permission)</em>' if skipped else ""),
|
||||
)
|
||||
|
||||
|
||||
@command("cleanwelcome", "Purge pending welcome DMs that were never reacted to (admin only)")
|
||||
async def cmd_cleanwelcome(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
if sender not in ADMIN_USERS:
|
||||
await send_text(client, room_id, "⛔ Admin only.")
|
||||
return
|
||||
removed = clean_stale_dm_messages()
|
||||
await send_html(client, room_id,
|
||||
f"✅ Cleared {removed} stale welcome DM record(s).",
|
||||
f'<font color="#22c55e"><strong>✅ Welcome cleanup</strong></font><br>'
|
||||
f'Removed <strong>{removed}</strong> pending DM record(s) that were never reacted to.',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user