diff --git a/app/api/autobuyer/runs/claim/route.ts b/app/api/autobuyer/runs/claim/route.ts index f028a6f..e643604 100644 --- a/app/api/autobuyer/runs/claim/route.ts +++ b/app/api/autobuyer/runs/claim/route.ts @@ -1,5 +1,5 @@ import { claimRun, getSetting } from '@/lib/db'; -import { findAutomation, resolveSteps } from '@/lib/automations'; +import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations'; import { corsJson, corsPreflight } from '../../cors'; /** The Python runner polls this. Returns the run plus the steps to execute, so @@ -24,6 +24,7 @@ export async function POST() { firm: found.firm.label, label: found.automation.label, steps: resolveSteps(found.firm, found.automation), + authSteps: resolveAuthSteps(found.firm), }, }); } diff --git a/app/autobuyer/page.tsx b/app/autobuyer/page.tsx index 3cf7540..7f1466e 100644 --- a/app/autobuyer/page.tsx +++ b/app/autobuyer/page.tsx @@ -301,16 +301,7 @@ export default function AutoBuyer() { ? 'Runner offline' : runner.busy ? 'Runner busy' : 'Runner online'} - {runner?.online && runner.stale && ( -
- The runner is out of date (reporting v{runner.version ?? 'unknown'}, this - dashboard needs v{runner.expectedVersion}). It will fail on step types it - predates. Restart it: stop with Ctrl-C and run{' '} - python clicker/runner.py again. -
- )} - - {runner?.online && runner.dryRun && ( + {runner?.online && runner.dryRun && ( dry run diff --git a/clicker/actions.py b/clicker/actions.py index 31ec81e..57b3d60 100644 --- a/clicker/actions.py +++ b/clicker/actions.py @@ -14,7 +14,7 @@ class StepError(Exception): """A step refused to run, or ran and could not be verified. `code` mirrors the CLI's exit codes: 3 = refused before acting, - 4 = acted but verification failed. + 4 = acted but verification failed, 5 = the session is signed out. """ def __init__(self, message: str, code: int = 3): @@ -22,6 +22,35 @@ class StepError(Exception): self.code = code +class SignedOutError(StepError): + """The tab is on the login page, so the session has expired. + + Distinct from an ordinary failure because nothing is wrong with the + automation — it just cannot proceed until a human signs in. + """ + + def __init__(self, url: str): + super().__init__( + f"signed out — the tab is on {url}. Log in to the site, then run this again.", + code=5, + ) + + +def check_signed_in(found: dict, signed_out: str) -> None: + """Abort if the page redirected to a login screen. + + Worth doing before every action, not just at the start: a session can expire + mid-run, and once it does, every selector resolves against a login form. A + click or a keystroke aimed at the old page would land on whatever that form + happens to render in the same place. + """ + if not signed_out: + return + url = found.get("url") or "" + if signed_out in url: + raise SignedOutError(url) + + # Display scaling factors that actually exist. Anything else means the two sides # are describing different things (usually a multi-monitor desktop) rather than a # scaled single display. @@ -73,6 +102,7 @@ def perform( index: int = 0, url: str = "", open_url: str = "", + signed_out: str = "", text: str | None = None, clear: bool = False, scale: float | None = None, @@ -101,6 +131,9 @@ def perform( if on_located: on_located(found) + # Before any check that assumes we are on the real page. + check_signed_in(found, signed_out) + if found.get("covered") and not force: raise StepError( f"{selector} is covered by {found.get('coveredBy')} — the click would hit that instead" @@ -163,6 +196,7 @@ def perform( # Typing into a field that never took focus, or that ignores the input, fails # silently and is otherwise indistinguishable from success. after = dash.locate(selector, index, url, timeout, open_url) + check_signed_in(after, signed_out) got = after.get("value") if got is None: say("element exposes no value to verify against") diff --git a/clicker/clicker.py b/clicker/clicker.py index 2569b16..ecf2d08 100644 --- a/clicker/clicker.py +++ b/clicker/clicker.py @@ -119,6 +119,7 @@ def main() -> int: parser.add_argument("--index", type=int, default=0, help="which match, if the selector hits several (default 0)") parser.add_argument("--url", default="", help='Chrome match pattern for the tab, e.g. "https://tradeify.co/*"') parser.add_argument("--open-url", default="", help="page to open if no tab matches --url") + parser.add_argument("--signed-out", default="", help="URL substring identifying the login page; abort if the tab lands there") parser.add_argument("--api", default=DEFAULT_API, help=f"dashboard URL (default {DEFAULT_API})") parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension (default 45; a cold page load takes time)") parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio (default: auto-detect)") @@ -210,6 +211,7 @@ def main() -> int: index=args.index, url=args.url, open_url=args.open_url, + signed_out=args.signed_out, text=text, clear=args.clear, scale=args.scale, diff --git a/clicker/runner.py b/clicker/runner.py index 168167e..c7ff2bd 100644 --- a/clicker/runner.py +++ b/clicker/runner.py @@ -32,7 +32,7 @@ HEARTBEAT_SECONDS = 2.0 # Bumped whenever the step vocabulary or the locate protocol changes. Reported in # the heartbeat so the dashboard can say "restart your runner" instead of letting # a stale process fail on a step type it has never heard of. -VERSION = "0.4.0" +VERSION = "0.9.0" # Shared with the heartbeat thread: whether a run is currently executing. _busy = threading.Event() @@ -55,11 +55,155 @@ def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None: stop.wait(HEARTBEAT_SECONDS) +def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | None = None) -> None: + """Dispatch a single step. Raises StepError / DashboardError on failure.""" + if step["action"] == "wait": + time.sleep(float(step.get("seconds", 1))) + report(f"waited {step.get('seconds', 1)}s") + + elif step["action"] == "navigate": + # No mouse involved: the extension points the tab at the page and waits + # for it to load. Locating confirms it is really there. + target = step.get("url", "") + landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url, + opts.timeout, step.get("openUrl", "") or "", + navigate_url=target) + # Navigating to a protected page is where an expired session shows up: + # the site answers with a redirect to its login form. + actions.check_signed_in(landed, step.get("signedOut", "") or "") + report(f"tab is on {landed.get('url', target)}") + + elif step["action"] == "waitFor": + # A gate, not an action: block until the element exists. Nothing is + # clicked or typed. Whatever has to make it appear — a person solving a + # challenge, a slow server, a background job — happens outside this run. + selector = step["selector"] + timeout_s = float(step.get("timeoutSeconds", 120)) + deadline = time.time() + timeout_s + announced = False + + while True: + # Long gates must still honour the dashboard's Stop button. + if run_id is not None and run_status(dash, run_id) == "cancelled": + raise actions.StepError("cancelled while waiting") + + try: + dash.locate(selector, int(step.get("index", 0)), + step.get("urlPattern", "") or opts.url, + opts.timeout, step.get("openUrl", "") or "") + report(f"{selector} appeared") + break + except DashboardError: + pass # not there yet, or the tab is mid-render + + if time.time() >= deadline: + raise actions.StepError( + f"{selector} did not appear within {timeout_s:.0f}s" + ) + if not announced: + report(f"waiting for {selector} (up to {timeout_s:.0f}s)") + announced = True + time.sleep(2.0) + + elif step["action"] not in ("click", "type"): + # Almost always a stale runner: the server defines the step vocabulary, + # so a step type this process has never heard of means automations.ts has + # moved on and this file has not been restarted. + raise actions.StepError( + f"unknown step action {step['action']!r} — this runner is " + f"v{VERSION}; restart it to pick up newer step types" + ) + + else: + actions.perform( + dash, + step["action"], + step["selector"], + index=int(step.get("index", 0)), + url=step.get("urlPattern", "") or opts.url, + open_url=step.get("openUrl", "") or "", + signed_out=step.get("signedOut", "") or "", + text=step.get("text"), + clear=bool(step.get("clear")), + scale=opts.scale, + timeout=opts.timeout, + rng=rng, + activate=not opts.no_activate, + dry_run=opts.dry_run, + report=report, + ) + + +def run_status(dash: Dashboard, run_id: int) -> str | None: + """Current status, so a wait can notice the dashboard's Stop button.""" + try: + return dash._request(f"/api/autobuyer/runs?id={run_id}")["run"]["status"] + except (DashboardError, KeyError): + return None + + +def wait_for_sign_in(dash: Dashboard, step: dict, run_id: int, opts, deadline: float) -> bool: + """Block until the tab leaves the login page. + + The runner does not log in — entering credentials is the human's job, and a + keystroke aimed at the wrong field is not something to risk automating. All + this does is watch the tab and pick the run back up once you are through. + """ + signed_out = step.get("signedOut", "") or "" + pattern = step.get("urlPattern", "") or opts.url + open_url = step.get("openUrl", "") or "" + + while time.time() < deadline: + if run_status(dash, run_id) == "cancelled": + return False + time.sleep(3.0) + try: + landed = dash.locate("body", 0, pattern, opts.timeout, open_url) + except DashboardError: + continue # tab busy mid-redirect; look again shortly + if signed_out not in (landed.get("url") or ""): + return True + + return False + + +def signed_in_now(dash: Dashboard, step: dict, opts, settle_seconds: float = 20.0) -> bool: + """Did we actually get back in? + + Auth steps can 'succeed' — every click landing — while the site still rejects + the sign-in, so this confirms rather than assumes before the failed step is + retried. + + It polls rather than checking once: submitting a login form starts a network + round trip and then a redirect, so the tab is still sitting on the login URL + for a second or two afterwards. Checking immediately reports a failure for a + sign-in that is merely still in flight. + """ + signed_out = step.get("signedOut", "") or "" + if not signed_out: + return True + + deadline = time.time() + settle_seconds + while True: + try: + landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url, + opts.timeout, step.get("openUrl", "") or "") + if signed_out not in (landed.get("url") or ""): + return True + except DashboardError: + pass # mid-redirect the tab can be briefly un-injectable + + if time.time() >= deadline: + return False + time.sleep(1.0) + + def run_steps(dash: Dashboard, run: dict, opts) -> None: """Work through one automation. Reports every step; stops on the first failure, because a half-completed purchase flow should not barrel on.""" run_id = run["id"] steps = run["steps"] + auth_steps = run.get("authSteps") or [] rng = random.Random(opts.seed) if opts.seed is not None else random.Random() print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)") @@ -74,49 +218,73 @@ def run_steps(dash: Dashboard, run: dict, opts) -> None: detail_parts.append(message) print(f" {message}") - try: - if step["action"] == "wait": - time.sleep(float(step.get("seconds", 1))) - report(f"waited {step.get('seconds', 1)}s") - elif step["action"] == "navigate": - # No mouse involved: the extension points the tab at the page and - # waits for it to load. Locating confirms it's really there. - target = step.get("url", "") - dash.locate("body", 0, step.get("urlPattern", "") or opts.url, - opts.timeout, step.get("openUrl", "") or "", - navigate_url=target) - report(f"tab is on {target}") - elif step["action"] not in ("click", "type"): - # Almost always a stale runner: the server defines the step - # vocabulary, so a step type this process has never heard of means - # automations.ts has moved on and this file hasn't been restarted. - raise actions.StepError( - f"unknown step action {step['action']!r} — this runner is " - f"v{VERSION}; restart it to pick up newer step types" - ) - else: - actions.perform( - dash, - step["action"], - step["selector"], - index=int(step.get("index", 0)), - url=step.get("urlPattern", "") or opts.url, - open_url=step.get("openUrl", "") or "", - text=step.get("text"), - clear=bool(step.get("clear")), - scale=opts.scale, - timeout=opts.timeout, - rng=rng, - activate=not opts.no_activate, - dry_run=opts.dry_run, - report=report, - ) - except (actions.StepError, DashboardError) as exc: - print(f" FAILED: {exc}", file=sys.stderr) - post_progress(dash, run_id, i, label, False, str(exc)) - finish(dash, run_id, str(exc)) - print(f"✗ run #{run_id} stopped at step {i + 1}") - return + # One retry after a sign-in: the session can only be expired once per step. + for attempt in range(2): + try: + run_one_step(dash, step, opts, rng, report, run_id) + break + + except actions.SignedOutError as exc: + wait_seconds = float(step.get("signedOutWait", 0) or 0) + + # Scripted auth first, if the firm defines any; otherwise fall + # back to pausing for a human. Only ever attempted once per step — + # auth that "succeeds" without signing in would otherwise loop. + if auth_steps and attempt == 0: + post_progress(dash, run_id, i, f"signed out — running {len(auth_steps)} auth step(s)", + False, exc.args[0]) + try: + for astep in auth_steps: + alabel = astep.get("label") or f"auth: {astep['action']}" + print(f" [auth] {alabel}") + run_one_step(dash, astep, opts, rng, report, run_id) + except (actions.StepError, DashboardError) as aexc: + msg = f"auth steps failed: {aexc}" + post_progress(dash, run_id, i, label, False, msg) + finish(dash, run_id, msg) + print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr) + return + + if not signed_in_now(dash, step, opts): + msg = "auth steps ran but the session is still signed out" + post_progress(dash, run_id, i, label, False, msg) + finish(dash, run_id, msg) + print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr) + return + + report("signed in via auth steps — retrying") + continue + + if wait_seconds <= 0 or attempt > 0: + print(f" FAILED: {exc}", file=sys.stderr) + post_progress(dash, run_id, i, label, False, str(exc)) + finish(dash, run_id, str(exc)) + print(f"✗ run #{run_id} stopped at step {i + 1}") + return + + mins = wait_seconds / 60 + note = f"signed out — waiting up to {mins:.0f} min for you to log in" + print(f" {note}", file=sys.stderr) + post_progress(dash, run_id, i, note, False, exc.args[0]) + + if not wait_for_sign_in(dash, step, run_id, opts, time.time() + wait_seconds): + if run_status(dash, run_id) == "cancelled": + print(f"■ run #{run_id} cancelled while waiting for sign-in") + return + msg = f"still signed out after {mins:.0f} min" + post_progress(dash, run_id, i, label, False, msg) + finish(dash, run_id, msg) + print(f"✗ run #{run_id} gave up waiting for sign-in") + return + + report("signed in — retrying the step") + + except (actions.StepError, DashboardError) as exc: + print(f" FAILED: {exc}", file=sys.stderr) + post_progress(dash, run_id, i, label, False, str(exc)) + finish(dash, run_id, str(exc)) + print(f"✗ run #{run_id} stopped at step {i + 1}") + return status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None) diff --git a/lib/automations.ts b/lib/automations.ts index e2f6996..c9cf205 100644 --- a/lib/automations.ts +++ b/lib/automations.ts @@ -16,7 +16,10 @@ export type AutomationStep = | { action: 'wait'; seconds: number; label?: string } // Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped // when the tab is already there, so it doesn't reload and lose page state. - | { action: 'navigate'; url?: string; label?: string }; + | { action: 'navigate'; url?: string; label?: string } + // Block until `selector` exists, then carry on. Nothing is clicked or typed — + // this is a gate, for conditions something outside the run has to satisfy. + | { action: 'waitFor'; selector: string; index?: number; timeoutSeconds?: number; label?: string }; export interface Automation { id: string; @@ -32,6 +35,8 @@ export interface Automation { export type ResolvedStep = AutomationStep & { urlPattern?: string; openUrl?: string; + signedOut?: string; + signedOutWait?: number; }; export interface Firm { @@ -43,6 +48,27 @@ export interface Firm { /** Concrete page to open when no tab matches `urlPattern`. A match pattern * can't be navigated to, so this has to be spelled out separately. */ url: string; + /** Substring identifying the signed-out page. When a session expires the site + * redirects here, and every subsequent selector resolves against a login form + * — so a run that lands on it must stop rather than click through it. */ + signedOutPattern: string; + /** Seconds to pause and let a human sign in when a run hits the login page. + * 0 aborts instead. Used only when `authSteps` is empty. */ + signedOutWaitSeconds: number; + /** Steps run when a run lands on the login page, before retrying the step + * that hit it. Left empty here on purpose — fill it in yourself. + * + * Two things to know if you do: + * - These run *while on the signed-out page*, so unlike normal steps they + * carry no signed-out guard. Nothing stops them clicking around a login + * form; that is the point, and also why a wrong selector here is worse + * than elsewhere. + * - Anything written here lives in this file in plain text, and this file + * is in the repo. + * + * While empty, a run that hits the login page falls back to pausing for + * `signedOutWaitSeconds` so you can sign in by hand. */ + authSteps: AutomationStep[]; automations: Automation[]; } @@ -52,6 +78,11 @@ export const FIRMS: Firm[] = [ label: 'Tradeify', urlPattern: 'https://app-f.tradeify.co/*', url: 'https://app-f.tradeify.co/', + signedOutPattern: '/auth/', + signedOutWaitSeconds: 300, + authSteps: [ + { action: 'click', selector: 'form > div > div:last-child button', label: 'Open Add Account' }, + ], automations: [ { id: 'buy-accounts', @@ -69,8 +100,18 @@ export const FIRMS: Firm[] = [ // every site build, so they are not safe to select on. { action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' }, { action: 'wait', seconds: 2, label: 'Wait for the form' }, + { action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'}, + { action: 'click', selector: 'div.account_types:nth-child(7) span:last-child'}, + { action: 'click', selector: 'div.summary_section div.MuiTextField-root input'}, + { action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'}, + { action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'}, + { action: 'click', selector: 'div.captcha-solver[data-state="ready"]'}, + { action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, + label: 'Wait for the challenge' }, + { action: 'click', selector: 'div.summary_section > button:last-child', label: 'Open Add Account' }, // TODO: the rest of the purchase flow. Confirm each selector with // `clicker.py locate` before adding it here. + ], }, ], @@ -101,10 +142,42 @@ export function findAutomation(key: string): { firm: Firm; automation: Automatio * runner never has to know which firm it is working on. */ export function resolveSteps(firm: Firm, automation: Automation): ResolvedStep[] { return automation.steps.map((step) => { + if (step.action === 'wait') return step; + if (step.action === 'navigate') { + return { + ...step, + url: step.url ?? firm.url, + urlPattern: firm.urlPattern, + openUrl: firm.url, + signedOut: firm.signedOutPattern, + signedOutWait: firm.signedOutWaitSeconds, + }; + } + if (step.action === 'waitFor') { + return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url }; + } + return { + ...step, + urlPattern: step.urlPattern ?? firm.urlPattern, + openUrl: firm.url, + signedOut: firm.signedOutPattern, + signedOutWait: firm.signedOutWaitSeconds, + }; + }); +} + +/** Auth steps run on the login page itself, so they get the firm's tab pattern + * but deliberately no `signedOut` guard — that guard exists to stop ordinary + * steps acting on a login form, and these are the exception. */ +export function resolveAuthSteps(firm: Firm): ResolvedStep[] { + return firm.authSteps.map((step) => { if (step.action === 'wait') return step; if (step.action === 'navigate') { return { ...step, url: step.url ?? firm.url, urlPattern: firm.urlPattern, openUrl: firm.url }; } + if (step.action === 'waitFor') { + return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url }; + } return { ...step, urlPattern: step.urlPattern ?? firm.urlPattern, openUrl: firm.url }; }); } @@ -117,5 +190,6 @@ export function describeStep(step: AutomationStep): string { case 'type': return `type into ${step.selector}`; case 'wait': return `wait ${step.seconds}s`; case 'navigate': return `open ${step.url ?? 'the firm page'}`; + case 'waitFor': return `wait for ${step.selector}`; } } diff --git a/lib/db.ts b/lib/db.ts index 9c3623b..f864bfb 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -565,7 +565,7 @@ export const RUNNER_TIMEOUT_MS = 7000; /** The runner version this server's step vocabulary requires. A running process * doesn't reload when the source changes, so an older one silently fails on * steps it predates — the dashboard warns instead. */ -export const RUNNER_EXPECTED_VERSION = '0.4.0'; +export const RUNNER_EXPECTED_VERSION = '0.9.0'; export interface RunnerHeartbeat { at: number;