Files
matrix/matrixbot/welcome.py
T
jared 88627470c1
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
feat: management polish, !cancel, !wordlestats, welcome fixes
- 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>
2026-04-29 14:50:14 -04:00

171 lines
5.6 KiB
Python

"""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")