From fb933b0b13f7e8165f417fef9ef94e8332f906cf Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 16 Aug 2026 15:46:36 -0400 Subject: [PATCH] feat(matrixbot): remove the auto-invite welcome flow entirely Joining the Lotus Guild Space (join_rule: public) triggered a welcome DM; reacting to it made the bot invite that user into a fixed room list. Now that #general is published to the public room directory, any stranger can join the Space and trigger this. The room list excluded the invite-only rooms, but it included Voice, whose join rule is `knock`. An invite bypasses a knock gate, so a stranger could skip the approval step that rule exists to enforce. Removes welcome.py, the Space-join watcher in Callbacks.member, both welcome-reaction hooks, and the admin `cleanwelcome` command. The bot no longer issues invites automatically anywhere. The PL50+ `invite` and `inviteall` commands are untouched: those are deliberate admin actions. Co-Authored-By: Claude Opus 5 (1M context) --- matrixbot/bot.py | 3 - matrixbot/callbacks.py | 34 ++------- matrixbot/commands.py | 14 +--- matrixbot/welcome.py | 170 ----------------------------------------- 4 files changed, 9 insertions(+), 212 deletions(-) delete mode 100644 matrixbot/welcome.py diff --git a/matrixbot/bot.py b/matrixbot/bot.py index 4c39154..89366a0 100644 --- a/matrixbot/bot.py +++ b/matrixbot/bot.py @@ -27,7 +27,6 @@ from config import ( ) from callbacks import Callbacks from utils import setup_logging -from welcome import log_ready as _welcome_log_ready logger = setup_logging(LOG_LEVEL) @@ -188,8 +187,6 @@ async def main(): # Trust devices after initial sync loads the device store await trust_devices(client) - _welcome_log_ready() - logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID) # Run sync_forever in a task so we can cancel on shutdown diff --git a/matrixbot/callbacks.py b/matrixbot/callbacks.py index 9a7f3ef..61016db 100644 --- a/matrixbot/callbacks.py +++ b/matrixbot/callbacks.py @@ -14,8 +14,6 @@ from commands import ( record_nhie_reaction, record_hottake_reaction, ) -from welcome import handle_welcome_reaction, handle_space_join, SPACE_ROOM_ID - logger = logging.getLogger("matrixbot") @@ -95,7 +93,6 @@ class Callbacks: key = event.key logger.info("reaction: key=%r target=%s sender=%s", key, reacted_event_id[:16], event.sender) - await handle_welcome_reaction(self.client, room.room_id, event.sender, reacted_event_id, key) record_wyr_vote(reacted_event_id, event.sender, key) record_acronym_vote(reacted_event_id, event.sender, key) record_nhie_reaction(reacted_event_id, event.sender, key) @@ -119,33 +116,18 @@ class Callbacks: key = relates_to.get("key", "") logger.info("unknown_event reaction: key=%r target=%s sender=%s", key, reacted_event_id[:16], event.sender) - await handle_welcome_reaction(self.client, room.room_id, event.sender, reacted_event_id, key) record_wyr_vote(reacted_event_id, event.sender, key) record_acronym_vote(reacted_event_id, event.sender, key) record_nhie_reaction(reacted_event_id, event.sender, key) record_hottake_reaction(reacted_event_id, event.sender, key) async def member(self, room, event): - """Handle m.room.member events — watch for Space joins.""" - # Ignore events from before startup - if self.startup_sync_token is None: - return + """Handle m.room.member events. - # Only care about the Space - if room.room_id != SPACE_ROOM_ID: - return - - # Ignore our own membership changes - if event.state_key == MATRIX_USER_ID: - return - - # Only trigger on joins (not leaves, bans, etc.) - if event.membership != "join": - return - - # Check if this is a new join (prev was not "join") - prev = event.prev_membership if hasattr(event, "prev_membership") else None - if prev == "join": - return # Already was a member, this is a profile update or similar - - await handle_space_join(self.client, event.state_key) + The Space-join welcome flow was removed deliberately: it DM'd anyone + who joined the (public) Space and, on a reaction, invited them into + rooms including Voice, whose join rule is `knock`. An invite bypasses + that gate, so a stranger could skip the approval step entirely. The + bot no longer issues invites of any kind. + """ + return diff --git a/matrixbot/commands.py b/matrixbot/commands.py index 3db7bed..58109ab 100644 --- a/matrixbot/commands.py +++ b/matrixbot/commands.py @@ -15,7 +15,6 @@ from nio import AsyncClient from utils import send_text, send_html, send_reaction, edit_html, sanitize_input, rcon_command, RconError 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, @@ -147,7 +146,7 @@ async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str): 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'] @@ -4444,14 +4443,3 @@ async def cmd_syncspace(client: AsyncClient, room_id: str, sender: str, args: st ) -@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 deleted file mode 100644 index 8f8e134..0000000 --- a/matrixbot/welcome.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Welcome module — DM new Space members. - -When a user joins the Space, the bot sends them a DM with a welcome -message and a reaction button. When they react, the bot invites them -to the standard public channels (General, Commands, Memes). -""" - -import json -import logging -from pathlib import Path - -from nio import AsyncClient - -from utils import send_html, send_reaction, get_or_create_dm -from config import MATRIX_USER_ID - -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. -# 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 - "!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U", # Memes - "!ktQu0gavhjpCMkgxk8SYdb6mnJRY-u7mY7_KfksV0SU", # Music - "!ARbRFSPNp2U0MslWTBGoTT3gbmJJ25dPRL6enQntvPo", # Voice - "!3gMjTHqV-r823ZrvXnck7waB0Pd8tiCu-zbF7mSS83E", # Voice 2 -] - -WELCOME_EMOJI = "\u2705" # checkmark - -STATE_FILE = Path("welcome_state.json") - - -def _load_state() -> dict: - if STATE_FILE.exists(): - try: - return json.loads(STATE_FILE.read_text()) - except (json.JSONDecodeError, OSError): - pass - return {} - - -def _save_state(state: dict): - try: - tmp = STATE_FILE.with_suffix(".tmp") - tmp.write_text(json.dumps(state, indent=2)) - tmp.rename(STATE_FILE) - except OSError as 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): - """Called when a new user joins the Space. DM them a welcome message.""" - state = _load_state() - welcomed = state.get("welcomed_users", []) - - 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) - if not dm_room: - logger.error("Could not create DM with %s for welcome", sender) - return - - plain = ( - "Welcome to The Lotus Guild!\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, 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 public channels.

