feat: management polish, !cancel, !wordlestats, welcome fixes
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Failing after 5s
Lint / Python deps (pip-audit) (push) Successful in 41s
Lint / Secret scan (gitleaks) (push) Successful in 5s

- 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:
2026-04-29 14:50:14 -04:00
parent 4ef73afed2
commit 88627470c1
3 changed files with 433 additions and 21 deletions
+2 -3
View File
@@ -26,7 +26,7 @@ from config import (
) )
from callbacks import Callbacks from callbacks import Callbacks
from utils import setup_logging 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) logger = setup_logging(LOG_LEVEL)
@@ -179,8 +179,7 @@ async def main():
# Trust devices after initial sync loads the device store # Trust devices after initial sync loads the device store
await trust_devices(client) await trust_devices(client)
# Post welcome message (idempotent — only posts if not already stored) _welcome_log_ready()
await post_welcome_message(client)
logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID) logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID)
+401 -8
View File
@@ -14,7 +14,8 @@ import aiohttp
from nio import AsyncClient from nio import AsyncClient
from utils import send_text, send_html, send_reaction, edit_html, sanitize_input 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 ( from config import (
MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX, ADMIN_USERS, MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX, ADMIN_USERS,
OLLAMA_URL, OLLAMA_MODEL, CREATIVE_MODEL, ASK_MODEL, COOLDOWN_SECONDS, 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") @command("help", "Show all available commands")
async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str): async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str):
elevated = is_elevated(client, room_id, sender)
categories = [ categories = [
("🤖 AI / Fun", ["ask", "fortune", "8ball", "roast", "story", "debate"]), ("🤖 AI / Fun", ["ask", "fortune", "8ball", "roast", "story", "debate"]),
("🎮 Games", [ ("🎮 Games", [
"wordle", "trivia", "rps", "poll", "hangman", "guess", "wordle", "wordlestats", "trivia", "rps", "poll", "hangman", "guess",
"scramble", "wyr", "riddle", "scramble", "wyr", "riddle",
"numguess", "ng", "numguess", "ng",
"wordchain", "wc", "endwc", "wordchain", "wc", "endwc",
@@ -136,11 +138,16 @@ async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str):
"ttt", "move", "ttt", "move",
"blackjack", "hit", "stand", "blackjack", "hit", "stand",
"triviaduel", "da", "triviaduel", "da",
"cancel",
]), ]),
("🎲 Random", ["flip", "roll", "random", "champion", "agent"]), ("🎲 Random", ["flip", "roll", "random", "champion", "agent"]),
("🖥️ Server", ["minecraft", "ping", "health"]), ("🖥️ Server", ["minecraft", "ping"] + (["health"] if sender in ADMIN_USERS else [])),
("🔧 Management (PL50+)", ["mkroom", "roominfo", "topic", "invite", "inviteall", "setpl"]),
] ]
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"] plain_lines = ["LotusBot Commands"]
html_parts = ['<font color="#a855f7"><strong>🌸 LotusBot — Commands</strong></font>'] 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) 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 # 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)) "\n".join(lines_plain), "".join(lines_html))
if hasattr(resp, "event_id"): if hasattr(resp, "event_id"):
_ACRONYM_POLL_IDS[resp.event_id] = room_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) await asyncio.sleep(30)
@@ -3005,11 +3019,9 @@ def record_nhie_reaction(event_id: str, sender: str, key: str) -> None:
if not poll: if not poll:
return return
if key == "🙋": if key == "🙋":
poll["have"].discard(sender)
poll["have"].add(sender) poll["have"].add(sender)
poll["never"].discard(sender) poll["never"].discard(sender)
elif key == "🙅": elif key == "🙅":
poll["never"].discard(sender)
poll["never"].add(sender) poll["never"].add(sender)
poll["have"].discard(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 event_id = resp.event_id
_NHIE_POLLS[event_id] = {"room_id": room_id, "have": set(), "never": set()} _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(): async def _reveal():
await asyncio.sleep(30) 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 event_id = resp.event_id
_HOTTAKE_POLLS[event_id] = {"room_id": room_id, "agree": set(), "disagree": set()} _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(): async def _reveal():
await asyncio.sleep(30) 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) 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+) # 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://", "") _MGMT_SERVER_NAME = MATRIX_HOMESERVER.replace("https://", "").replace("http://", "")
# Rooms excluded from !inviteall regardless of their join rule. # Rooms excluded from !inviteall regardless of their join rule.
# Commands is restricted (not invite-only) but gives access to bot management, # Commands uses join_rule=knock (not invite-only) so the standard filter misses it;
# so new members should only be added there deliberately. # access should be granted deliberately by admins, not bulk-invited.
_INVITEALL_BLOCKED: set[str] = { _INVITEALL_BLOCKED: set[str] = {
"!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s", # #commands "!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'<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 ""), + (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.',
)
+30 -10
View File
@@ -19,11 +19,15 @@ logger = logging.getLogger("matrixbot")
# The Space room to watch for new members # The Space room to watch for new members
SPACE_ROOM_ID = "!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc" 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 = [ INVITE_ROOMS = [
"!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0", # General (v12) "!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0", # General
"!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s", # Commands (v12) "!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U", # Memes
"!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U", # Memes (v12) "!ktQu0gavhjpCMkgxk8SYdb6mnJRY-u7mY7_KfksV0SU", # Music
"!ARbRFSPNp2U0MslWTBGoTT3gbmJJ25dPRL6enQntvPo", # Voice
"!3gMjTHqV-r823ZrvXnck7waB0Pd8tiCu-zbF7mSS83E", # Voice 2
] ]
WELCOME_EMOJI = "\u2705" # checkmark WELCOME_EMOJI = "\u2705" # checkmark
@@ -49,6 +53,17 @@ def _save_state(state: dict):
logger.error("Failed to save welcome state: %s", e) 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): async def handle_space_join(client: AsyncClient, sender: str):
"""Called when a new user joins the Space. DM them a welcome message.""" """Called when a new user joins the Space. DM them a welcome message."""
state = _load_state() state = _load_state()
@@ -57,6 +72,12 @@ async def handle_space_join(client: AsyncClient, sender: str):
if sender in welcomed: if sender in welcomed:
return 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) logger.info("New Space member %s — sending welcome DM", sender)
dm_room = await get_or_create_dm(client, sender) dm_room = await get_or_create_dm(client, sender)
@@ -66,13 +87,13 @@ async def handle_space_join(client: AsyncClient, sender: str):
plain = ( plain = (
"Welcome to The Lotus Guild!\n\n" "Welcome to The Lotus Guild!\n\n"
f"React to this message with {WELCOME_EMOJI} to get invited to all channels.\n\n" f"React to this message with {WELCOME_EMOJI} to get invited to all public channels.\n\n"
"You'll be added to General, Commands, and Memes." "You'll be added to General, Memes, Music, and the Voice channels."
) )
html = ( html = (
"<h3>Welcome to The Lotus Guild!</h3>" "<h3>Welcome to The Lotus Guild!</h3>"
f"<p>React to this message with {WELCOME_EMOJI} to get invited to all channels.</p>" f"<p>React to this message with {WELCOME_EMOJI} to get invited to all public channels.</p>"
"<p>You'll be added to <b>General</b>, <b>Commands</b>, and <b>Memes</b>.</p>" "<p>You'll be added to <b>General</b>, <b>Memes</b>, <b>Music</b>, and the <b>Voice</b> channels.</p>"
) )
resp = await send_html(client, dm_room, plain, html) 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!") await send_text(client, room_id, "You're already in all the channels!")
async def post_welcome_message(client: AsyncClient): def log_ready():
"""No-op kept for backward compatibility with bot.py startup."""
logger.info("Welcome module ready — watching Space for new members") logger.info("Welcome module ready — watching Space for new members")