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 callbacks import Callbacks
|
||||||
from utils import setup_logging
|
from utils import setup_logging
|
||||||
from welcome import log_ready as _welcome_log_ready
|
|
||||||
|
|
||||||
logger = setup_logging(LOG_LEVEL)
|
logger = setup_logging(LOG_LEVEL)
|
||||||
|
|
||||||
@@ -188,8 +187,6 @@ 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)
|
||||||
|
|
||||||
_welcome_log_ready()
|
|
||||||
|
|
||||||
logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID)
|
logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID)
|
||||||
|
|
||||||
# Run sync_forever in a task so we can cancel on shutdown
|
# 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_nhie_reaction,
|
||||||
record_hottake_reaction,
|
record_hottake_reaction,
|
||||||
)
|
)
|
||||||
from welcome import handle_welcome_reaction, handle_space_join, SPACE_ROOM_ID
|
|
||||||
|
|
||||||
logger = logging.getLogger("matrixbot")
|
logger = logging.getLogger("matrixbot")
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +93,6 @@ class Callbacks:
|
|||||||
key = event.key
|
key = event.key
|
||||||
logger.info("reaction: key=%r target=%s sender=%s", key, reacted_event_id[:16], event.sender)
|
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_wyr_vote(reacted_event_id, event.sender, key)
|
||||||
record_acronym_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_nhie_reaction(reacted_event_id, event.sender, key)
|
||||||
@@ -119,33 +116,18 @@ class Callbacks:
|
|||||||
key = relates_to.get("key", "")
|
key = relates_to.get("key", "")
|
||||||
logger.info("unknown_event reaction: key=%r target=%s sender=%s", key, reacted_event_id[:16], event.sender)
|
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_wyr_vote(reacted_event_id, event.sender, key)
|
||||||
record_acronym_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_nhie_reaction(reacted_event_id, event.sender, key)
|
||||||
record_hottake_reaction(reacted_event_id, event.sender, key)
|
record_hottake_reaction(reacted_event_id, event.sender, key)
|
||||||
|
|
||||||
async def member(self, room, event):
|
async def member(self, room, event):
|
||||||
"""Handle m.room.member events — watch for Space joins."""
|
"""Handle m.room.member events.
|
||||||
# Ignore events from before startup
|
|
||||||
if self.startup_sync_token is None:
|
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
|
return
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|||||||
+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 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 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,
|
||||||
@@ -147,7 +146,7 @@ async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str):
|
|||||||
categories.append(("🔧 Management (PL50+)", [
|
categories.append(("🔧 Management (PL50+)", [
|
||||||
"mkroom", "roominfo", "roomname", "topic", "invite", "inviteall",
|
"mkroom", "roominfo", "roomname", "topic", "invite", "inviteall",
|
||||||
"setpl", "kick", "purge", "members", "whois", "announce", "syncspace",
|
"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>']
|
||||||
@@ -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