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'
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")