#!/usr/bin/env python3 """settings_rpa.py: RPA module for Muse.ai Settings menu, dock rail, and dialogs. Provides headless browser automation primitives for: - Dock rail settings menu toggle via CDP mouse events - Settings dialog navigation (General, Connectors, Wallet, etc.) - Token and weekly quota usage inspection - Settings-based invite code redemption entry point Compatible with NetVM's unified box CLI and approvals system. """ from __future__ import annotations import argparse import itertools import json import re import sys import time from dataclasses import asdict, dataclass from typing import Any, Dict, List, Optional, Tuple try: from approvals import cdp_evaluate, get_cdp_ws, get_node_pages except ImportError: # Allow running when approvals is in current dir or NetVM bin import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from approvals import cdp_evaluate, get_cdp_ws, get_node_pages _REQ_COUNTER = itertools.count(10000) @dataclass class UsageBar: label: str percent: int raw_text: str @dataclass class NodeUsage: node: str plan: str weekly_reset_text: str weekly_percent_used: int extra_tokens_status: str extra_percent_used: int extra_tokens_remaining: str is_blocked: bool bars: List[Dict[str, Any]] dialog_text: str has_redeemed: bool = False def to_dict(self) -> Dict[str, Any]: return asdict(self) def cdp_send_command(ws: Any, method: str, params: Dict[str, Any], timeout: float = 3.0) -> Optional[Dict[str, Any]]: """Send a raw CDP command and await response with a unique request ID.""" req_id = next(_REQ_COUNTER) msg = {"id": req_id, "method": method, "params": params} ws.send(json.dumps(msg)) deadline = time.time() + timeout while time.time() < deadline: try: raw = ws.recv() except Exception: break resp = json.loads(raw) if resp.get("id") == req_id: return resp.get("result", {}) return None def cdp_dispatch_mouse_click(ws: Any, x: float, y: float, timeout: float = 3.0) -> bool: """Simulate real mousePressed and mouseReleased events at (x, y).""" p1 = cdp_send_command( ws, "Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1}, timeout=timeout, ) p2 = cdp_send_command( ws, "Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1}, timeout=timeout, ) return p1 is not None and p2 is not None def cdp_click_element_by_selector(ws: Any, selector: str, timeout: float = 3.0) -> bool: """Find element by selector, calculate bounding box center, and dispatch real mouse click.""" js_rect = f"""(() => {{ const el = document.querySelector({json.dumps(selector)}); if (!el) return null; const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return null; return {{x: r.x + r.width / 2, y: r.y + r.height / 2}}; }})()""" rect = cdp_evaluate(ws, js_rect, timeout=timeout) if not rect or "x" not in rect: return False return cdp_dispatch_mouse_click(ws, float(rect["x"]), float(rect["y"]), timeout=timeout) def cdp_send_escape(ws: Any) -> None: """Send an Escape keydown event to dismiss popovers and modals.""" js_escape = """(() => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true })); })()""" cdp_evaluate(ws, js_escape) class SettingsRPA: """Automates Settings menu, dialogs, and usage inspection for a NetVM node.""" def __init__(self, node: str, timeout: float = 4.0): self.node = node self.timeout = timeout self.ws = None self.page = None def connect(self) -> SettingsRPA: if self.ws is None: self.ws, self.page = get_cdp_ws(self.node, timeout=self.timeout) return self def close(self) -> None: if self.ws is not None: try: self.ws.close() except Exception: pass self.ws = None def __enter__(self) -> SettingsRPA: return self.connect() def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.close() def ensure_main_chat(self) -> bool: """Ensure agent is in main chat home view so dock and main toolbar are active.""" self.connect() js_is_main = """(() => { return Boolean(document.querySelector('[data-testid="hatch-invite-friends-button"]')); })()""" if cdp_evaluate(self.ws, js_is_main): return True # Click the chat dock item to return to main cdp_evaluate( self.ws, """(() => { const chatLink = document.querySelector('[data-testid="hatch-nav-chat"]'); if (chatLink) { chatLink.click(); return true; } return false; })()""", ) time.sleep(1.0) return bool(cdp_evaluate(self.ws, js_is_main)) def is_menu_open(self) -> bool: """Check if dock radix menu is currently open.""" self.connect() js_check = """(() => { return document.querySelectorAll('[role="menuitem"]').length > 0; })()""" return bool(cdp_evaluate(self.ws, js_check)) def is_settings_dialog_open(self) -> bool: """Check if Settings dialog is currently open.""" self.connect() js_check = """(() => { const d = document.querySelector('[role="dialog"]'); if (!d) return false; const h = d.querySelector('h2'); return h && h.innerText.trim().toLowerCase() === 'settings'; })()""" return bool(cdp_evaluate(self.ws, js_check)) def open_dock_menu(self) -> bool: """Open the dock settings menu via real mouse clicks on hatch-dock-more.""" self.connect() if self.is_menu_open(): return True # Dispatch real mouse click on dock button clicked = cdp_click_element_by_selector(self.ws, '[data-testid="hatch-dock-more"]', timeout=self.timeout) if not clicked: return False deadline = time.time() + 2.0 while time.time() < deadline: if self.is_menu_open(): return True time.sleep(0.1) return False def close_dock_menu(self) -> None: """Dismiss dock menu.""" if self.is_menu_open(): cdp_send_escape(self.ws) time.sleep(0.2) def open_settings_dialog(self) -> bool: """Open the Settings dialog through the dock settings menu.""" self.connect() if self.is_settings_dialog_open(): return True # Ensure menu is open if not self.open_dock_menu(): return False time.sleep(0.3) # Click "Settings" menu item js_click_item = """(() => { const items = Array.from(document.querySelectorAll('[role="menuitem"]')); const target = items.find(el => el.getAttribute('data-pel-click') === 'settings_nav_click') || items.find(el => (el.innerText || '').trim() === 'Settings'); if (target) { target.click(); return true; } return false; })()""" clicked = cdp_evaluate(self.ws, js_click_item) if not clicked: return False deadline = time.time() + 3.0 while time.time() < deadline: if self.is_settings_dialog_open(): return True time.sleep(0.15) return False def close_settings_dialog(self) -> None: """Dismiss Settings dialog with Escape or Close button.""" if self.is_settings_dialog_open(): cdp_send_escape(self.ws) time.sleep(0.3) # If still open, try close button if self.is_settings_dialog_open(): cdp_evaluate( self.ws, """(() => { const btn = Array.from(document.querySelectorAll('[role="dialog"] button')).find(b => (b.innerText||'').trim() === 'Close'); if (btn) btn.click(); })()""", ) time.sleep(0.2) def select_tab(self, tab_name: str) -> bool: """Select a section tab inside the Settings dialog.""" self.connect() if not self.open_settings_dialog(): return False js_tab = f"""(() => {{ const btns = Array.from(document.querySelectorAll('[role="dialog"] button')); const tab = btns.find(b => (b.innerText||'').trim().toLowerCase() === {json.dumps(tab_name.lower())}); if (tab) {{ tab.click(); return true; }} return false; }})()""" res = cdp_evaluate(self.ws, js_tab) if res: time.sleep(0.4) return True return False def read_usage(self, keep_dialog_open: bool = False) -> NodeUsage: """Open settings dialog, read token and weekly limit usage, and optionally close dialog.""" self.connect() opened = self.open_settings_dialog() if not opened: raise RuntimeError(f"Could not open Settings dialog for node {self.node}") # Ensure General tab is active self.select_tab("General") time.sleep(0.4) js_usage = """(() => { const dialog = document.querySelector('[role="dialog"]'); if (!dialog) return null; const fullText = dialog.innerText || ''; const bars = Array.from(dialog.querySelectorAll('[role="progressbar"]')).map(b => ({ label: b.getAttribute('aria-label') || '', percent: parseInt(b.getAttribute('aria-valuenow') || '0', 10), raw_text: b.parentElement ? b.parentElement.innerText.trim() : '' })); // Extract plan let plan = 'Free plan'; if (fullText.includes('Free plan')) plan = 'Free plan'; else if (fullText.includes('Pro plan')) plan = 'Pro plan'; // Extract reset text let resetText = ''; const mReset = fullText.match(/Weekly limit resets on [^\\n]+/); if (mReset) resetText = mReset[0]; // Extract tokens left let tokensLeft = ''; const mLeft = fullText.match(/\\(([^\\)]+tokens left)\\)/); if (mLeft) tokensLeft = mLeft[1]; else if (fullText.includes('0 tokens left')) tokensLeft = '0 tokens left'; return { dialogText: fullText, bars: bars, plan: plan, resetText: resetText, tokensLeft: tokensLeft }; })()""" # Poll up to 4.0s for usage elements to render in React deadline = time.time() + 4.0 raw = None while time.time() < deadline: res = cdp_evaluate(self.ws, js_usage) if res and (res.get("bars") or "Weekly limit" in res.get("dialogText", "") or "Additional tokens" in res.get("dialogText", "")): raw = res break time.sleep(0.3) if not raw: raw = cdp_evaluate(self.ws, js_usage) if not keep_dialog_open: self.close_settings_dialog() if not raw: raise RuntimeError(f"Failed to read usage data from Settings dialog on {self.node}") bars = raw.get("bars", []) weekly_pct = 0 extra_pct = 0 extra_status = "Never expires" for b in bars: label = b.get("label", "").lower() detail = b.get("raw_text", "").lower() if "free plan" in label or "weekly" in detail: weekly_pct = b.get("percent", 0) elif "additional tokens" in label or "additional" in detail: extra_pct = b.get("percent", 0) # Blocked condition: weekly limit 100% and additional tokens 100% or 0 tokens left tokens_left = raw.get("tokensLeft", "") is_blocked = (weekly_pct >= 100 and extra_pct >= 100) or ("0 tokens left" in tokens_left) # has_redeemed binary: If the "Additional tokens" ticker is present in Settings (or "Redeem invite code" entry is absent), # the agent has already redeemed an invite code. has_extra_ticker = any("additional tokens" in b.get("label", "").lower() or "additional" in b.get("raw_text", "").lower() for b in bars) has_redeemed = has_extra_ticker or ("Additional tokens" in raw.get("dialogText", "")) return NodeUsage( node=self.node, plan=raw.get("plan", "Free plan"), weekly_reset_text=raw.get("resetText", ""), weekly_percent_used=weekly_pct, extra_tokens_status=extra_status, extra_percent_used=extra_pct, extra_tokens_remaining=tokens_left or ("0 tokens left" if extra_pct >= 100 else "Unknown"), is_blocked=is_blocked, bars=bars, dialog_text=raw.get("dialogText", ""), has_redeemed=has_redeemed, ) def check_redeem_entrypoint(self) -> Dict[str, Any]: """Check if 'Redeem invite code' option is available in Settings -> General.""" self.connect() if not self.open_settings_dialog(): return {"available": False, "reason": "could_not_open_settings"} self.select_tab("General") time.sleep(0.3) js_check = """(() => { const dialog = document.querySelector('[role="dialog"]'); if (!dialog) return {available: false, reason: 'no_dialog'}; const items = Array.from(dialog.querySelectorAll('button, div, span')); const redeemItem = items.find(el => (el.innerText || '').trim() === 'Redeem invite code'); return { available: Boolean(redeemItem), has_text: dialog.innerText.includes('Redeem invite code') }; })()""" res = cdp_evaluate(self.ws, js_check) self.close_settings_dialog() return res or {"available": False, "reason": "evaluation_failed"} def main() -> None: parser = argparse.ArgumentParser(description="Settings menu RPA for NetVM agents") parser.add_argument("node", help="Node name (e.g. 646, pip, muse, opm)") parser.add_argument("--usage", action="store_true", help="Read token and weekly quota usage") parser.add_argument("--json", action="store_true", help="Emit JSON output") parser.add_argument("--check-redeem", action="store_true", help="Check if Redeem Invite Code is available") args = parser.parse_args() rpa = SettingsRPA(args.node) try: rpa.connect() if args.usage: usage = rpa.read_usage() if args.json: print(json.dumps(usage.to_dict(), indent=2)) else: status_str = "BLOCKED (LIMIT REACHED)" if usage.is_blocked else "ACTIVE" print(f"=== Node {usage.node} Usage ===") print(f" Plan: {usage.plan}") print(f" Weekly Reset: {usage.weekly_reset_text} ({usage.weekly_percent_used}% used)") print(f" Extra Tokens: {usage.extra_tokens_remaining} ({usage.extra_percent_used}% used)") print(f" Status: {status_str}") elif args.check_redeem: info = rpa.check_redeem_entrypoint() print(json.dumps(info, indent=2)) else: usage = rpa.read_usage() print(json.dumps(usage.to_dict(), indent=2)) finally: rpa.close() if __name__ == "__main__": main()