Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion knock-approver/approver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1407,7 +1407,14 @@ async def announce_lobby_events(client):
dirty = True
if (meta.get("closed") and not rec["failed"]
and meta.get("closed_reason") not in (None, "promoted", "already_member")):
users = ", ".join(meta.get("challenged", []) or ["(no users joined)"])
if meta.get("closed_reason") == "timeout" and not meta.get("challenged"):
# Ghost room: link-preview bots and aborted clicks mint a
# /join/api room but never produce a real join. Mark seen
# so we don't re-evaluate, but don't notify.
rec["failed"] = True
dirty = True
continue
users = ", ".join(meta["challenged"])
await _send_msg(client, OPERATOR_NOTIFY_ROOM,
f"⚠️ lobby failed for {users} "
f"(reason={meta.get('closed_reason')}, code={meta.get('code', '?')})")
Expand Down
45 changes: 45 additions & 0 deletions tests/announce_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,56 @@ def test_seen_persists_across_cycles_for_open_then_closed():
assert len(sends) == 2, f"expected exactly 2 sends total (🚪 + ⚠️), got {len(sends)}: {sends}"


def test_ghost_timeout_suppressed_but_real_timeout_announced():
"""A timeout with empty `challenged` is a link-preview bot / aborted
click and must not fire ⚠️. A timeout where a user actually joined
but never completed the haiku still fires."""
sends = _install_send_recorder()
# Phase 1: both rooms open. !real has a challenged user (joined +
# got the haiku), !ghost has nobody (link-preview / aborted click).
lobby = {
"!ghost:t": {"code": "abc", "challenged": [], "displaynames": {},
"closed": False},
"!real:t": {"code": "abc", "challenged": ["@u:t"],
"displaynames": {"@u:t": "u"}, "closed": False},
}
approver._save(approver.LOBBY_PATH, lobby)
approver._save(approver.OPERATOR_ANNOUNCE_PATH,
{"!seed:t": {"started": [], "failed": False}})

client = MagicMock()
asyncio.run(approver.announce_lobby_events(client))
started_msgs = [t for _r, t in sends if "started lobby flow" in t]
assert len(started_msgs) == 1, f"expected 1 🚪 in phase 1, got {sends}"

# Phase 2: both time out. Ghost still has no challenged users.
sends.clear()
for rid in ("!ghost:t", "!real:t"):
lobby[rid]["closed"] = True
lobby[rid]["closed_reason"] = "timeout"
approver._save(approver.LOBBY_PATH, lobby)

asyncio.run(approver.announce_lobby_events(client))
asyncio.run(approver.announce_lobby_events(client))

failed_msgs = [t for _r, t in sends if "lobby failed for" in t]
assert len(failed_msgs) == 1, f"expected 1 ⚠️ (real only), got {sends}"
assert "@u:t" in failed_msgs[0], f"⚠️ should name the real user, got {failed_msgs[0]}"
assert "(no users joined)" not in " ".join(t for _r, t in sends), \
f"ghost timeout must not fire (no users joined) message: {sends}"

seen = json.loads(approver.OPERATOR_ANNOUNCE_PATH.read_text())
assert seen["!ghost:t"]["failed"] is True, \
"ghost room must be marked failed so we don't re-evaluate"


if __name__ == "__main__":
test_no_flood_on_historical_closed_rooms()
print("ok: no_flood_on_historical_closed_rooms")
test_open_room_still_announced()
print("ok: open_room_still_announced")
test_seen_persists_across_cycles_for_open_then_closed()
print("ok: seen_persists_across_cycles_for_open_then_closed")
test_ghost_timeout_suppressed_but_real_timeout_announced()
print("ok: ghost_timeout_suppressed_but_real_timeout_announced")
print("all tests passed")
Loading