Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7.1 KiB
Python
176 lines
7.1 KiB
Python
"""
|
|
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
|