""" Operator prompts. Everything here can time out. An earlier run wedged for forty minutes on a plain MessageBoxW: the mailbox poll found nothing, the code fell back to asking a human, and nobody was at the desk — so the process sat holding a browser open with no log output while eCW went on rate-limiting the account. A prompt that can block forever is a prompt that will, so `MessageBoxTimeoutW` is used instead and callers are handed an explicit TIMEOUT. `MessageBoxTimeoutW` is undocumented but has shipped in user32 since XP. If it is ever missing, the plain call is used and the timeout silently does not apply — better a hang than a crash at the moment a human is being asked for help. """ import ctypes from ctypes import wintypes from . import watchdog # Return values OK = "ok" CANCEL = "cancel" TIMEOUT = "timeout" _MB_OK = 0x00000000 _MB_OKCANCEL = 0x00000001 _MB_ICONINFO = 0x00000040 _MB_ICONWARN = 0x00000030 _MB_TOPMOST = 0x00040000 # otherwise it can open behind the browser window # Second button (Cancel, on an OK/Cancel box) is the default one, so Enter or # Space picks Cancel instead of OK. Without it, Enter on the per-claim save # box meant OK — save the claim to live eCW (review item P1). _MB_DEFBUTTON2 = 0x00000100 _IDOK = 1 _IDTIMEOUT = 32000 def _raw_call(text: str, caption: str, flags: int, timeout_s: int) -> int: # A box waits on a person, not a frozen page: the claim watchdog's # clock starts again when it opens and when it closes. watchdog.beat("message box") try: return _call_box(text, caption, flags, timeout_s) finally: watchdog.beat("message box closed") def _call_box(text: str, caption: str, flags: int, timeout_s: int) -> int: user32 = ctypes.windll.user32 try: fn = user32.MessageBoxTimeoutW fn.argtypes = [wintypes.HWND, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.UINT, wintypes.WORD, wintypes.DWORD] fn.restype = ctypes.c_int return fn(None, text, caption, flags, 0, int(timeout_s * 1000)) except AttributeError: return user32.MessageBoxW(None, text, caption, flags) def info(title: str, msg: str, timeout_s: int, needs_person: bool = False) -> str: """Informational box. Returns OK or TIMEOUT. With QUIET_BOXES on (a run the results page started), a box that does not need a person is one log line instead, and answers OK.""" if cfg.QUIET_BOXES and not needs_person: first = (msg or "").strip().splitlines()[0] if (msg or "").strip() \ else "" print(f"[BOX] {titled(title)}: {first}") return OK try: rc = _raw_call(msg, titled(title), _MB_OK | _MB_ICONINFO | _MB_TOPMOST, timeout_s) except Exception: return TIMEOUT return TIMEOUT if rc == _IDTIMEOUT else OK def confirm(title: str, msg: str, timeout_s: int, default_cancel: bool = False) -> str: """ OK/Cancel box. Returns OK, CANCEL or TIMEOUT. `default_cancel=True` makes Cancel the default button, so Enter/Space answers Cancel. For boxes where OK writes to live eCW: there, a key pressed out of habit must not count as a decision. Off by default so the captcha and security-image prompts, where OK only lets a read continue, behave as they always have. """ footer = (f"\n\n(If there's no reply, this will cancel automatically " f"after {timeout_s}s.)") flags = _MB_OKCANCEL | _MB_ICONWARN | _MB_TOPMOST if default_cancel: flags |= _MB_DEFBUTTON2 try: rc = _raw_call(msg + footer, titled(title), flags, timeout_s) except Exception: # No user32 (not Windows): nothing can be asked, so do not pretend a # human agreed. return TIMEOUT if rc == _IDTIMEOUT: return TIMEOUT return OK if rc == _IDOK else CANCEL # ============================================================================= # Convenience wrappers that apply the configured timeout and log the timeout # ============================================================================= # These were private helpers in auth.py. They are here because every caller # wants the same two things — the configured timeout, and a log line when a # prompt goes unanswered — and duplicating that per module is how one of them # ends up silently treating a timeout as consent. from .. import settings as cfg # noqa: E402 (kept below the ctypes core) TEST_PREFIX = "[TEST] " def titled(title: str) -> str: """ `title` as the box shows it: with `[TEST] ` in front when the claim ids come from a TEST_CLAIM_IDS_FILE sheet (review item D9, 2026-10-05). A test run drives the same live eCW as a real one, so a screen with a box on it looks exactly like production to anyone watching — the client in a demo included. The [MODE] banner says "TEST MODE" in the log only; this puts it on every box. Applied in the raw `info()` / `confirm()`, the two functions every box reaches `_raw_call` through, not only in the `info_box` / `ok_cancel` wrappers: three callers use the raw ones directly (the 2FA manual box in auth/mailbox.py, which a test run does reach at login; "Run the Lookup?" in pages/claim_results.py; the export report box in pages/claim_report.py). Done here rather than at each call site so a box added later cannot forget it. The wrappers apply it too, so their "timed out" log line names the title the box showed; that is safe because the prefix is never doubled. Read at call time (this module imports settings below the ctypes core, and settings can change under a test). """ if cfg.TEST_CLAIM_IDS_FILE and not title.startswith(TEST_PREFIX): return TEST_PREFIX + title return title def info_box(title: str, msg: str, needs_person: bool = False) -> None: """Informational box on the configured timeout. Never blocks the run. `needs_person=True` for a box a person must act on (it is never quieted by QUIET_BOXES).""" title = titled(title) # The keyword is passed only when set, so a test that replaces info() # with a three-argument stub keeps working. extra = {"needs_person": True} if needs_person else {} if info(title, msg, cfg.MSGBOX_TIMEOUT_SECONDS, **extra) == TIMEOUT: print(f"[UI] '{title}' timed out with nobody at the desk — continuing") def ok_cancel(title: str, msg: str, default_cancel: bool = False) -> bool: """ True only on an explicit OK. A timeout counts as 'no'. Note the asymmetry with `claim_results.run_lookup`, which treats a timeout as "carry on": there, proceeding only reads. Here the prompts guard captcha and security-image steps where proceeding without a human is meaningless, so silence must not be read as agreement. `default_cancel=True` (see `confirm`) for any box whose OK saves to eCW: the per-claim save box and the LIVE-WRITE start box. """ title = titled(title) result = confirm(title, msg, cfg.MSGBOX_TIMEOUT_SECONDS, default_cancel=default_cancel) if result == TIMEOUT: print(f"[UI] '{title}' timed out with nobody at the desk — treating as cancel") return result == OK