From 88627470c1cf98a917d742ac9773750ecdc9718f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 29 Apr 2026 14:50:14 -0400 Subject: [PATCH] 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 --- matrixbot/bot.py | 5 +- matrixbot/commands.py | 409 +++++++++++++++++++++++++++++++++++++++++- matrixbot/welcome.py | 40 +++-- 3 files changed, 433 insertions(+), 21 deletions(-) diff --git a/matrixbot/bot.py b/matrixbot/bot.py index 577ee2f..ce738aa 100644 --- a/matrixbot/bot.py +++ b/matrixbot/bot.py @@ -26,7 +26,7 @@ from config import ( ) from callbacks import Callbacks from utils import setup_logging -from welcome import post_welcome_message +from welcome import log_ready as _welcome_log_ready logger = setup_logging(LOG_LEVEL) @@ -179,8 +179,7 @@ async def main(): # Trust devices after initial sync loads the device store await trust_devices(client) - # Post welcome message (idempotent — only posts if not already stored) - await post_welcome_message(client) + _welcome_log_ready() logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID) diff --git a/matrixbot/commands.py b/matrixbot/commands.py index c4895ce..9e18777 100644 --- a/matrixbot/commands.py +++ b/matrixbot/commands.py @@ -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 = ['🌸 LotusBot — Commands'] @@ -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'🛑 Cancelled: {", ".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'{name} → PL{new_level} in {len(updated)} room(s)' + (f'
⚠️ {len(skipped)} room(s) skipped (bot lacks permission)' 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'👢 {name} kicked — {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'🚫 {name} purged
' + f'Kicked from {len(kicked)} Space room(s)' + + (f'
⚠️ {len(skipped)} skipped' 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'
  • {display}{pl_badge}
  • ') + + title = "Elevated Members" if elevated_only else "Members" + await send_html(client, room_id, + "\n".join(plain_lines), + f'{title} ({len(members)})', + ) + + +@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'
  • {rname} — PL{pl}
  • ') + + await send_html(client, room_id, + "\n".join(plain_lines), + f'@{name} — power levels across Space', + ) + + +@command("announce", "Broadcast a message to all public Space rooms (PL100) — !announce ") +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 ") + 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'📢 Announcement from {sender_name}

    {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'✅ Announced to {len(sent)} room(s)' + + (f'
    {len(skipped)} private room(s) skipped' if skipped else ""), + ) + + +@command("roomname", "Rename the current room (PL50+) — !roomname ") +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 ") + 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'✅ Room renamed → {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'✅ Space sync complete
    ' + f'Power levels applied to {len(updated)} room(s)' + + (f'
    ⚠️ {len(skipped)} skipped (bot lacks permission)' 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'✅ Welcome cleanup
    ' + f'Removed {removed} pending DM record(s) that were never reacted to.', + ) diff --git a/matrixbot/welcome.py b/matrixbot/welcome.py index 1d4333d..8f8e134 100644 --- a/matrixbot/welcome.py +++ b/matrixbot/welcome.py @@ -19,11 +19,15 @@ logger = logging.getLogger("matrixbot") # The Space room to watch for new members SPACE_ROOM_ID = "!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc" -# Public channels to invite new members to (skip Management + Cool Kids) +# Public channels to invite new members to. +# Intentionally excludes: Management, Cool Kids, Spam and Stuff (invite-only), +# and Commands (knock-gated — access granted deliberately by admins). INVITE_ROOMS = [ - "!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0", # General (v12) - "!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s", # Commands (v12) - "!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U", # Memes (v12) + "!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0", # General + "!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U", # Memes + "!ktQu0gavhjpCMkgxk8SYdb6mnJRY-u7mY7_KfksV0SU", # Music + "!ARbRFSPNp2U0MslWTBGoTT3gbmJJ25dPRL6enQntvPo", # Voice + "!3gMjTHqV-r823ZrvXnck7waB0Pd8tiCu-zbF7mSS83E", # Voice 2 ] WELCOME_EMOJI = "\u2705" # checkmark @@ -49,6 +53,17 @@ def _save_state(state: dict): logger.error("Failed to save welcome state: %s", e) +def clean_stale_dm_messages() -> int: + """Remove all pending welcome DM records. Returns count removed.""" + state = _load_state() + pending = state.get("dm_welcome_messages", {}) + count = len(pending) + if count: + state["dm_welcome_messages"] = {} + _save_state(state) + return count + + async def handle_space_join(client: AsyncClient, sender: str): """Called when a new user joins the Space. DM them a welcome message.""" state = _load_state() @@ -57,6 +72,12 @@ async def handle_space_join(client: AsyncClient, sender: str): if sender in welcomed: return + # Skip if we already sent them a DM they haven't reacted to yet + pending = state.get("dm_welcome_messages", {}) + if any(v["user"] == sender for v in pending.values()): + logger.debug("Already have a pending welcome DM for %s, skipping", sender) + return + logger.info("New Space member %s — sending welcome DM", sender) dm_room = await get_or_create_dm(client, sender) @@ -66,13 +87,13 @@ async def handle_space_join(client: AsyncClient, sender: str): plain = ( "Welcome to The Lotus Guild!\n\n" - f"React to this message with {WELCOME_EMOJI} to get invited to all channels.\n\n" - "You'll be added to General, Commands, and Memes." + f"React to this message with {WELCOME_EMOJI} to get invited to all public channels.\n\n" + "You'll be added to General, Memes, Music, and the Voice channels." ) html = ( "

    Welcome to The Lotus Guild!

    " - f"

    React to this message with {WELCOME_EMOJI} to get invited to all channels.

    " - "

    You'll be added to General, Commands, and Memes.

    " + f"

    React to this message with {WELCOME_EMOJI} to get invited to all public channels.

    " + "

    You'll be added to General, Memes, Music, and the Voice channels.

    " ) resp = await send_html(client, dm_room, plain, html) @@ -145,6 +166,5 @@ async def handle_welcome_reaction( await send_text(client, room_id, "You're already in all the channels!") -async def post_welcome_message(client: AsyncClient): - """No-op kept for backward compatibility with bot.py startup.""" +def log_ready(): logger.info("Welcome module ready — watching Space for new members")