# ───────────────────────────────────────────────────────────────── # 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 # .venv\Scripts\python otp_fill.py --from-marker # # 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())