" - "

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

" - ) - - resp = await send_html(client, dm_room, plain, html) - if hasattr(resp, "event_id"): - # Track the welcome message per user so we can match their reaction - dm_messages = state.get("dm_welcome_messages", {}) - dm_messages[resp.event_id] = {"user": sender, "dm_room": dm_room} - state["dm_welcome_messages"] = dm_messages - _save_state(state) - - # React to our own message to show what to click - await send_reaction(client, dm_room, resp.event_id, WELCOME_EMOJI) - logger.info("Sent welcome DM to %s (event %s)", sender, resp.event_id) - else: - logger.error("Failed to send welcome DM to %s: %s", sender, resp) - - -async def handle_welcome_reaction( - client: AsyncClient, room_id: str, sender: str, reacted_event_id: str, key: str -): - """Handle a reaction to a welcome DM. Invite user to channels.""" - if sender == MATRIX_USER_ID: - return - - if key != WELCOME_EMOJI: - return - - state = _load_state() - dm_messages = state.get("dm_welcome_messages", {}) - entry = dm_messages.get(reacted_event_id) - - if not entry: - return - - if entry["user"] != sender: - return - - logger.info("Welcome reaction from %s — sending invites", sender) - - invited_count = 0 - for invite_room_id in INVITE_ROOMS: - room = client.rooms.get(invite_room_id) - if room and sender in (m.user_id for m in room.users.values()): - logger.debug("%s already in %s, skipping", sender, invite_room_id) - continue - - try: - resp = await client.room_invite(invite_room_id, sender) - logger.info("Invited %s to %s: %s", sender, invite_room_id, resp) - invited_count += 1 - except Exception as e: - logger.error("Failed to invite %s to %s: %s", sender, invite_room_id, e) - - # Mark user as welcomed - welcomed = state.get("welcomed_users", []) - if sender not in welcomed: - welcomed.append(sender) - state["welcomed_users"] = welcomed - - # Remove the DM message entry (one-time use) - del dm_messages[reacted_event_id] - state["dm_welcome_messages"] = dm_messages - _save_state(state) - - # Confirm in DM - from utils import send_text - if invited_count > 0: - await send_text(client, room_id, f"You've been invited to {invited_count} channel(s). Check your invites!") - else: - await send_text(client, room_id, "You're already in all the channels!") - - -def log_ready(): - logger.info("Welcome module ready — watching Space for new members")