#!/usr/bin/env python3 """invite_handler.py: Invite code discovery, inspection, and redemption handler. Supports: - Extracting agent invite codes via Main Chat DOM RPA & in-session API - Redeeming invite codes via Settings Menu RPA & redemption endpoint - Fleet-wide invite inventory, usage tracking, and agent salvage flows """ from __future__ import annotations import argparse import json import re import sys import time from dataclasses import asdict, dataclass from typing import Any, Dict, List, Optional try: from approvals import cdp_evaluate, get_cdp_ws, get_node_pages from settings_rpa import SettingsRPA, cdp_click_element_by_selector, cdp_send_escape except ImportError: import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from approvals import cdp_evaluate, get_cdp_ws, get_node_pages from settings_rpa import SettingsRPA, cdp_click_element_by_selector, cdp_send_escape VALID_NODES = ["muse", "pip", "646", "opm", "def", "dev"] @dataclass class InviteCodeInfo: node: str code: str uses_remaining: int use_count: int max_uses: int has_redeemed: bool invite_state: str reward: Optional[Dict[str, Any]] method_used: str def to_dict(self) -> Dict[str, Any]: return asdict(self) @dataclass class RedemptionResult: target_node: str code: str success: bool status: str reason: Optional[str] detail: Optional[str] method_used: str def to_dict(self) -> Dict[str, Any]: return asdict(self) class InviteHandler: """Handles invite codes discovery, inspection, and redemption for NetVM nodes.""" def __init__(self, node: str, timeout: float = 4.0): self.node = node self.timeout = timeout self.ws = None def connect(self) -> InviteHandler: if self.ws is None: self.ws, _ = 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) -> InviteHandler: return self.connect() def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.close() def find_code_api(self) -> InviteCodeInfo: """Fetch invite code and metadata directly via in-session /api/hatch/invite query.""" self.connect() js_query = """(async () => { try { const resp = await fetch('/api/hatch/invite', {method: 'GET', cache: 'no-store'}); if (!resp.ok) return {error: `HTTP ${resp.status}`}; return await resp.json(); } catch(e) { return {error: e.toString()}; } })()""" res = cdp_evaluate(self.ws, js_query, await_promise=True, timeout=self.timeout) if not res or "error" in res or "code" not in res: err = res.get("error", "Invalid response") if res else "No response" raise RuntimeError(f"Failed to fetch invite info for {self.node}: {err}") return InviteCodeInfo( node=self.node, code=res.get("code", ""), uses_remaining=res.get("uses_remaining", 0), use_count=res.get("use_count", 0), max_uses=res.get("max_uses", 30), has_redeemed=bool(res.get("has_redeemed_invite_code", False)), invite_state=res.get("invite_state", "UNKNOWN"), reward=res.get("reward"), method_used="api", ) def find_code_dom(self) -> InviteCodeInfo: """Find invite code by navigating to Main Chat and opening the Invite popover.""" self.connect() # 1. Ensure main chat home is active with SettingsRPA(self.node, timeout=self.timeout) as rpa: rpa.ensure_main_chat() time.sleep(0.3) # Dismiss any open popovers first cdp_send_escape(self.ws) time.sleep(0.2) # 2. Click Invite button in main chat clicked = cdp_click_element_by_selector(self.ws, '[data-testid="hatch-invite-friends-button"]', timeout=self.timeout) if not clicked: # Fallback to button by text js_click = """(() => { const btn = Array.from(document.querySelectorAll('button')).find(b => (b.innerText||'').trim() === 'Invite'); if (btn) { btn.click(); return true; } return false; })()""" clicked = cdp_evaluate(self.ws, js_click) if not clicked: raise RuntimeError(f"Could not click Invite button on {self.node}") time.sleep(0.8) # 3. Read popover contents js_read_popover = """(() => { const pop = document.querySelector('[data-slot="popover-content"]'); if (!pop) return {found: false}; // Look for revealed code span const codeSpan = pop.querySelector('[data-pel-impression="invite_code_revealed_impression"]'); let code = codeSpan ? codeSpan.innerText.trim() : null; // Regex fallback on text const fullText = pop.innerText || ''; if (!code) { const m = fullText.match(/Invite code revealed:\\s*([A-Z0-9]{6})/); if (m) code = m[1]; } if (!code) { const m2 = fullText.match(/\\b([A-Z0-9]{6})\\b/); if (m2) code = m2[1]; } let usesLeft = 30; const mUses = fullText.match(/(\\d+)\\s+uses\\s+left/); if (mUses) usesLeft = parseInt(mUses[1], 10); return { found: true, code: code, uses_left: usesLeft, text: fullText }; })()""" pop_res = cdp_evaluate(self.ws, js_read_popover) cdp_send_escape(self.ws) if not pop_res or not pop_res.get("found") or not pop_res.get("code"): # Fall back to API if popover extraction failed return self.find_code_api() code = pop_res.get("code") uses_left = pop_res.get("uses_left", 30) # Query API for additional metadata try: api_info = self.find_code_api() api_info.method_used = "dom+api" return api_info except Exception: return InviteCodeInfo( node=self.node, code=code, uses_remaining=uses_left, use_count=30 - uses_left, max_uses=30, has_redeemed=True, invite_state="ELIGIBLE", reward=None, method_used="dom", ) def find_code(self, method: str = "auto") -> InviteCodeInfo: """Find invite code using specified method ('auto', 'api', or 'dom').""" if method == "api": return self.find_code_api() elif method == "dom": return self.find_code_dom() else: # Auto: Try API first, fallback to DOM try: return self.find_code_api() except Exception: return self.find_code_dom() def redeem_code_api(self, code: str) -> RedemptionResult: """Redeem invite code using in-session /api/hatch/invite-code/redeem endpoint.""" self.connect() clean_code = code.strip().upper() js_redeem = f"""(async () => {{ try {{ const resp = await fetch('/api/hatch/invite-code/redeem', {{ method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{code: {json.dumps(clean_code)}, supportsRedemptionStatus: true}}) }}); const data = await resp.json(); return {{status_code: resp.status, ok: resp.ok, data: data}}; }} catch(e) {{ return {{error: e.toString()}}; }} }})()""" res = cdp_evaluate(self.ws, js_redeem, await_promise=True, timeout=self.timeout) if not res or "error" in res: err = res.get("error", "No response") if res else "Timeout" return RedemptionResult( target_node=self.node, code=clean_code, success=False, status="error", reason="transport_failure", detail=err, method_used="api", ) data = res.get("data", {}) ok = res.get("ok", False) and data.get("success", False) status = data.get("redemptionStatus") or ("redeemed" if ok else "failed") reason = data.get("reason") detail = data.get("detail") return RedemptionResult( target_node=self.node, code=clean_code, success=ok, status=status, reason=reason, detail=detail, method_used="api", ) def redeem_code_dom(self, code: str) -> RedemptionResult: """Redeem invite code using Settings Menu RPA -> Settings -> Redeem Invite Code dialog.""" self.connect() clean_code = code.strip().upper() with SettingsRPA(self.node, timeout=self.timeout) as rpa: # 1. Open Settings dialog opened = rpa.open_settings_dialog() if not opened: return RedemptionResult( target_node=self.node, code=clean_code, success=False, status="error", reason="settings_open_failed", detail="Could not open Settings dialog via RPA", method_used="dom", ) rpa.select_tab("General") time.sleep(0.3) # 2. Check for "Redeem invite code" entry js_find_and_click = """(() => { const dialog = document.querySelector('[role="dialog"]'); if (!dialog) return {found: false}; const items = Array.from(dialog.querySelectorAll('button, div, span')); const redeemItem = items.find(el => (el.innerText || '').trim() === 'Redeem invite code'); if (redeemItem) { redeemItem.click(); return {found: true}; } return {found: false, text: dialog.innerText}; })()""" click_res = cdp_evaluate(self.ws, js_find_and_click) if not click_res or not click_res.get("found"): rpa.close_settings_dialog() # If entrypoint missing, likely already redeemed api_check = self.find_code_api() if api_check.has_redeemed: return RedemptionResult( target_node=self.node, code=clean_code, success=False, status="already_redeemed", reason="already_redeemed", detail="Node has already redeemed an invite code (entrypoint hidden)", method_used="dom", ) return RedemptionResult( target_node=self.node, code=clean_code, success=False, status="entrypoint_not_found", reason="not_eligible", detail="Redeem invite code item not present in General settings", method_used="dom", ) time.sleep(0.8) # 3. Handle HatchInviteRedemptionDialog js_input_code = f"""(() => {{ // Look for dialog titled "Redeem a code" const input = document.querySelector('input[aria-label="Invite code"]') || document.querySelector('[role="dialog"] input[type="text"]'); if (!input) return {{found_input: false}}; // Type code into input input.focus(); input.value = {json.dumps(clean_code)}; input.dispatchEvent(new Event('input', {{bubbles: true}})); input.dispatchEvent(new Event('change', {{bubbles: true}})); // Submit via Enter key input.dispatchEvent(new KeyboardEvent('keydown', {{key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true}})); return {{found_input: true}}; }})()""" input_res = cdp_evaluate(self.ws, js_input_code) time.sleep(1.5) # 4. Check outcome in dialog js_check_outcome = """(() => { const dialog = document.querySelector('[role="dialog"]'); if (!dialog) return {found: false}; const text = dialog.innerText || ''; if (text.includes('Congratulations') || text.includes('successful')) { return {success: true, status: 'redeemed', detail: text}; } if (text.includes('already redeemed')) { return {success: false, status: 'already_redeemed', detail: text}; } if (text.includes('no longer valid') || text.includes('invalid')) { return {success: false, status: 'invalid', detail: text}; } if (text.includes('used up')) { return {success: false, status: 'used_up', detail: text}; } if (text.includes('passed') || text.includes('expired')) { return {success: false, status: 'window_expired', detail: text}; } return {status: 'unknown', detail: text}; })()""" outcome = cdp_evaluate(self.ws, js_check_outcome) rpa.close_settings_dialog() if outcome and outcome.get("success"): return RedemptionResult( target_node=self.node, code=clean_code, success=True, status="redeemed", reason=None, detail="Redemption successful via Settings RPA", method_used="dom", ) elif outcome and outcome.get("status") in ["already_redeemed", "invalid", "used_up", "window_expired"]: return RedemptionResult( target_node=self.node, code=clean_code, success=False, status=outcome.get("status"), reason=outcome.get("status"), detail=outcome.get("detail"), method_used="dom", ) # Fallback to API check if DOM did not confirm status return self.redeem_code_api(clean_code) def redeem_code(self, code: str, method: str = "auto") -> RedemptionResult: """Redeem invite code using specified method ('auto', 'dom', or 'api').""" if method == "dom": return self.redeem_code_dom(code) elif method == "api": return self.redeem_code_api(code) else: # Auto: Validate and attempt via API for reliability return self.redeem_code_api(code) def scan_fleet_invites(nodes: Optional[List[str]] = None) -> List[Dict[str, Any]]: """Scan fleet nodes and return invite code details for each active node.""" target_nodes = nodes or ["muse", "pip", "646", "opm"] results = [] for node in target_nodes: try: handler = InviteHandler(node) with handler: info = handler.find_code(method="auto") results.append(info.to_dict()) except Exception as e: results.append({ "node": node, "code": None, "uses_remaining": 0, "use_count": 0, "max_uses": 30, "has_redeemed": None, "invite_state": "UNREACHABLE", "reward": None, "method_used": "error", "error": str(e), }) return results def scan_fleet_usage(nodes: Optional[List[str]] = None) -> List[Dict[str, Any]]: """Scan fleet nodes and return token usage for each active node.""" target_nodes = nodes or ["muse", "pip", "646", "opm"] results = [] for node in target_nodes: try: with SettingsRPA(node) as rpa: usage = rpa.read_usage() results.append(usage.to_dict()) except Exception as e: results.append({ "node": node, "plan": "Unknown", "weekly_reset_text": "Unreachable", "weekly_percent_used": 0, "extra_tokens_status": "Unknown", "extra_percent_used": 0, "extra_tokens_remaining": "Unknown", "is_blocked": False, "bars": [], "error": str(e), }) return results def salvage_blocked_node(blocked_node: str = "646", helper_node: Optional[str] = None) -> Dict[str, Any]: """Salvage an out-of-tokens node by identifying its code and redeeming it on an eligible peer.""" # 1. Fetch blocked node invite code with InviteHandler(blocked_node) as h_blocked: blocked_info = h_blocked.find_code() code_to_redeem = blocked_info.code if not code_to_redeem: return { "success": False, "blocked_node": blocked_node, "error": f"Could not find invite code for blocked node {blocked_node}", } # 2. Check candidate helper nodes candidates = [helper_node] if helper_node else [n for n in VALID_NODES if n != blocked_node] eligible_peer = None for peer in candidates: try: with InviteHandler(peer) as h_peer: peer_info = h_peer.find_code() if not peer_info.has_redeemed: eligible_peer = peer break except Exception: continue if not eligible_peer: return { "success": False, "blocked_node": blocked_node, "invite_code": code_to_redeem, "error": "No existing fleet peer is currently eligible (all active peers have already redeemed an invite code). An onboarding agent or fresh client profile must redeem this code.", "code_to_redeem": code_to_redeem, "share_instruction": f"Redeem code '{code_to_redeem}' on a newly provisioned agent to credit 1 billion tokens to {blocked_node}." } # 3. Redeem on eligible peer with InviteHandler(eligible_peer) as h_peer: redemption = h_peer.redeem_code(code_to_redeem) return { "success": redemption.success, "blocked_node": blocked_node, "helper_node": eligible_peer, "code_redeemed": code_to_redeem, "redemption_result": redemption.to_dict(), } def main() -> None: parser = argparse.ArgumentParser(description="NetVM Invite Code Handler") subparsers = parser.add_subparsers(dest="command") p_find = subparsers.add_parser("find", help="Find invite code for an agent") p_find.add_argument("node", help="Node name (e.g. 646, pip, muse, opm)") p_find.add_argument("--method", choices=["auto", "api", "dom"], default="auto") p_find.add_argument("--json", action="store_true") p_redeem = subparsers.add_parser("redeem", help="Redeem an invite code on a target agent") p_redeem.add_argument("node", help="Target node to redeem the code on") p_redeem.add_argument("code", help="6-character invite code") p_redeem.add_argument("--method", choices=["auto", "api", "dom"], default="auto") p_redeem.add_argument("--json", action="store_true") p_list = subparsers.add_parser("list", help="List invite codes across fleet") p_list.add_argument("--json", action="store_true") p_salvage = subparsers.add_parser("salvage", help="Salvage a blocked agent (e.g. 646)") p_salvage.add_argument("node", default="646", nargs="?", help="Blocked node (default: 646)") p_salvage.add_argument("--helper", help="Specific helper node to redeem code") p_salvage.add_argument("--json", action="store_true") args = parser.parse_args() if args.command == "find": with InviteHandler(args.node) as h: info = h.find_code(method=args.method) if args.json: print(json.dumps(info.to_dict(), indent=2)) else: print(f"=== Agent {info.node} Invite Code ===") print(f" Code: {info.code}") print(f" Uses Remaining: {info.uses_remaining} / {info.max_uses}") print(f" Has Redeemed?: {'Yes' if info.has_redeemed else 'No'}") print(f" Method: {info.method_used}") if info.reward: print(f" Reward: {info.reward.get('title')}") elif args.command == "redeem": with InviteHandler(args.node) as h: res = h.redeem_code(args.code, method=args.method) if args.json: print(json.dumps(res.to_dict(), indent=2)) else: status_icon = "✔" if res.success else "✖" print(f"[{status_icon}] Redemption on {res.target_node} for code {res.code}:") print(f" Success: {res.success}") print(f" Status: {res.status}") if res.detail: print(f" Detail: {res.detail}") elif args.command == "list": fleet = scan_fleet_invites() if args.json: print(json.dumps(fleet, indent=2)) else: print("\n=== NETVM FLEET INVITE CODES ===") print(f" {'NODE':<8} {'INVITE CODE':<14} {'USES LEFT':<12} {'REDEEMED?':<12} {'REWARD / NOTE':<25}") print(f" {'────':<8} {'───────────':<14} {'─────────':<12} {'─────────':<12} {'─────────────':<25}") for row in fleet: node = row.get("node", "") code = row.get("code") or "N/A" uses = f"{row.get('uses_remaining', 0)}/{row.get('max_uses', 30)}" redeemed = "Yes" if row.get("has_redeemed") else "No" reward = row.get("reward", {}) reward_str = reward.get("title", "-") if reward else "-" print(f" {node:<8} {code:<14} {uses:<12} {redeemed:<12} {reward_str:<25}") print() elif args.command == "salvage": salvage_res = salvage_blocked_node(args.node, helper_node=args.helper) if args.json: print(json.dumps(salvage_res, indent=2)) else: print(f"\n=== SALVAGE REPORT FOR {args.node.upper()} ===") print(f" Invite Code to Credit: {salvage_res.get('invite_code') or salvage_res.get('code_redeemed')}") if salvage_res.get("success"): print(f" Status: SUCCESS! Redeemed on {salvage_res.get('helper_node')}") else: print(f" Status: {salvage_res.get('error')}") if salvage_res.get("share_instruction"): print(f" Next Step: {salvage_res.get('share_instruction')}") print() else: parser.print_help() if __name__ == "__main__": main()