Files
box/bin/invite_handler.py
T

791 lines
32 KiB
Python
Raw Normal View History

#!/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()