2026-10-06 19:33:17 +00:00
|
|
|
#!/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
|
2026-10-06 20:02:48 +00:00
|
|
|
stats_loaded: bool = True
|
2026-10-06 19:33:17 +00:00
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return asdict(self)
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 20:02:48 +00:00
|
|
|
|
2026-10-06 19:33:17 +00:00
|
|
|
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:
|
2026-10-06 20:02:48 +00:00
|
|
|
raw = {
|
|
|
|
|
"dialogText": "",
|
|
|
|
|
"bars": [],
|
|
|
|
|
"plan": "Unknown",
|
|
|
|
|
"resetText": "Unavailable (usage stats did not load)",
|
|
|
|
|
"tokensLeft": "Unavailable",
|
|
|
|
|
}
|
2026-10-06 19:33:17 +00:00
|
|
|
|
|
|
|
|
bars = raw.get("bars", [])
|
2026-10-06 20:02:48 +00:00
|
|
|
dialog_text = raw.get("dialogText", "")
|
|
|
|
|
stats_loaded = bool(bars or "Weekly limit" in dialog_text or "Additional tokens" in dialog_text or "Free plan" in dialog_text)
|
|
|
|
|
|
2026-10-06 19:33:17 +00:00
|
|
|
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", "")
|
2026-10-06 20:02:48 +00:00
|
|
|
is_blocked = bool(stats_loaded and ((weekly_pct >= 100 and extra_pct >= 100) or ("0 tokens left" in tokens_left)))
|
2026-10-06 19:33:17 +00:00
|
|
|
|
|
|
|
|
# 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)
|
2026-10-06 20:02:48 +00:00
|
|
|
has_redeemed = bool(has_extra_ticker or ("Additional tokens" in dialog_text))
|
2026-10-06 19:33:17 +00:00
|
|
|
|
|
|
|
|
return NodeUsage(
|
|
|
|
|
node=self.node,
|
2026-10-06 20:02:48 +00:00
|
|
|
plan=raw.get("plan", "Unknown" if not stats_loaded else "Free plan"),
|
|
|
|
|
weekly_reset_text=raw.get("resetText", "") or ("Unavailable (stats did not load)" if not stats_loaded else ""),
|
2026-10-06 19:33:17 +00:00
|
|
|
weekly_percent_used=weekly_pct,
|
|
|
|
|
extra_tokens_status=extra_status,
|
|
|
|
|
extra_percent_used=extra_pct,
|
2026-10-06 20:02:48 +00:00
|
|
|
extra_tokens_remaining=tokens_left or ("Unavailable" if not stats_loaded else ("0 tokens left" if extra_pct >= 100 else "Unknown")),
|
2026-10-06 19:33:17 +00:00
|
|
|
is_blocked=is_blocked,
|
|
|
|
|
bars=bars,
|
2026-10-06 20:02:48 +00:00
|
|
|
dialog_text=dialog_text,
|
2026-10-06 19:33:17 +00:00
|
|
|
has_redeemed=has_redeemed,
|
2026-10-06 20:02:48 +00:00
|
|
|
stats_loaded=stats_loaded,
|
2026-10-06 19:33:17 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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:
|
2026-10-06 20:02:48 +00:00
|
|
|
if not usage.stats_loaded:
|
|
|
|
|
status_str = "UNLOADED (STATS DID NOT RENDER)"
|
|
|
|
|
elif usage.is_blocked:
|
|
|
|
|
status_str = "BLOCKED (LIMIT REACHED)"
|
|
|
|
|
else:
|
|
|
|
|
status_str = "ACTIVE"
|
2026-10-06 19:33:17 +00:00
|
|
|
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()
|