diff --git a/matrixbot/callbacks.py b/matrixbot/callbacks.py index 5f6f820..737743e 100644 --- a/matrixbot/callbacks.py +++ b/matrixbot/callbacks.py @@ -81,19 +81,29 @@ class Callbacks: # m.reaction events come as UnknownEvent with type "m.reaction" if not hasattr(event, "source"): + logger.info("reaction: event has no source attr, type=%s sender=%s", type(event).__name__, event.sender) return + event_type = event.source.get("type", "") content = event.source.get("content", {}) relates_to = content.get("m.relates_to", {}) - if relates_to.get("rel_type") != "m.annotation": - return - + rel_type = relates_to.get("rel_type", "") reacted_event_id = relates_to.get("event_id", "") key = relates_to.get("key", "") + logger.info( + "reaction: type=%s rel_type=%s key=%r target=%s sender=%s", + event_type, rel_type, key, reacted_event_id[:16] if reacted_event_id else "", event.sender, + ) + + if rel_type != "m.annotation": + return + await handle_welcome_reaction( self.client, room.room_id, event.sender, reacted_event_id, key ) + from commands import _WYR_POLLS + logger.info("reaction: wyr polls active=%s matched=%s", list(_WYR_POLLS.keys()), reacted_event_id in _WYR_POLLS) record_wyr_vote(reacted_event_id, event.sender, key) async def member(self, room, event): diff --git a/matrixbot/commands.py b/matrixbot/commands.py index bfdbc5a..4a56de7 100644 --- a/matrixbot/commands.py +++ b/matrixbot/commands.py @@ -11,7 +11,7 @@ import aiohttp from nio import AsyncClient -from utils import send_text, send_html, send_reaction, sanitize_input +from utils import send_text, send_html, send_reaction, edit_html, sanitize_input from wordle import handle_wordle from config import ( MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX, ADMIN_USERS, @@ -1321,6 +1321,32 @@ def _hangman_display(game: dict) -> str: return " ".join(c if c.lower() in guessed else "_" for c in word.upper()) +def _hangman_board_html(game: dict, status_line: str = "") -> tuple[str, str]: + """Return (plain, html) for the current hangman board state.""" + word = game["word"] + wrong_count = game["wrong_count"] + display = _hangman_display(game) + wrong_letters = sorted(ch for ch in game["guessed_letters"] if ch not in word) + stage_art = _HANGMAN_STAGES[wrong_count].replace("```", "") + + plain = ( + f"🎯 Hangman!\n{stage_art}\n" + f"Word: {display} ({len(word)} letters)\n" + f"Hint: {game['hint']}\n" + f"Wrong ({wrong_count}/6): {', '.join(wrong_letters) or 'none'}" + + (f"\n{status_line}" if status_line else "") + ) + html = ( + f'🎯 Hangman!
' + f'
{stage_art}
' + f'Word: {display} ({len(word)} letters)
' + f'Hint: {game["hint"]}
' + f'Wrong ({wrong_count}/6): {", ".join(wrong_letters) or "none"}' + + (f'
{status_line}' if status_line else "") + ) + return plain, html + + async def _generate_hangman_word() -> dict | None: system_msg = ( "You are a hangman game generator. Always respond with ONLY a JSON object — no markdown, no explanation. " @@ -1393,25 +1419,14 @@ async def cmd_hangman(client: AsyncClient, room_id: str, sender: str, args: str) "hint": hint, "guessed_letters": set(), "wrong_count": 0, + "board_event_id": None, } _HANGMAN_GAMES[room_id] = game - plain = ( - f"🎯 Hangman!\n" - f"{_HANGMAN_STAGES[0]}\n" - f"Word: {display} ({len(word)} letters)\n" - f"Hint: {hint}\n" - f"Guess a letter or word with !guess \n" - f"Max 6 wrong guesses." - ) - html = ( - f'🎯 Hangman!
' - f'
{_HANGMAN_STAGES[0].replace("```", "")}
' - f'Word: {display} ({len(word)} letters)
' - f'Hint: {hint}
' - f'Guess with !guess <letter/word> — max 6 wrong guesses' - ) - await send_html(client, room_id, plain, html) + plain, html = _hangman_board_html(game, "Guess with !guess — max 6 wrong guesses") + resp = await send_html(client, room_id, plain, html) + if hasattr(resp, "event_id"): + game["board_event_id"] = resp.event_id @command("guess", "Guess a letter or word in hangman (!guess )") @@ -1429,38 +1444,34 @@ async def cmd_guess(client: AsyncClient, room_id: str, sender: str, args: str): word = game["word"] + board_id = game.get("board_event_id") + + async def _update_board(status: str): + """Edit the board message in place, or send a new one if edit unavailable.""" + p, h = _hangman_board_html(game, status) + if board_id: + await edit_html(client, room_id, board_id, p, h) + else: + await send_html(client, room_id, p, h) + # Full word guess if len(guess) > 1: winner = sender.split(":")[0].lstrip("@") if guess == word: del _HANGMAN_GAMES[room_id] - plain = f"🎉 {winner} got it! The word was: {word.upper()}" - html = ( - f'🎉 {winner} got it! The word was: {word.upper()}' + await send_html( + client, room_id, + f"🎉 {winner} got it! The word was: {word.upper()}", + f'🎉 {winner} got it! The word was: {word.upper()}', ) - await send_html(client, room_id, plain, html) else: game["wrong_count"] += 1 - display = _hangman_display(game) if game["wrong_count"] >= 6: del _HANGMAN_GAMES[room_id] - plain = f"❌ '{guess.upper()}' is wrong! Game over — the word was: {word.upper()}" - html = f'❌ Wrong! Game over — the word was: {word.upper()}' - await send_html(client, room_id, plain, html) + await _update_board(f"💀 Wrong! Game over — the word was: {word.upper()}") else: remaining = 6 - game["wrong_count"] - plain = ( - f"❌ '{guess.upper()}' is wrong! {remaining} guesses remaining.\n" - f"{_HANGMAN_STAGES[game['wrong_count']]}\n" - f"Word: {display}\nHint: {game['hint']}" - ) - html = ( - f'❌ \'{guess.upper()}\' is wrong! ' - f'{remaining} guesses remaining.
' - f'Word: {display}
' - f'Hint: {game["hint"]}' - ) - await send_html(client, room_id, plain, html) + await _update_board(f"❌ '{guess.upper()}' is wrong! {remaining} guesses remaining.") return # Single letter guess @@ -1473,64 +1484,21 @@ async def cmd_guess(client: AsyncClient, room_id: str, sender: str, args: str): if letter in word: display = _hangman_display(game) - # Check if fully revealed if "_" not in display: del _HANGMAN_GAMES[room_id] - plain = f"🎉 Solved! The word was: {word.upper()}" - html = f'🎉 Solved! The word was: {word.upper()}' - await send_html(client, room_id, plain, html) + await _update_board(f"🎉 Solved! The word was: {word.upper()}") return - - wrong_letters = sorted(ch for ch in game["guessed_letters"] if ch not in word) - plain = ( - f"✅ '{letter.upper()}' is in the word!\n" - f"{_HANGMAN_STAGES[game['wrong_count']]}\n" - f"Word: {display}\n" - f"Hint: {game['hint']}\n" - f"Wrong guesses ({game['wrong_count']}/6): {', '.join(wrong_letters) or 'none'}" - ) - html = ( - f'✅ \'{letter.upper()}\' is in the word!
' - f'Word: {display}
' - f'Hint: {game["hint"]}
' - f'Wrong ({game["wrong_count"]}/6): {", ".join(wrong_letters) or "none"}' - ) - await send_html(client, room_id, plain, html) + await _update_board(f"✅ '{letter.upper()}' is in the word!") else: game["wrong_count"] += 1 wrong_count = game["wrong_count"] - display = _hangman_display(game) - wrong_letters = sorted(ch for ch in game["guessed_letters"] if ch not in word) if wrong_count >= 6: del _HANGMAN_GAMES[room_id] - plain = ( - f"💀 '{letter.upper()}' is wrong! Game over!\n" - f"{_HANGMAN_STAGES[6]}\n" - f"The word was: {word.upper()}" - ) - html = ( - f'💀 Game Over!
' - f'The word was: {word.upper()}
' - f'Wrong letters: {", ".join(wrong_letters)}' - ) - await send_html(client, room_id, plain, html) + await _update_board(f"💀 Game over! The word was: {word.upper()}") else: remaining = 6 - wrong_count - plain = ( - f"❌ '{letter.upper()}' is not in the word. {remaining} wrong guesses remaining.\n" - f"{_HANGMAN_STAGES[wrong_count]}\n" - f"Word: {display}\n" - f"Hint: {game['hint']}\n" - f"Wrong guesses ({wrong_count}/6): {', '.join(wrong_letters)}" - ) - html = ( - f'❌ \'{letter.upper()}\' is not in the word!
' - f'Word: {display}
' - f'Hint: {game["hint"]}
' - f'Wrong ({wrong_count}/6): {", ".join(wrong_letters)}' - ) - await send_html(client, room_id, plain, html) + await _update_board(f"❌ '{letter.upper()}' not in the word — {remaining} guesses left.") # --------------------------------------------------------------------------- diff --git a/matrixbot/utils.py b/matrixbot/utils.py index 3c43e52..cb047cb 100644 --- a/matrixbot/utils.py +++ b/matrixbot/utils.py @@ -87,6 +87,31 @@ async def send_html(client: AsyncClient, room_id: str, plain: str, html: str): return resp +async def edit_html(client: AsyncClient, room_id: str, event_id: str, plain: str, html: str): + """Edit an existing message in place using m.replace.""" + logger = logging.getLogger("matrixbot") + content = { + "msgtype": "m.text", + "body": f"* {plain}", + "format": "org.matrix.custom.html", + "formatted_body": html, + "m.new_content": { + "msgtype": "m.text", + "body": plain, + "format": "org.matrix.custom.html", + "formatted_body": html, + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": event_id, + }, + } + resp = await _room_send_trusted(client, room_id, message_type="m.room.message", content=content) + if not isinstance(resp, RoomSendResponse): + logger.error("edit_html failed: %s", resp) + return resp + + async def send_reaction(client: AsyncClient, room_id: str, event_id: str, emoji: str): return await _room_send_trusted( client, room_id,