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,