Workflow-App/Workflow/scripts/otp_fill.py

151 lines
5 KiB
Python
Raw Normal View History

# ─────────────────────────────────────────────────────────────────
# otp_fill.py — fill an SMS OTP into a running SARS login Chromium
# session via Chrome DevTools Protocol.
#
# Used by the parent agent when `SARS login Company spesific.py`
# detects a SARS OTP prompt and pauses. The login script writes its
# CDP port + PID to `scripts/.sars_cdp.json`; we read it from there
# (or accept the port as an argument) and connect.
#
# Usage:
# .venv\Scripts\python otp_fill.py <cdp_port> <otp>
# .venv\Scripts\python otp_fill.py --from-marker <otp>
#
# Returns exit code 0 on success, non-zero on failure (with a clear
# stderr message so the agent can debug).
# ─────────────────────────────────────────────────────────────────
import argparse
import json
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
SCRIPTS_DIR = Path(__file__).resolve().parent
CDP_INFO_PATH = SCRIPTS_DIR / ".sars_cdp.json"
# Selectors that match SARS's OTP input. Kept in sync with
# SARS login Company spesific.py::OTP_SELECTORS.
OTP_SELECTORS = [
"input[aria-label*='OTP' i]",
"input[aria-label*='One' i]", # "One Time Pin"
"input[aria-label*='verification' i]",
"input[placeholder*='OTP' i]",
"input[placeholder*='PIN' i]",
"input[name*='otp' i]",
"input[id*='otp' i]",
"input[type='tel']",
]
# Button names SARS uses to submit the OTP. First match wins.
SUBMIT_BUTTON_NAMES = ["Submit", "Verify", "Continue", "Login", "Confirm", "Next"]
def _fill_otp(page, otp: str) -> str:
"""Find the OTP input and fill it. Returns the selector that matched."""
for sel in OTP_SELECTORS:
try:
loc = page.locator(sel).first
if loc.count() > 0:
loc.fill(otp)
return sel
except Exception:
continue
raise RuntimeError(
f"No OTP field found matching any of {OTP_SELECTORS} "
f"(current URL: {page.url})"
)
def _click_submit(page) -> str:
"""Click the submit button. Returns the button name that matched."""
for name in SUBMIT_BUTTON_NAMES:
try:
btn = page.get_by_role("button", name=name).first
if btn.count() > 0:
btn.click(timeout=3_000)
return name
except Exception:
continue
raise RuntimeError(
f"No submit button found matching any of {SUBMIT_BUTTON_NAMES}"
)
def main() -> int:
ap = argparse.ArgumentParser(
description=(
"Inject an SMS OTP into a running SARS login Chromium session "
"via Chrome DevTools Protocol."
)
)
ap.add_argument("port_or_marker", help="CDP port number, or --from-marker flag value")
ap.add_argument("otp", help="OTP/PIN value to type into the browser")
ap.add_argument(
"--from-marker",
action="store_true",
help=(
"Read CDP port + PID from scripts/.sars_cdp.json "
"(written by the SARS login script)."
),
)
args = ap.parse_args()
if args.from_marker:
try:
with open(CDP_INFO_PATH) as f:
port = json.load(f)["cdp_port"]
except Exception as e:
print(
f"ERROR: Could not read {CDP_INFO_PATH}: {e}",
file=sys.stderr,
)
return 2
print(f"Read CDP port {port} from {CDP_INFO_PATH}")
else:
port = int(args.port_or_marker)
otp = args.otp
try:
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(f"http://127.0.0.1:{port}")
if not browser.contexts:
print("ERROR: Browser has no contexts", file=sys.stderr)
return 3
ctx = browser.contexts[0]
if not ctx.pages:
print("ERROR: Context has no pages", file=sys.stderr)
return 4
page = ctx.pages[0]
print(f"Connected to port {port}. URL: {page.url}")
matched_sel = _fill_otp(page, otp)
print(f"Filled OTP into field matched by: {matched_sel}")
try:
btn_name = _click_submit(page)
print(f"Clicked button: {btn_name}")
except RuntimeError as e:
print(f"WARNING: {e}", file=sys.stderr)
print("OTP filled but not submitted. Typing Enter as fallback.")
try:
page.keyboard.press("Enter")
except Exception:
pass
time.sleep(2)
print(f"After submit, URL: {page.url}")
except Exception as e:
print(f"ERROR: {type(e).__name__}: {e}", file=sys.stderr)
return 5
print("OK")
return 0
if __name__ == "__main__":
sys.exit(main())