diff --git a/app/api/autobuyer/automations/route.ts b/app/api/autobuyer/automations/route.ts new file mode 100644 index 0000000..118c673 --- /dev/null +++ b/app/api/autobuyer/automations/route.ts @@ -0,0 +1,53 @@ +import { NextRequest } from 'next/server'; +import { FIRMS, findAutomation, automationKey, describeStep } from '@/lib/automations'; +import { createRun, getSetting } from '@/lib/db'; +import { corsJson, corsPreflight } from '../cors'; + +/** The dashboard renders a tab per firm, and a button per automation inside it. */ +export async function GET() { + return corsJson({ + firms: FIRMS.map((firm) => ({ + id: firm.id, + label: firm.label, + urlPattern: firm.urlPattern, + automations: firm.automations.map((a) => ({ + key: automationKey(firm.id, a.id), + id: a.id, + label: a.label, + description: a.description, + confirm: a.confirm ?? null, + steps: a.steps.map(describeStep), + })), + })), + }); +} + +/** Queue a run. The Python runner picks it up. */ +export async function POST(req: NextRequest) { + try { + const body = await req.json() as { automationId?: unknown }; + if (typeof body.automationId !== 'string') { + return corsJson({ error: '`automationId` is required' }, { status: 400 }); + } + const found = findAutomation(body.automationId); + if (!found) { + return corsJson({ error: `No automation "${body.automationId}"` }, { status: 404 }); + } + if (found.automation.steps.length === 0) { + return corsJson({ error: `"${found.automation.label}" has no steps yet` }, { status: 400 }); + } + // The dashboard switch is the master arm for anything that drives the mouse. + if (getSetting('autobuyer_enabled') !== '1') { + return corsJson({ error: 'AutoBuyer is switched off' }, { status: 409 }); + } + + const run = createRun(body.automationId, found.automation.steps.length); + return corsJson({ runId: run.id, totalSteps: run.total_steps }); + } catch (err: any) { + return corsJson({ error: err?.message ?? 'Failed to queue run' }, { status: 500 }); + } +} + +export async function OPTIONS() { + return corsPreflight(); +} diff --git a/app/api/autobuyer/locate/claim/route.ts b/app/api/autobuyer/locate/claim/route.ts index 6646a1a..006bb9c 100644 --- a/app/api/autobuyer/locate/claim/route.ts +++ b/app/api/autobuyer/locate/claim/route.ts @@ -12,6 +12,8 @@ export async function POST() { selector: row.selector, index: row.match_index, urlPattern: row.url_pattern, + openUrl: row.open_url, + navigateUrl: row.navigate_url, }, }); } diff --git a/app/api/autobuyer/locate/route.ts b/app/api/autobuyer/locate/route.ts index f6426bb..98fccbb 100644 --- a/app/api/autobuyer/locate/route.ts +++ b/app/api/autobuyer/locate/route.ts @@ -5,14 +5,16 @@ import { corsJson, corsPreflight } from '../cors'; /** Python enqueues "find this selector and tell me where it is on screen". */ export async function POST(req: NextRequest) { try { - const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown }; + const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown; openUrl?: unknown; navigateUrl?: unknown }; if (typeof body.selector !== 'string' || !body.selector.trim()) { return corsJson({ error: '`selector` is required' }, { status: 400 }); } const index = Number.isInteger(body.index) ? Number(body.index) : 0; const urlPattern = typeof body.urlPattern === 'string' ? body.urlPattern : ''; + const openUrl = typeof body.openUrl === 'string' ? body.openUrl : ''; + const navigateUrl = typeof body.navigateUrl === 'string' ? body.navigateUrl : ''; - const row = createLocateRequest(body.selector.trim(), index, urlPattern); + const row = createLocateRequest(body.selector.trim(), index, urlPattern, openUrl, navigateUrl); return corsJson({ id: row.id, status: row.status }); } catch (err: any) { return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 }); diff --git a/app/api/autobuyer/runner/route.ts b/app/api/autobuyer/runner/route.ts new file mode 100644 index 0000000..5656f04 --- /dev/null +++ b/app/api/autobuyer/runner/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from 'next/server'; +import { setRunnerHeartbeat, getRunnerHeartbeat, RUNNER_TIMEOUT_MS, RUNNER_EXPECTED_VERSION } from '@/lib/db'; +import { corsJson, corsPreflight } from '../cors'; + +/** The runner checks in. Timestamped server-side so a runner with a skewed clock + * doesn't read as permanently offline (or permanently online). */ +export async function POST(req: NextRequest) { + try { + const body = await req.json().catch(() => ({})) as Record; + setRunnerHeartbeat({ + host: typeof body.host === 'string' ? body.host : undefined, + pid: typeof body.pid === 'number' ? body.pid : undefined, + dryRun: body.dryRun === true, + version: typeof body.version === 'string' ? body.version : undefined, + busy: body.busy === true, + }); + return corsJson({ ok: true }); + } catch (err: any) { + return corsJson({ error: err?.message ?? 'Failed to record heartbeat' }, { status: 500 }); + } +} + +export async function GET() { + const hb = getRunnerHeartbeat(); + if (!hb) return corsJson({ online: false, lastSeen: null, ageMs: null }); + + const ageMs = Date.now() - hb.at; + return corsJson({ + online: ageMs < RUNNER_TIMEOUT_MS, + lastSeen: hb.at, + ageMs, + host: hb.host ?? null, + pid: hb.pid ?? null, + dryRun: !!hb.dryRun, + busy: !!hb.busy, + version: hb.version ?? null, + expectedVersion: RUNNER_EXPECTED_VERSION, + stale: (hb.version ?? '') !== RUNNER_EXPECTED_VERSION, + }); +} + +export async function OPTIONS() { + return corsPreflight(); +} diff --git a/app/api/autobuyer/runs/claim/route.ts b/app/api/autobuyer/runs/claim/route.ts new file mode 100644 index 0000000..f028a6f --- /dev/null +++ b/app/api/autobuyer/runs/claim/route.ts @@ -0,0 +1,33 @@ +import { claimRun, getSetting } from '@/lib/db'; +import { findAutomation, resolveSteps } from '@/lib/automations'; +import { corsJson, corsPreflight } from '../../cors'; + +/** The Python runner polls this. Returns the run plus the steps to execute, so + * the runner never needs its own copy of the automation definitions. */ +export async function POST() { + if (getSetting('autobuyer_enabled') !== '1') { + return corsJson({ run: null, reason: 'AutoBuyer is switched off' }); + } + + const row = claimRun(); + if (!row) return corsJson({ run: null }); + + const found = findAutomation(row.automation_id); + if (!found) { + return corsJson({ run: null, reason: `Unknown automation ${row.automation_id}` }); + } + + return corsJson({ + run: { + id: row.id, + automationId: row.automation_id, + firm: found.firm.label, + label: found.automation.label, + steps: resolveSteps(found.firm, found.automation), + }, + }); +} + +export async function OPTIONS() { + return corsPreflight(); +} diff --git a/app/api/autobuyer/runs/progress/route.ts b/app/api/autobuyer/runs/progress/route.ts new file mode 100644 index 0000000..36d944b --- /dev/null +++ b/app/api/autobuyer/runs/progress/route.ts @@ -0,0 +1,48 @@ +import { NextRequest } from 'next/server'; +import { appendRunLog, finishRun, getRun } from '@/lib/db'; +import { corsJson, corsPreflight } from '../../cors'; + +interface ProgressBody { + id?: unknown; + stepIndex?: unknown; + step?: unknown; + ok?: unknown; + detail?: unknown; + /** Present only on the final call. */ + finish?: unknown; + error?: unknown; +} + +/** The runner reports each step, then a final finish. */ +export async function POST(req: NextRequest) { + try { + const body = await req.json() as ProgressBody; + const id = Number(body.id); + const row = getRun(id); + if (!row) return corsJson({ error: 'No such run' }, { status: 404 }); + + if (typeof body.step === 'string') { + appendRunLog(id, { + at: Date.now(), + step: body.step, + ok: body.ok !== false, + detail: typeof body.detail === 'string' ? body.detail : undefined, + }, Number(body.stepIndex) || row.step_index); + } + + if (body.finish === true) { + const error = typeof body.error === 'string' && body.error ? body.error : null; + finishRun(id, error ? 'error' : 'done', error); + } + + // The runner reads this to notice the Stop button between steps. + const now = getRun(id); + return corsJson({ ok: true, status: now?.status ?? 'error' }); + } catch (err: any) { + return corsJson({ error: err?.message ?? 'Failed to record progress' }, { status: 500 }); + } +} + +export async function OPTIONS() { + return corsPreflight(); +} diff --git a/app/api/autobuyer/runs/route.ts b/app/api/autobuyer/runs/route.ts new file mode 100644 index 0000000..1cbe01c --- /dev/null +++ b/app/api/autobuyer/runs/route.ts @@ -0,0 +1,43 @@ +import { NextRequest } from 'next/server'; +import { getRun, getRecentRuns, cancelRun } from '@/lib/db'; +import { findAutomation } from '@/lib/automations'; +import { corsJson, corsPreflight } from '../cors'; + +function shape(row: NonNullable>) { + return { + id: row.id, + automationId: row.automation_id, + automationLabel: findAutomation(row.automation_id)?.automation.label ?? row.automation_id, + status: row.status, + stepIndex: row.step_index, + totalSteps: row.total_steps, + log: (() => { try { return JSON.parse(row.log); } catch { return []; } })(), + error: row.error, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +/** `?id=` for one run, otherwise the recent history the dashboard shows. */ +export async function GET(req: NextRequest) { + const idParam = req.nextUrl.searchParams.get('id'); + if (idParam) { + const row = getRun(Number(idParam)); + if (!row) return corsJson({ error: 'No such run' }, { status: 404 }); + return corsJson({ run: shape(row) }); + } + return corsJson({ runs: getRecentRuns(5).map(shape) }); +} + +/** Stop button. A queued run never starts; a running one halts at the next step. */ +export async function DELETE(req: NextRequest) { + const id = Number(req.nextUrl.searchParams.get('id')); + if (!Number.isInteger(id) || id <= 0) { + return corsJson({ error: '`id` is required' }, { status: 400 }); + } + return corsJson({ cancelled: cancelRun(id) }); +} + +export async function OPTIONS() { + return corsPreflight(); +} diff --git a/app/autobuyer/page.tsx b/app/autobuyer/page.tsx index f8756ee..3cf7540 100644 --- a/app/autobuyer/page.tsx +++ b/app/autobuyer/page.tsx @@ -34,6 +34,64 @@ interface Capture { capturedAt: number; } +interface AutomationSummary { + /** firm:automation — what runs are recorded against. */ + key: string; + id: string; + label: string; + description: string; + confirm: string | null; + steps: string[]; +} + +interface FirmSummary { + id: string; + label: string; + urlPattern: string; + automations: AutomationSummary[]; +} + +interface RunLogEntry { + at: number; + step: string; + ok: boolean; + detail?: string; +} + +interface Run { + id: number; + automationId: string; + automationLabel: string; + status: 'queued' | 'running' | 'done' | 'error' | 'cancelled'; + stepIndex: number; + totalSteps: number; + log: RunLogEntry[]; + error: string | null; + createdAt: number; + updatedAt: number; +} + +interface RunnerStatus { + online: boolean; + lastSeen: number | null; + ageMs: number | null; + host?: string | null; + pid?: number | null; + dryRun?: boolean; + busy?: boolean; + version?: string | null; + expectedVersion?: string; + stale?: boolean; +} + +const RUN_BADGE: Record = { + queued: 'bg-slate-100 text-slate-600', + running: 'bg-blue-100 text-blue-700', + done: 'bg-green-100 text-green-700', + error: 'bg-red-100 text-red-700', + cancelled: 'bg-amber-100 text-amber-700', +}; + const POLL_MS = 2000; /** Point relative URLs (stylesheets, images, fonts) at the origin the snapshot came @@ -57,6 +115,11 @@ export default function AutoBuyer() { // reload the frame that often. So the rendered view shows a snapshot pinned when you // opened it — clicking Rendered again re-pins to the latest capture. const [pinned, setPinned] = useState(null); + const [firms, setFirms] = useState([]); + const [activeFirm, setActiveFirm] = useState(null); + const [runs, setRuns] = useState([]); + const [busyRun, setBusyRun] = useState(false); + const [runner, setRunner] = useState(null); // Tracks the newest capture we already hold, so the poll can skip re-downloading it. const lastAtRef = useRef(0); @@ -87,6 +150,67 @@ export default function AutoBuyer() { return () => clearInterval(id); }, [poll]); + useEffect(() => { + fetch('/api/autobuyer/automations') + .then((r) => r.json()) + .then((d) => { + const list: FirmSummary[] = d.firms ?? []; + setFirms(list); + setActiveFirm((current) => current ?? list[0]?.id ?? null); + }) + .catch(() => {}); + }, []); + + // Runs tick faster than the capture poll — a run is something you watch. + const pollRuns = useCallback(async () => { + try { + const [d, r] = await Promise.all([ + fetch('/api/autobuyer/runs', { cache: 'no-store' }).then((x) => x.json()), + fetch('/api/autobuyer/runner', { cache: 'no-store' }).then((x) => x.json()), + ]); + setRuns(d.runs ?? []); + setRunner(r); + } catch { /* transient; the next tick retries */ } + }, []); + + useEffect(() => { + pollRuns(); + const id = setInterval(pollRuns, 1000); + return () => clearInterval(id); + }, [pollRuns]); + + const activeRun = runs.find((r) => r.status === 'queued' || r.status === 'running') ?? null; + const firmAutomations = firms.find((f) => f.id === activeFirm)?.automations ?? []; + + async function startRun(automation: AutomationSummary) { + // Anything that drives the real mouse against a broker gets a confirmation. + if (automation.confirm && !window.confirm( + `${automation.confirm}\n\nSteps:\n${automation.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}` + )) return; + + setBusyRun(true); + try { + const res = await fetch('/api/autobuyer/automations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ automationId: automation.key }), + }); + const data = await res.json(); + if (!res.ok) setError(data.error ?? 'Failed to start'); + else setError(null); + await pollRuns(); + } catch (err: any) { + setError(err?.message ?? 'Failed to start'); + } finally { + setBusyRun(false); + } + } + + async function cancelRun(id: number) { + await fetch(`/api/autobuyer/runs?id=${id}`, { method: 'DELETE' }); + await pollRuns(); + } + async function toggle() { setBusy(true); try { @@ -153,6 +277,158 @@ export default function AutoBuyer() { )} + {/* ── One tab per firm ── */} +
+ {firms.map((firm) => ( + + ))} + + + + {!runner?.online + ? '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 && ( + + dry run + + )} +
+
+ + {!runner?.online && ( +
+ Nothing will run until the runner is started — it's the process that + moves the mouse. In the project directory:{' '} + python clicker/runner.py +
+ )} + + {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 && ( +
+ The runner is in dry-run mode: it will walk through the steps and move the + cursor, but never press or type. Restart it without --dry-run to act for real. +
+ )} + +
+ {firmAutomations.length === 0 ? ( +
+ {firms.length === 0 ? 'No firms configured' : 'No automations for this firm yet'} +
+ ) : firmAutomations.map((a, i) => ( +
+
+

{a.label}

+

+ {a.description} · {a.steps.length} step{a.steps.length === 1 ? '' : 's'} +

+
+ +
+ ))} +
+ + {/* ── Run status ── */} + {runs.length > 0 && ( + <> +

+ Runs +

+
+ {runs.map((run) => ( +
+
+ + {run.status} + + {run.automationLabel} + + step {Math.min(run.stepIndex + (run.status === 'done' ? 0 : 1), run.totalSteps)}/{run.totalSteps} + + + {new Date(run.createdAt).toLocaleTimeString()} + + {(run.status === 'queued' || run.status === 'running') && ( + + )} +
+ {run.log.length > 0 && ( +
    + {run.log.map((entry, i) => ( +
  1. + + {entry.ok ? '✓' : '✗'} + {' '} + {entry.step} + {entry.detail && — {entry.detail}} +
  2. + ))} +
+ )} + {run.error && ( +

{run.error}

+ )} +
+ ))} +
+ + )} + {/* ── Latest capture ── */}

Latest capture diff --git a/clicker/README.md b/clicker/README.md index 15e5240..77d6f69 100644 --- a/clicker/README.md +++ b/clicker/README.md @@ -16,6 +16,27 @@ clicker --GET /api/autobuyer/locate?id=---> reads the answer clicker moves the mouse, clicks ``` +## Two ways to run it + +**`clicker.py`** — one action at a time, from the shell. Use this to find +selectors and confirm coordinates before wiring anything up. + +**`runner.py`** — the daemon behind the dashboard's buttons. Leave it running; it +polls for queued runs and executes the steps. + +```bash +python runner.py # then press a button on the AutoBuyer page +python runner.py --dry-run # walks the steps, never presses or types +``` + +Automations are defined in `lib/automations.ts`, and the steps are sent to the +runner by the server — so adding a button means editing that file, and nothing in +the runner or the page changes. Each run reports step-by-step progress back to the +dashboard, and the page's Stop button halts a run between steps. + +Only one run executes at a time: two processes driving the same physical mouse +would interleave clicks. + ## Setup ```bash @@ -41,6 +62,12 @@ python clicker.py click "button.buy" --dry-run # Actually click. python clicker.py click "button.buy" +# Type into a field: clicks it to place the caret, then types. +python clicker.py type 'input[name="quantity"]' --text "5000" --clear + +# Keep the text out of shell history. +echo "5000" | python clicker.py type 'input#qty' --stdin --clear + # Pin it to a specific tab, and pick the 3rd match. python clicker.py click ".trade-btn" --index 2 --url "https://tradeify.co/*" ``` @@ -57,6 +84,46 @@ python clicker.py click ".trade-btn" --index 2 --url "https://tradeify.co/*" | `--robotic` | Straight-line move and instant click, skipping the motion model. | | `--seed` | Seed the motion RNG so a run replays identically (debugging). | | `--no-activate` | Don't raise the browser first. The click may then be swallowed. | +| `--text` | Text to type (action `type`). | +| `--stdin` | Read the text from stdin instead, keeping it out of shell history. | +| `--clear` | Select-all and delete before typing, instead of appending at the caret. | +| `--allow-enter` | Permit newlines. Each one presses Enter, which may submit the form. | +| `--no-verify` | Skip reading the field back after typing. | + +## Typing + +`type` clicks the field to place the caret, then types with an uneven human +cadence (45–130ms between keys, a beat after each space, an occasional longer +pause). Real OS-level keystrokes are also the *correct* way to fill a React form: +setting `.value` directly is ignored by controlled components, while genuine key +events are not. + +Four guards, all of which catch silent failures: + +- **Non-ASCII is rejected.** `pyautogui.write()` has no keycode for `é` or `£` and + skips them without complaint, which would leave a quietly truncated value in the + field. Better to refuse than to submit `caf` where you meant `café`. +- **Newlines are rejected** unless `--allow-enter`, because Enter may submit the + form — and on this site that could mean placing an order. +- **Non-editable targets are refused** — disabled, read-only, or simply not an + input. The keystrokes would go nowhere. +- **The field is read back afterwards** and compared against what was typed + (`--no-verify` skips it). A field that never took focus, or that ignored the + input, otherwise looks exactly like success. Exit code 4 means the text did not + land. + +`--clear` selects-all and deletes first. Without it the text is inserted at +wherever the caret landed, which for a field with existing contents usually +produces something like `50005000`. + +Typos are deliberately *not* simulated. A mistyped digit in a trading form is a +real loss, and the backspace-and-correct step is exactly the part that can go +wrong — a field with input masking or autocomplete can swallow the correction and +leave the wrong number behind. + +Don't pass credentials via `--text`: it lands in your shell history and in the +process list. `--stdin` avoids both, but nothing here is built to handle secrets +safely. ## Window focus diff --git a/clicker/actions.py b/clicker/actions.py new file mode 100644 index 0000000..31ec81e --- /dev/null +++ b/clicker/actions.py @@ -0,0 +1,174 @@ +"""Execution of a single automation step: locate the element, then click or type. + +Shared by the CLI (clicker.py) and the automation runner (runner.py) so there is +one implementation of the safety checks. Callers differ only in how they report +progress and how they surface failures. +""" + +import random + +import focus + + +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. + """ + + def __init__(self, message: str, code: int = 3): + super().__init__(message) + self.code = code + + +# 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. +KNOWN_SCALES = (1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0) + + +def scale_factor(found: dict, override: float | None, report=None) -> float: + """CSS pixels and desktop pixels match on macOS and unscaled Windows, but + Windows display scaling breaks that. + + Deliberately conservative: a raw ratio is only trusted when it lands on a real + scaling factor. On a multi-monitor desktop the browser reports the display it + is on while pyautogui reports the primary one, and the resulting ratio is + meaningless — halving every coordinate would put clicks far from the target. + Fall back to 1:1 and say so, rather than silently scaling. + """ + if override is not None: + return override + + try: + import pyautogui + except ImportError: + return 1.0 + + css_width = (found.get("screenSize") or {}).get("width") + if not css_width: + return 1.0 + + os_width = pyautogui.size().width + ratio = os_width / css_width + + for known in KNOWN_SCALES: + if abs(ratio - known) < 0.02: + return known + + if report: + report( + f"screen size disagrees (browser {css_width}px, OS {os_width}px, " + f"ratio {ratio:.3f}) — assuming 1:1. If clicks land off, pass --scale." + ) + return 1.0 + + +def perform( + dash, + action: str, + selector: str, + *, + index: int = 0, + url: str = "", + open_url: str = "", + text: str | None = None, + clear: bool = False, + scale: float | None = None, + timeout: float = 20.0, + rng: random.Random | None = None, + activate: bool = True, + verify: bool = True, + force: bool = False, + dry_run: bool = False, + robotic: bool = False, + report=None, + on_located=None, +) -> dict: + """Run one step. Returns the located element's data. + + Raises DashboardError if the extension can't find it, StepError if the step + is refused or fails verification. + """ + import pyautogui + import humanize + + rng = rng or random.Random() + say = report or (lambda _m: None) + + found = dash.locate(selector, index, url, timeout, open_url) + if on_located: + on_located(found) + + if found.get("covered") and not force: + raise StepError( + f"{selector} is covered by {found.get('coveredBy')} — the click would hit that instead" + ) + + if action == "type" and not found.get("editable", True) and not force: + kind = found.get("inputType") + raise StepError( + f"<{found['tag']}{f' type={kind}' if kind else ''}> is not an editable field " + "(disabled, read-only, or not an input)" + ) + + factor = scale_factor(found, scale, report) + x = found["screen"]["x"] * factor + y = found["screen"]["y"] * factor + + screen_w, screen_h = pyautogui.size() + if not (0 <= x < screen_w and 0 <= y < screen_h): + raise StepError( + f"target ({x:.0f}, {y:.0f}) is off-screen ({screen_w}x{screen_h}) — " + "is the Chrome window on another display?" + ) + + say(f"at {x:.0f},{y:.0f}") + + # A click on a background window is consumed activating it and never reaches + # the page, so raise the browser immediately before pressing. + if activate: + focused = focus.ensure_frontmost() + say(focused.detail) + if not focused.ok: + raise StepError( + "the browser is not frontmost — the click would be consumed " + "activating its window instead of pressing the element" + ) + + pyautogui.FAILSAFE = True + + if robotic: + pyautogui.moveTo(x, y, duration=0.25) + if not dry_run: + pyautogui.click() + else: + humanize.click(x, y, rng=rng, press=not dry_run) + + if action == "click" or dry_run: + return found + + # ── type ──────────────────────────────────────────────────────────────── + import time + time.sleep(0.15) # let the field take focus and any JS handlers settle + + if clear: + humanize.clear_field(rng) + humanize.type_text(text or "", rng) + + if not verify: + return found + + # 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) + got = after.get("value") + if got is None: + say("element exposes no value to verify against") + return found + if (text or "") in got: + say(f"verified, field contains {got!r}") + return after + + raise StepError(f"verification failed — field contains {got!r}", code=4) diff --git a/clicker/clicker.py b/clicker/clicker.py index 534cf87..2569b16 100644 --- a/clicker/clicker.py +++ b/clicker/clicker.py @@ -27,6 +27,7 @@ import time import urllib.error import urllib.request +import actions import focus DEFAULT_API = "http://localhost:3000" @@ -64,12 +65,18 @@ class Dashboard: def status(self) -> dict: return self._request("/api/autobuyer/status") - def locate(self, selector: str, index: int, url_pattern: str, timeout: float) -> dict: - """Queue a lookup and block until the extension answers it.""" + def locate(self, selector: str, index: int, url_pattern: str, timeout: float, + open_url: str = "", navigate_url: str = "") -> dict: + """Queue a lookup and block until the extension answers it. + + `open_url` is the page the extension should open if no tab matches + `url_pattern` — a match pattern isn't navigable, so it's passed separately. + """ queued = self._request( "/api/autobuyer/locate", "POST", - {"selector": selector, "index": index, "urlPattern": url_pattern}, + {"selector": selector, "index": index, "urlPattern": url_pattern, + "openUrl": open_url, "navigateUrl": navigate_url}, ) request_id = queued["id"] @@ -88,28 +95,6 @@ class Dashboard: ) -def scale_factor(found: dict, override: float | None) -> float: - """CSS pixels and desktop pixels are the same on macOS and on unscaled Windows, - but Windows display scaling and some Linux setups break that. Compare the - screen size the browser reports against the one the OS reports.""" - if override is not None: - return override - - try: - import pyautogui - except ImportError: - return 1.0 # `locate` is useful without the mouse library installed - - os_width = pyautogui.size().width - css_width = (found.get("screenSize") or {}).get("width") - if not css_width: - return 1.0 - ratio = os_width / css_width - # Only trust a clean-ish ratio; anything odd means a multi-monitor layout we - # shouldn't guess at, so fall back to 1:1 and let --scale override. - return ratio if 0.4 < ratio < 4.0 else 1.0 - - def describe(found: dict, factor: float) -> str: x, y = found["screen"]["x"] * factor, found["screen"]["y"] * factor lines = [ @@ -127,13 +112,15 @@ def describe(found: dict, factor: float) -> str: def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("action", choices=["locate", "click"], - help="locate = measure only; click = measure then click") + parser.add_argument("action", choices=["locate", "click", "type"], + help="locate = measure only; click = measure then click; " + "type = click the field, then type into it") parser.add_argument("selector", help="CSS selector of the target element") 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("--api", default=DEFAULT_API, help=f"dashboard URL (default {DEFAULT_API})") - parser.add_argument("--timeout", type=float, default=20.0, help="seconds to wait for the extension (default 20)") + 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)") parser.add_argument("--dry-run", action="store_true", help="move the cursor to the target but do not press") parser.add_argument("--force", action="store_true", help="click even if something is covering the element") @@ -143,79 +130,113 @@ def main() -> int: help="seed the motion RNG so a run is reproducible (for debugging)") parser.add_argument("--no-activate", action="store_true", help="do not raise the browser first (the click may be eaten by window activation)") + parser.add_argument("--text", default=None, help="text to type (action=type)") + parser.add_argument("--stdin", action="store_true", + help="read the text to type from stdin instead of --text, keeping it out of shell history") + parser.add_argument("--clear", action="store_true", + help="select-all and delete before typing, rather than appending at the caret") + parser.add_argument("--allow-enter", action="store_true", + help="permit newlines in the text (each one presses Enter, which may submit the form)") + parser.add_argument("--no-verify", action="store_true", + help="skip reading the field back after typing") args = parser.parse_args() + # Anything that moves the mouse or presses a key needs pyautogui; check once, + # up front, so a missing dependency reports itself rather than surfacing as an + # ImportError from somewhere deeper. + if args.action in ("click", "type"): + try: + import pyautogui # noqa: F401 + except ImportError: + print("error: pyautogui is not installed — run: pip install -r requirements.txt", + file=sys.stderr) + return 1 + + # ── resolve and vet the text before touching anything ─────────────────── + text = "" + if args.action == "type": + if args.stdin: + text = sys.stdin.read() + elif args.text is not None: + text = args.text + else: + print("error: type needs --text or --stdin", file=sys.stderr) + return 1 + if not text: + print("error: nothing to type", file=sys.stderr) + return 1 + + import humanize as _h + bad = _h.untypeable(text) + if bad: + print(f"error: pyautogui cannot type {''.join(bad)!r} and would silently " + f"skip those characters, leaving a truncated value in the field.", + file=sys.stderr) + return 1 + if "\n" in text and not args.allow_enter: + print("error: the text contains a newline, which presses Enter and may " + "submit the form. Pass --allow-enter if that is intended.", file=sys.stderr) + return 1 + dash = Dashboard(args.api) try: if not dash.status().get("enabled"): print("AutoBuyer capture is OFF — turn it on from the dashboard first.", file=sys.stderr) return 2 - - print(f"Locating {args.selector!r} …", flush=True) - found = dash.locate(args.selector, args.index, args.url, args.timeout) except DashboardError as exc: print(f"error: {exc}", file=sys.stderr) return 1 + print(f"Locating {args.selector!r} …", flush=True) + + # `locate` never touches the mouse, so it stays a plain lookup. if args.action == "locate": - print(describe(found, scale_factor(found, args.scale))) + try: + found = dash.locate(args.selector, args.index, args.url, args.timeout, args.open_url) + except DashboardError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(describe(found, actions.scale_factor(found, args.scale))) return 0 - if found.get("covered") and not args.force: - print(describe(found, 1.0)) - print("\nRefusing to click: the element is covered — the click would hit " - f"{found['coveredBy']} instead. Pass --force to click anyway.", file=sys.stderr) - return 3 + rng = random.Random(args.seed) if args.seed is not None else random.Random() try: - import pyautogui - except ImportError: - print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr) + actions.perform( + dash, + args.action, + args.selector, + index=args.index, + url=args.url, + open_url=args.open_url, + text=text, + clear=args.clear, + scale=args.scale, + timeout=args.timeout, + rng=rng, + activate=not args.no_activate, + verify=not args.no_verify, + force=args.force, + dry_run=args.dry_run, + robotic=args.robotic, + on_located=lambda f: print(describe(f, actions.scale_factor(f, args.scale))), + report=lambda m: print(f" {m}"), + ) + except DashboardError as exc: + print(f"error: {exc}", file=sys.stderr) return 1 - - # Slamming the cursor into a screen corner aborts the script. - pyautogui.FAILSAFE = True - - factor = scale_factor(found, args.scale) - x = found["screen"]["x"] * factor - y = found["screen"]["y"] * factor - - screen_w, screen_h = pyautogui.size() - if not (0 <= x < screen_w and 0 <= y < screen_h): - print(describe(found, factor)) - print(f"\nerror: target ({x:.0f}, {y:.0f}) is off-screen ({screen_w}x{screen_h}). " - f"Is the Chrome window partly off the display, or on a second monitor?", file=sys.stderr) - return 3 - - print(describe(found, factor)) - - # A click on a background window is eaten by the window manager activating it, - # so the first attempt silently does nothing. Raise the browser first, and do it - # here rather than before locating: the measurement takes seconds, and focus is - # only required at the moment of the press. - if not args.no_activate: - focused = focus.ensure_frontmost() - print(f" focus {focused.detail}") - if not focused.ok: - print("\nRefusing to click: the browser is not frontmost, so the click " - "would be consumed activating its window instead of pressing the " - "element. Pass --no-activate to override.", file=sys.stderr) - return 3 - - if args.robotic: - pyautogui.moveTo(x, y, duration=0.25) - if not args.dry_run: - pyautogui.click() - else: - import humanize - rng = random.Random(args.seed) if args.seed is not None else random.Random() - humanize.click(x, y, rng=rng, press=not args.dry_run) + except actions.StepError as exc: + print(f"\nerror: {exc}", file=sys.stderr) + return exc.code if args.dry_run: - print("\ndry run — cursor moved, no click sent.") - else: + print("\ndry run — cursor moved, nothing pressed or typed.") + elif args.action == "click": print("\nclicked.") + else: + shown = text if len(text) <= 60 else text[:57] + "\u2026" + print(f"\ntyped {shown!r}" + (" (field cleared first)" if args.clear else "")) return 0 diff --git a/clicker/humanize.py b/clicker/humanize.py index 2b23bf4..36a2692 100644 --- a/clicker/humanize.py +++ b/clicker/humanize.py @@ -12,6 +12,7 @@ before pressing, and holds the button down for a human interval. import math import random +import sys import time import pyautogui @@ -116,6 +117,54 @@ def move(x: float, y: float, rng: random.Random | None = None) -> None: pyautogui.moveTo(x, y, duration=0, _pause=False) +# Typing rhythm. +KEY_DELAY = (0.045, 0.130) # between consecutive keystrokes +WORD_PAUSE = (0.050, 0.170) # extra beat after a space +THINK_CHANCE = 0.045 # occasional longer pause mid-string +THINK_PAUSE = (0.22, 0.55) + +# pyautogui.write() can only emit characters it has a keycode for — roughly +# printable ASCII. Anything else is *silently skipped*, so we reject it up front +# rather than typing a quietly truncated string into a form. +TYPEABLE = frozenset(chr(c) for c in range(32, 127)) | {"\t"} + + +def untypeable(text: str) -> list[str]: + """Characters pyautogui would silently drop. Empty list means safe to type.""" + return sorted({c for c in text if c not in TYPEABLE and c != "\n"}) + + +def clear_field(rng: random.Random | None = None) -> None: + """Select-all then delete, in the focused field.""" + rng = rng or random.Random() + modifier = "command" if sys.platform == "darwin" else "ctrl" + pyautogui.hotkey(modifier, "a", _pause=False) + time.sleep(rng.uniform(0.05, 0.12)) + pyautogui.press("delete", _pause=False) + time.sleep(rng.uniform(0.05, 0.12)) + + +def type_text(text: str, rng: random.Random | None = None) -> None: + """Type with a human cadence: uneven keystrokes, a beat after each word, and + the occasional pause. Deliberately does NOT simulate typos — a mistyped digit + in a trading form that fails to get corrected is a real loss, and the + correction is exactly the part that can go wrong.""" + rng = rng or random.Random() + + for ch in text: + if ch == "\n": + pyautogui.press("enter", _pause=False) + else: + pyautogui.write(ch, _pause=False) + + delay = rng.uniform(*KEY_DELAY) + if ch == " ": + delay += rng.uniform(*WORD_PAUSE) + if rng.random() < THINK_CHANCE: + delay += rng.uniform(*THINK_PAUSE) + time.sleep(delay) + + def click(x: float, y: float, rng: random.Random | None = None, press: bool = True) -> None: """Move to (x, y), settle, then press and release. diff --git a/clicker/runner.py b/clicker/runner.py new file mode 100644 index 0000000..168167e --- /dev/null +++ b/clicker/runner.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Automation runner — the process that makes dashboard buttons do something. + +Leave this running. It polls the dashboard for queued runs, and when one appears +it works through that automation's steps, driving the real mouse and keyboard, +reporting each step back so the page can show progress. + + python runner.py # against http://localhost:3000 + python runner.py --api http://vps:3000 + +Steps come from the server, so adding a new button means editing +lib/automations.ts — nothing here needs to change. + +Stop with Ctrl-C. A run in progress can be halted from the dashboard's Stop +button; the runner notices between steps. +""" + +import argparse +import os +import random +import socket +import sys +import threading +import time + +import actions +from clicker import Dashboard, DashboardError + +POLL_SECONDS = 1.0 +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" + +# Shared with the heartbeat thread: whether a run is currently executing. +_busy = threading.Event() + + +def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None: + """Check in on our own thread. + + Deliberately not folded into the main poll loop: a single step can block for + twenty seconds waiting on the extension, and a runner that goes quiet that + long would show as offline in the middle of the run it is executing. + """ + payload = {"host": socket.gethostname(), "pid": os.getpid(), + "dryRun": opts.dry_run, "version": VERSION} + while not stop.is_set(): + try: + dash._request("/api/autobuyer/runner", "POST", {**payload, "busy": _busy.is_set()}) + except DashboardError: + pass # the main loop reports connectivity; don't double up + stop.wait(HEARTBEAT_SECONDS) + + +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"] + 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)") + + for i, step in enumerate(steps): + label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip() + print(f" [{i + 1}/{len(steps)}] {label}") + + detail_parts: list[str] = [] + + def report(message: str) -> 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 + + status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None) + + # The dashboard's Stop button shows up here. + if status == "cancelled": + print(f"■ run #{run_id} cancelled from the dashboard") + return + + finish(dash, run_id, None) + print(f"✓ run #{run_id} complete") + + +def post_progress(dash, run_id, step_index, step, ok, detail): + try: + res = dash._request("/api/autobuyer/runs/progress", "POST", { + "id": run_id, "stepIndex": step_index, "step": step, "ok": ok, "detail": detail, + }) + return res.get("status") + except DashboardError as exc: + print(f" (could not report progress: {exc})", file=sys.stderr) + return None + + +def finish(dash, run_id, error): + try: + dash._request("/api/autobuyer/runs/progress", "POST", + {"id": run_id, "finish": True, "error": error}) + except DashboardError as exc: + print(f" (could not report completion: {exc})", file=sys.stderr) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL") + parser.add_argument("--url", default="", help="fallback Chrome match pattern for steps that omit one") + parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension per step (a cold page load takes time)") + parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio") + parser.add_argument("--seed", type=int, default=None, help="seed the motion RNG (debugging)") + parser.add_argument("--dry-run", action="store_true", help="move the cursor through the steps but never press or type") + parser.add_argument("--no-activate", action="store_true", help="do not raise the browser before each step") + opts = parser.parse_args() + + try: + import pyautogui # noqa: F401 + except ImportError: + print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr) + return 1 + + dash = Dashboard(opts.api) + print(f"Runner watching {opts.api}" + (" [DRY RUN — nothing will be pressed]" if opts.dry_run else "")) + print("Waiting for a run. Press a button on the AutoBuyer page. Ctrl-C to stop.") + + stop = threading.Event() + beat = threading.Thread(target=heartbeat_loop, args=(dash, opts, stop), daemon=True) + beat.start() + + idle_warned = False + while True: + try: + claim = dash._request("/api/autobuyer/runs/claim", "POST", {}) + except DashboardError as exc: + if not idle_warned: + print(f" ({exc})", file=sys.stderr) + idle_warned = True + time.sleep(POLL_SECONDS * 3) + continue + + idle_warned = False + run = claim.get("run") + if not run: + time.sleep(POLL_SECONDS) + continue + + _busy.set() + try: + run_steps(dash, run, opts) + except Exception as exc: # keep the daemon alive + print(f"✗ run failed unexpectedly: {exc}", file=sys.stderr) + finish(dash, run["id"], f"runner error: {exc}") + finally: + _busy.clear() + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nstopped.") + sys.exit(0) diff --git a/extension/background.js b/extension/background.js index 2f8d4ff..9b9217b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -183,10 +183,19 @@ function pageLocate(selector, index) { const atPoint = document.elementFromPoint(cx, cy); const covered = atPoint && atPoint !== el && !el.contains(atPoint); + // Current contents, so the caller can confirm afterwards that what it typed + // actually landed — a field that silently ignored the keystrokes (masked, + // read-only, or never focused) is otherwise indistinguishable from success. + const isField = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'; + const value = isField ? el.value : (el.isContentEditable ? el.innerText : null); + return { tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 80), matchCount: matches.length, + value, + editable: (isField || el.isContentEditable) && !el.disabled && !el.readOnly, + inputType: isField ? (el.type || null) : null, covered: !!covered, coveredBy: covered ? `${atPoint.tagName.toLowerCase()}${atPoint.id ? '#' + atPoint.id : ''}` : null, viewport: { x: r.left, y: r.top, width: r.width, height: r.height }, @@ -201,17 +210,62 @@ function pageLocate(selector, index) { }; } +/** Is the tab already showing this page? Compares origin and path only — + * query strings and hashes shouldn't force a reload. */ +function alreadyAt(current, target) { + try { + const a = new URL(current); + const b = new URL(target); + return a.origin === b.origin && a.pathname.replace(/\/$/, '') === b.pathname.replace(/\/$/, ''); + } catch { + return false; + } +} + +/** Resolve after the tab reports `complete`. A tab that has only just been + * created has no DOM to inject into yet. */ +function waitForTabLoad(tabId, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + const started = Date.now(); + const check = async () => { + let tab; + try { + tab = await chrome.tabs.get(tabId); + } catch { + return reject(new Error('the tab was closed while loading')); + } + if (tab.status === 'complete') return resolve(tab); + if (Date.now() - started > timeoutMs) { + return reject(new Error(`the page did not finish loading within ${timeoutMs / 1000}s`)); + } + setTimeout(check, 200); + }; + check(); + }); +} + async function resolveLocateTab(cfg, request) { const pattern = request.urlPattern || cfg.targetUrlPattern; if (pattern) { const tabs = await chrome.tabs.query({ url: pattern }); const tab = tabs.find((t) => /^https?:/.test(t.url || '')); - if (!tab) throw new Error(`No open tab matches ${pattern}`); - return tab; + if (tab) return { tab, opened: false }; + + // Nothing matching is open. Open it rather than failing the run — but only + // to the URL the firm declared, never to something a request supplied that + // we have no host permission for. + if (request.openUrl) { + const created = await chrome.tabs.create({ url: request.openUrl, active: true }); + await waitForTabLoad(created.id); + return { tab: await chrome.tabs.get(created.id), opened: true }; + } + + throw new Error(`No open tab matches ${pattern}, and no URL is configured to open`); } + const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); if (!tab || !/^https?:/.test(tab.url || '')) throw new Error('No eligible active tab'); - return tab; + return { tab, opened: false }; } /** Returns true if a request was handled, false if the queue was empty. */ @@ -224,7 +278,17 @@ async function serveLocateRequest(cfg) { let result = null; let error = null; try { - const tab = await resolveLocateTab(cfg, request); + let { tab, opened } = await resolveLocateTab(cfg, request); + + // A navigate step points the tab at a specific page first. Skip it when we + // are already there — reloading would throw away page state for nothing, + // and the common case is that the tab is on the right page already. + if (request.navigateUrl && !alreadyAt(tab.url, request.navigateUrl)) { + await chrome.tabs.update(tab.id, { url: request.navigateUrl }); + await waitForTabLoad(tab.id); + tab = await chrome.tabs.get(tab.id); + opened = true; // treat as a cold load: the app still has to mount + } // The reported coordinates are only worth anything if that tab is the one // actually visible: raise its window and bring the tab to the front. @@ -238,15 +302,24 @@ async function serveLocateRequest(cfg) { await chrome.tabs.update(tab.id, { active: true }); await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it - const [injection] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: pageLocate, - args: [request.selector, request.index || 0], - }); - const out = injection?.result; - if (!out) throw new Error('Injection returned nothing'); - if (out.error) throw new Error(out.error); - result = { ...out, tabId: tab.id, windowId: tab.windowId }; + // `complete` only means the document loaded — a React app still has to + // mount and paint. Retry briefly rather than declaring the element missing, + // with a longer budget when we just opened the page from cold. + const deadline = Date.now() + (opened ? 8000 : 2500); + let out; + for (;;) { + const [injection] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: pageLocate, + args: [request.selector, request.index || 0], + }); + out = injection?.result; + if (!out) throw new Error('Injection returned nothing'); + if (!out.error) break; + if (Date.now() >= deadline) throw new Error(out.error); + await new Promise((r) => setTimeout(r, 350)); + } + result = { ...out, tabId: tab.id, windowId: tab.windowId, openedTab: opened }; } catch (err) { error = err.message; } diff --git a/extension/manifest.json b/extension/manifest.json index b3ec39e..e1cc351 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "AutoFirmer Capture", - "version": "0.2.0", + "version": "0.4.0", "description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.", "permissions": ["scripting", "tabs", "storage", "alarms"], "host_permissions": [ diff --git a/lib/automations.ts b/lib/automations.ts new file mode 100644 index 0000000..e2f6996 --- /dev/null +++ b/lib/automations.ts @@ -0,0 +1,121 @@ +// Automations live inside the firm whose site they drive. The dashboard renders +// a tab per firm and a button per automation within it; the Python runner claims +// a queued run, resolves each selector through the extension, and drives the real +// mouse. +// +// Adding a button means adding an entry to that firm's `automations` — nothing in +// the page, the API or the runner changes. +// +// Adding a NEW FIRM also needs its host added to extension/manifest.json +// host_permissions, and the extension reloaded. Without that the extension is not +// permitted to read that site and every step fails to locate. + +export type AutomationStep = + | { action: 'click'; selector: string; index?: number; urlPattern?: string; label?: string } + | { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; urlPattern?: string; label?: string } + | { 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 }; + +export interface Automation { + id: string; + label: string; + description: string; + /** Shown as a confirmation before the run. Set it on anything that spends money. */ + confirm?: string; + steps: AutomationStep[]; +} + +/** A step as handed to the runner: the firm's tab pattern and fallback URL + * filled in, so the runner never has to know which firm it is working on. */ +export type ResolvedStep = AutomationStep & { + urlPattern?: string; + openUrl?: string; +}; + +export interface Firm { + id: string; + label: string; + /** Chrome match pattern for this firm's tab. Steps inherit it unless they set + * their own, which keeps one firm's automation from acting on another's tab. */ + urlPattern: string; + /** 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; + automations: Automation[]; +} + +export const FIRMS: Firm[] = [ + { + id: 'tradeify', + label: 'Tradeify', + urlPattern: 'https://app-f.tradeify.co/*', + url: 'https://app-f.tradeify.co/', + automations: [ + { + id: 'buy-accounts', + label: 'Buy Accounts', + description: 'Opens the Add Account flow.', + confirm: 'This drives the real mouse against Tradeify and can spend money. Continue?', + steps: [ + // The tab is routinely left on another Tradeify page (/the-circuit, + // an account view). The Add Account link only exists on the + // dashboard, so go there first rather than assuming. + { action: 'navigate', label: 'Open the Tradeify dashboard' }, + // `a.add_account_btn` is the authored class on the Add Account + // link — confirmed against a real capture, matchCount 1. The MUI + // hash classes on the same element (mui-*) are regenerated on + // 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' }, + // TODO: the rest of the purchase flow. Confirm each selector with + // `clicker.py locate` before adding it here. + ], + }, + ], + }, +]; + +/** Runs store one string, so it has to identify the automation globally — and + * every firm will plausibly have its own "buy-accounts". Hence firm:automation + * rather than the bare id. */ +export function automationKey(firmId: string, automationId: string): string { + return `${firmId}:${automationId}`; +} + +export function getFirm(id: string): Firm | undefined { + return FIRMS.find((f) => f.id === id); +} + +export function findAutomation(key: string): { firm: Firm; automation: Automation } | undefined { + for (const firm of FIRMS) { + for (const automation of firm.automations) { + if (automationKey(firm.id, automation.id) === key) return { firm, automation }; + } + } + return undefined; +} + +/** Fill in the firm's tab pattern for any step that didn't name one, so the + * 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 }; + } + return { ...step, urlPattern: step.urlPattern ?? firm.urlPattern, openUrl: firm.url }; + }); +} + +/** What a step is doing, for the run log and the dashboard. */ +export function describeStep(step: AutomationStep): string { + if (step.label) return step.label; + switch (step.action) { + case 'click': return `click ${step.selector}`; + case 'type': return `type into ${step.selector}`; + case 'wait': return `wait ${step.seconds}s`; + case 'navigate': return `open ${step.url ?? 'the firm page'}`; + } +} diff --git a/lib/db.ts b/lib/db.ts index 510ee37..9c3623b 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -392,6 +392,8 @@ export interface LocateRow { selector: string; match_index: number; url_pattern: string; + open_url: string; + navigate_url: string; status: 'pending' | 'claimed' | 'done' | 'error'; result: string | null; error: string | null; @@ -401,10 +403,10 @@ export interface LocateRow { const LOCATE_HISTORY = 20; -export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string): LocateRow { +export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = ''): LocateRow { const res = db.prepare( - 'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, created_at) VALUES (?, ?, ?, ?)' - ).run(selector, matchIndex, urlPattern, Date.now()); + 'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now()); db.prepare(` DELETE FROM autobuyer_locate @@ -436,3 +438,150 @@ export function resolveLocateRequest(id: number, result: unknown | null, error: db.prepare('UPDATE autobuyer_locate SET status = ?, result = ?, error = ?, resolved_at = ? WHERE id = ?') .run(error ? 'error' : 'done', result ? JSON.stringify(result) : null, error, Date.now(), id); } + +// ── AutoBuyer automation runs ──────────────────────────────────────────────── +// +// The dashboard queues a run; the Python runner claims it and works through the +// steps, reporting progress back so the page can show what is happening. + +db.exec(` + CREATE TABLE IF NOT EXISTS autobuyer_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + automation_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + step_index INTEGER NOT NULL DEFAULT 0, + total_steps INTEGER NOT NULL DEFAULT 0, + log TEXT NOT NULL DEFAULT '[]', + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); +`); + +// Migration: the page to send the tab to before locating. +try { + db.exec("ALTER TABLE autobuyer_locate ADD COLUMN navigate_url TEXT NOT NULL DEFAULT ''"); +} catch { + // Column already exists +} + +// Migration: the page to open when no tab matches the pattern. +try { + db.exec("ALTER TABLE autobuyer_locate ADD COLUMN open_url TEXT NOT NULL DEFAULT ''"); +} catch { + // Column already exists +} + +export type RunStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled'; + +export interface RunRow { + id: number; + automation_id: string; + status: RunStatus; + step_index: number; + total_steps: number; + log: string; // JSON { at: number; step: string; ok: boolean; detail?: string }[] + error: string | null; + created_at: number; + updated_at: number; +} + +const RUN_HISTORY = 30; + +export function createRun(automationId: string, totalSteps: number): RunRow { + const now = Date.now(); + const res = db.prepare( + 'INSERT INTO autobuyer_runs (automation_id, total_steps, created_at, updated_at) VALUES (?, ?, ?, ?)' + ).run(automationId, totalSteps, now, now); + + db.prepare(` + DELETE FROM autobuyer_runs + WHERE id NOT IN (SELECT id FROM autobuyer_runs ORDER BY id DESC LIMIT ?) + `).run(RUN_HISTORY); + + return db.prepare('SELECT * FROM autobuyer_runs WHERE id = ?').get(res.lastInsertRowid) as RunRow; +} + +export function getRun(id: number): RunRow | undefined { + return db.prepare('SELECT * FROM autobuyer_runs WHERE id = ?').get(id) as RunRow | undefined; +} + +export function getRecentRuns(limit = 5): RunRow[] { + return db.prepare('SELECT * FROM autobuyer_runs ORDER BY id DESC LIMIT ?').all(limit) as RunRow[]; +} + +export function countActiveRuns(): number { + const row = db.prepare("SELECT COUNT(*) AS n FROM autobuyer_runs WHERE status IN ('queued','running')").get() as { n: number }; + return row.n; +} + +/** The runner takes the oldest queued run. Only one runs at a time — two + * processes driving the same physical mouse would interleave clicks. */ +export function claimRun(): RunRow | undefined { + const running = db.prepare("SELECT 1 FROM autobuyer_runs WHERE status = 'running'").get(); + if (running) return undefined; + + const row = db.prepare("SELECT * FROM autobuyer_runs WHERE status = 'queued' ORDER BY id LIMIT 1").get() as RunRow | undefined; + if (!row) return undefined; + + db.prepare("UPDATE autobuyer_runs SET status = 'running', updated_at = ? WHERE id = ?").run(Date.now(), row.id); + return { ...row, status: 'running' }; +} + +export function appendRunLog(id: number, entry: unknown, stepIndex: number): void { + const row = getRun(id); + if (!row) return; + let log: unknown[]; + try { log = JSON.parse(row.log); } catch { log = []; } + log.push(entry); + db.prepare('UPDATE autobuyer_runs SET log = ?, step_index = ?, updated_at = ? WHERE id = ?') + .run(JSON.stringify(log), stepIndex, Date.now(), id); +} + +export function finishRun(id: number, status: RunStatus, error: string | null): void { + db.prepare('UPDATE autobuyer_runs SET status = ?, error = ?, updated_at = ? WHERE id = ?') + .run(status, error, Date.now(), id); +} + +/** Cancelling a queued run stops it starting; cancelling a running one is seen + * by the runner between steps. */ +export function cancelRun(id: number): boolean { + const row = getRun(id); + if (!row || row.status === 'done' || row.status === 'error' || row.status === 'cancelled') return false; + finishRun(id, 'cancelled', null); + return true; +} + +// ── Runner heartbeat ───────────────────────────────────────────────────────── +// +// The runner is a desktop process, so the server can't tell whether it's alive. +// It reports in every couple of seconds; if the beats stop, the dashboard greys +// out the buttons rather than queueing runs nobody will execute. + +/** Three missed beats. Long enough to ride out a slow tick, short enough that a + * killed runner shows as offline before you press anything. */ +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 interface RunnerHeartbeat { + at: number; + host?: string; + pid?: number; + dryRun?: boolean; + busy?: boolean; + version?: string; +} + +export function setRunnerHeartbeat(info: Omit): void { + setSetting('runner_heartbeat', JSON.stringify({ ...info, at: Date.now() })); +} + +export function getRunnerHeartbeat(): RunnerHeartbeat | null { + const raw = getSetting('runner_heartbeat'); + if (!raw) return null; + try { return JSON.parse(raw) as RunnerHeartbeat; } catch { return null; } +}