feat(matrixbot): remove the auto-invite welcome flow entirely
Lint / JS (eslint) (push) Successful in 1m16s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 1m1s
Lint / Secret scan (gitleaks) (push) Successful in 7s
Lint / Shell (shellcheck) (push) Successful in 16s

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:46:36 -04:00
co-authored by Claude Opus 5
parent 9e5d3ec83d
commit fb933b0b13
4 changed files with 9 additions and 212 deletions
-3
View File
@@ -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
+8 -26
View File
@@ -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
+1 -13
View File
@@ -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 = ['<font color="#a855f7"><strong>🌸 LotusBot — Commands</strong></font>']
@@ -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'<font color="#22c55e"><strong>✅ Welcome cleanup</strong></font><br>'
f'Removed <strong>{removed}</strong> pending DM record(s) that were never reacted to.',
)
-170
View File
@@ -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 = (
"<h3>Welcome to The Lotus Guild!</h3>"
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>Memes</b>, <b>Music</b>, and the <b>Voice</b> channels.</p>"
)
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")