Files
ecw-charge-validation/src/ecw_bot/core/msgbox.py
T
ManikandanandClaude Opus 5.5 ba15a574ab Initial import: eCW Charge Validation bot and results Portal
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>
2026-10-23 18:38:17 +05:30

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