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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user