#!/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 field_missing: bool = False loopback_notified: bool = False loopback_detail: Optional[str] = None def to_dict(self) -> Dict[str, Any]: return asdict(self) def send_loopback_notice( recipient: str, target: str, message: str, sender: str = "super", timeout: float = 15.0, ) -> bool: """Send a loopback notification DM via bin/dm.py to a sidechat without blocking or failing.""" try: from pathlib import Path bin_dir = Path(__file__).resolve().parent dm_script = bin_dir / "dm.py" if not dm_script.exists(): return False import subprocess cmd = [ sys.executable, str(dm_script), "send", "--agent", sender, "--to", recipient, "--target", target, message, ] if target == "main": cmd.insert(-1, "--allow-main-chat") res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) return res.returncode == 0 except Exception: return False 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 _dispatch_loopback(self, res: RedemptionResult, agent: str, target: str) -> bool: """Post a loopback notification DM via box dm.""" msg = f"[BOX-INVITE-LOOPBACK] Node @{self.node} redeem status: {res.status}. Reason: {res.reason or 'unknown'}. Detail: {res.detail or '-'}" ok = send_loopback_notice(recipient=agent, target=target, message=msg) res.loopback_notified = ok res.loopback_detail = f"Notified @{agent}/{target}" if ok else f"Failed to notify @{agent}/{target}" return ok 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, notify_target: Optional[str] = None, notify_agent: Optional[str] = None, ) -> RedemptionResult: """Redeem invite code using Settings Menu RPA -> Settings -> Redeem Invite Code dialog. Gracefully handles missing entrypoint rows, missing input fields, and async loading races. Falls back to in-page API automatically if DOM fields are absent, and dispatches DM loopback if requested.""" 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: # If dialog failed to open, try API fallback directly api_res = self.redeem_code_api(clean_code) if api_res.success: api_res.detail = f"Settings dialog could not open; redemption completed via API fallback ({api_res.detail or ''})".strip() return api_res res = RedemptionResult( target_node=self.node, code=clean_code, success=False, status="error", reason="settings_open_failed", detail=f"Could not open Settings dialog via RPA on @{self.node}. API fallback: {api_res.detail or api_res.reason or 'failed'}", method_used="dom", field_missing=True, ) if notify_target and notify_agent: self._dispatch_loopback(res, notify_agent, notify_target) return res rpa.select_tab("General") time.sleep(0.3) # 2. Check for "Redeem invite code" entry (poll up to 2.5s for React rendering) js_find_and_click = """(() => { const dialog = document.querySelector('[role="dialog"]'); if (!dialog) return {found: false, dialog_present: 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, dialog_present: true}; } return { found: false, dialog_present: true, text: dialog.innerText || '', has_additional: (dialog.innerText || '').includes('Additional tokens') }; })()""" click_res = None deadline = time.time() + 2.5 while time.time() < deadline: click_res = cdp_evaluate(self.ws, js_find_and_click) if click_res and click_res.get("found"): break time.sleep(0.3) if not click_res or not click_res.get("found"): # "Redeem invite code" field is missing in General settings! rpa.close_settings_dialog() # Attempt automatic API fallback first api_res = self.redeem_code_api(clean_code) if api_res.success: api_res.detail = f"Redeem field was missing in Settings DOM; redeemed successfully via API fallback! ({api_res.detail or ''})".strip() return api_res # Diagnose why field is missing diag_text = click_res.get("text", "") if click_res else "" has_extra = click_res.get("has_additional", False) if click_res else False is_already = has_extra or "Additional tokens" in diag_text if not is_already: try: api_check = self.find_code_api() is_already = bool(api_check.has_redeemed) except Exception: pass if is_already: status = "already_redeemed" reason = "already_redeemed" detail = f"Node @{self.node} has already redeemed an invite code (entrypoint hidden by active Additional tokens ticker)." elif api_res.reason in ["window_expired", "already_redeemed", "invalid", "used_up"]: status = api_res.status reason = api_res.reason detail = f"Redeem invite code field not present in Settings on @{self.node}: server reports {api_res.reason} ({api_res.detail or ''})." else: status = "entrypoint_not_found" reason = "field_missing" detail = f"Redeem invite code field missing in General settings on @{self.node} (account may be past 48h onboarding window or already redeemed)." res = RedemptionResult( target_node=self.node, code=clean_code, success=False, status=status, reason=reason, detail=detail, method_used="dom", field_missing=True, ) if notify_target and notify_agent: self._dispatch_loopback(res, notify_agent, notify_target) return res # 3. Handle HatchInviteRedemptionDialog (sub-dialog opened by clicking Redeem) # Poll up to 2.5s for input box to mount js_input_code = f"""(() => {{ // Look for dialog titled "Redeem a code" or input with label const input = document.querySelector('input[aria-label="Invite code"]') || document.querySelector('[role="dialog"] input[type="text"]'); if (!input) return {{found_input: false}}; input.focus(); input.value = {json.dumps(clean_code)}; input.dispatchEvent(new Event('input', {{bubbles: true}})); input.dispatchEvent(new Event('change', {{bubbles: true}})); input.dispatchEvent(new KeyboardEvent('keydown', {{key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true}})); return {{found_input: true}}; }})()""" input_res = None deadline = time.time() + 2.5 while time.time() < deadline: input_res = cdp_evaluate(self.ws, js_input_code) if input_res and input_res.get("found_input"): break time.sleep(0.3) if not input_res or not input_res.get("found_input"): # Subdialog opened or clicked, but input box is absent! cdp_send_escape(self.ws) rpa.close_settings_dialog() # Automatic API fallback api_res = self.redeem_code_api(clean_code) if api_res.success: api_res.detail = f"Redemption input box was missing in dialog; redeemed successfully via API fallback! ({api_res.detail or ''})".strip() return api_res res = RedemptionResult( target_node=self.node, code=clean_code, success=False, status=api_res.status or "dom_input_missing", reason=api_res.reason or "input_field_missing", detail=f"Invite code input field was not found in redemption dialog on @{self.node}. API fallback: {api_res.detail or api_res.reason or 'failed'}.", method_used="dom", field_missing=True, ) if notify_target and notify_agent: self._dispatch_loopback(res, notify_agent, notify_target) return res # Input submitted; poll for outcome text time.sleep(1.2) 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 = None deadline = time.time() + 2.5 while time.time() < deadline: outcome = cdp_evaluate(self.ws, js_check_outcome) if outcome and outcome.get("status") != "unknown": break time.sleep(0.3) 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"]: res = 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", ) if notify_target and notify_agent: self._dispatch_loopback(res, notify_agent, notify_target) return res # Fallback to API check if DOM did not confirm status api_res = self.redeem_code_api(clean_code) if not api_res.success and notify_target and notify_agent: self._dispatch_loopback(api_res, notify_agent, notify_target) return api_res def redeem_code( self, code: str, method: str = "auto", notify_target: Optional[str] = None, notify_agent: Optional[str] = None, ) -> RedemptionResult: """Redeem invite code using specified method ('auto', 'dom', or 'api').""" if method == "dom": return self.redeem_code_dom(code, notify_target=notify_target, notify_agent=notify_agent) elif method == "api": res = self.redeem_code_api(code) if not res.success and notify_target and notify_agent: self._dispatch_loopback(res, notify_agent, notify_target) return res else: # Auto: Validate and attempt via API for reliability. Fallback to DOM on transport error. res = self.redeem_code_api(code) if not res.success and res.reason == "transport_failure": res = self.redeem_code_dom(code, notify_target=notify_target, notify_agent=notify_agent) elif not res.success and notify_target and notify_agent: self._dispatch_loopback(res, notify_agent, notify_target) return res 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, notify_target: Optional[str] = None, notify_agent: 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: res = { "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}.", "field_missing": True, "loopback_notified": False, } if notify_target and notify_agent: msg = f"[SALVAGE-NOTICE] Node @{blocked_node} is blocked (code: {code_to_redeem}), but no eligible peer is available. Fresh onboarding required." res["loopback_notified"] = send_loopback_notice(notify_agent, notify_target, msg) return res # 3. Redeem on eligible peer with InviteHandler(eligible_peer) as h_peer: redemption = h_peer.redeem_code( code_to_redeem, notify_target=notify_target, notify_agent=notify_agent, ) return { "success": redemption.success, "blocked_node": blocked_node, "helper_node": eligible_peer, "code_redeemed": code_to_redeem, "redemption_result": redemption.to_dict(), "field_missing": redemption.field_missing, "loopback_notified": redemption.loopback_notified, } 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("--notify-target", default=None, help="Sidechat to notify on loopback") p_redeem.add_argument("--notify-agent", default=None, help="Agent to notify on loopback") 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("--notify-target", default=None, help="Sidechat to notify on loopback") p_salvage.add_argument("--notify-agent", default=None, help="Agent to notify on loopback") 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, notify_target=getattr(args, "notify_target", None), notify_agent=getattr(args, "notify_agent", None), ) 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}") if res.loopback_notified: print(f" Loopback:{res.loopback_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, notify_target=getattr(args, "notify_target", None), notify_agent=getattr(args, "notify_agent", None), ) 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')}") if salvage_res.get("loopback_notified"): print(" Loopback: Notice sent to requesting target.") print() else: parser.print_help() if __name__ == "__main__": main()