791 lines
32 KiB
Python
Executable File
791 lines
32 KiB
Python
Executable File
#!/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()
|