# shellcheck shell=bash # # lib.sh — helpers for marmot-interop.sh # # All functions assume the parent script has set: # STATE_DIR absolute path to state/ directory # LOG_FILE absolute path to current run's log file # RESULTS_FILE absolute path to results table (tab-separated) # WN_BIN, WND_BIN absolute paths to wn / wnd binaries # B_SOCKET, C_SOCKET socket paths for the two daemons set -u # ------- colors -------------------------------------------------------------- if [[ -t 1 ]]; then C_RESET=$'\033[0m' C_BOLD=$'\033[1m' C_RED=$'\033[31m' C_GREEN=$'\033[32m' C_YELLOW=$'\033[33m' C_BLUE=$'\033[34m' C_CYAN=$'\033[36m' C_GREY=$'\033[90m' else C_RESET=""; C_BOLD=""; C_RED=""; C_GREEN="" C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_GREY="" fi # ------- logging primitives -------------------------------------------------- log() { printf '%s\n' "$*" | tee -a "$LOG_FILE" >&2; } banner() { printf '\n%s%s================================================================%s\n' \ "$C_BOLD" "$C_BLUE" "$C_RESET" >&2 printf '%s%s %s%s\n' "$C_BOLD" "$C_BLUE" "$1" "$C_RESET" >&2 printf '%s%s================================================================%s\n\n' \ "$C_BOLD" "$C_BLUE" "$C_RESET" >&2 printf '\n=== %s ===\n' "$1" >>"$LOG_FILE" } step() { printf '%s>%s %s\n' "$C_CYAN" "$C_RESET" "$1" >&2; printf '> %s\n' "$1" >>"$LOG_FILE"; } info() { printf '%s %s%s\n' "$C_GREY" "$1" "$C_RESET" >&2; printf ' %s\n' "$1" >>"$LOG_FILE"; } warn() { printf '%s!%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; printf '! %s\n' "$1" >>"$LOG_FILE"; } pass_msg() { printf '%s\u2713 PASS%s %s\n' "$C_GREEN" "$C_RESET" "$1" >&2; printf 'PASS %s\n' "$1" >>"$LOG_FILE"; } fail_msg() { printf '%s\u2717 FAIL%s %s\n' "$C_RED" "$C_RESET" "$1" >&2; printf 'FAIL %s\n' "$1" >>"$LOG_FILE"; } skip_msg() { printf '%s- SKIP%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; printf 'SKIP %s\n' "$1" >>"$LOG_FILE"; } # ------- result tracking ------------------------------------------------------ # Each test calls record_result [note] record_result() { local test_id="$1" status="$2" note="${3:-}" printf '%s\t%s\t%s\n' "$test_id" "$status" "$note" >>"$RESULTS_FILE" } print_summary() { local total=0 passed=0 failed=0 skipped=0 printf '\n%s%s================================================================%s\n' \ "$C_BOLD" "$C_BLUE" "$C_RESET" printf '%s%s RESULTS%s\n' "$C_BOLD" "$C_BLUE" "$C_RESET" printf '%s%s================================================================%s\n\n' \ "$C_BOLD" "$C_BLUE" "$C_RESET" if [[ ! -s "$RESULTS_FILE" ]]; then printf ' (no tests ran)\n\n' return fi while IFS=$'\t' read -r tid status note; do total=$((total+1)) case "$status" in pass) passed=$((passed+1)); col="$C_GREEN"; sym="\u2713" ;; fail) failed=$((failed+1)); col="$C_RED"; sym="\u2717" ;; skip) skipped=$((skipped+1)); col="$C_YELLOW"; sym="-" ;; *) col="$C_GREY"; sym="?" ;; esac printf ' %b %s%-44s%s %s\n' "$sym" "$col" "$tid" "$C_RESET" "$note" done <"$RESULTS_FILE" printf '\n %s%d passed%s, %s%d failed%s, %s%d skipped%s (of %d)\n\n' \ "$C_GREEN" "$passed" "$C_RESET" \ "$C_RED" "$failed" "$C_RESET" \ "$C_YELLOW" "$skipped" "$C_RESET" \ "$total" printf ' full log: %s\n\n' "$LOG_FILE" } # ------- human interaction --------------------------------------------------- # Pretty-print multi-line instructions and wait for . prompt_human() { printf '\n%s%s---- DO THIS IN AMETHYST ----%s\n' "$C_BOLD" "$C_YELLOW" "$C_RESET" >&2 printf '%s%s%s\n' "$C_YELLOW" "$1" "$C_RESET" >&2 printf '%s%s-----------------------------%s\n' "$C_BOLD" "$C_YELLOW" "$C_RESET" >&2 printf '%s[Press Enter to continue]%s ' "$C_CYAN" "$C_RESET" >&2 read -r _ printf '\n' >>"$LOG_FILE" printf 'HUMAN-PROMPT: %s\n' "$1" >>"$LOG_FILE" } # Ask the operator to confirm a UI rendering result. # Returns 0 on pass, 1 on fail, 2 on skip. confirm() { local q="$1" ans while true; do printf '%s? %s%s [p]ass / [f]ail / [s]kip: ' "$C_CYAN" "$q" "$C_RESET" >&2 read -r ans case "$(printf '%s' "$ans" | tr '[:upper:]' '[:lower:]')" in p|pass) printf 'CONFIRM PASS: %s\n' "$q" >>"$LOG_FILE"; return 0 ;; f|fail) printf 'CONFIRM FAIL: %s\n' "$q" >>"$LOG_FILE"; return 1 ;; s|skip) printf 'CONFIRM SKIP: %s\n' "$q" >>"$LOG_FILE"; return 2 ;; *) printf 'enter p, f, or s\n' >&2 ;; esac done } # ------- wn wrappers --------------------------------------------------------- wn_b() { "$WN_BIN" --socket "$B_SOCKET" ${B_NPUB:+--account "$B_NPUB"} "$@"; } wn_c() { "$WN_BIN" --socket "$C_SOCKET" ${C_NPUB:+--account "$C_NPUB"} "$@"; } # Run wn with --json and pipe through jq; aborts the test on jq parse failure. wn_b_json() { wn_b --json "$@"; } wn_c_json() { wn_c --json "$@"; } # ------- assertions ---------------------------------------------------------- expect_eq() { local actual="$1" expected="$2" what="${3:-value}" if [[ "$actual" == "$expected" ]]; then info "$what == \"$expected\"" return 0 fi fail_msg "$what mismatch: expected \"$expected\", got \"$actual\"" return 1 } expect_contains() { local haystack="$1" needle="$2" what="${3:-output}" if [[ "$haystack" == *"$needle"* ]]; then info "$what contains \"$needle\"" return 0 fi fail_msg "$what missing \"$needle\"" info "actual: $haystack" return 1 } # ------- JSON helpers -------------------------------------------------------- # Extract MLS group ID as lowercase hex from wn JSON output. # Handles both formats: # - plain hex string (from `groups list`) # - {"value":{"vec":[...]}} serde struct (from `groups create`) # - flat byte array [n, ...] (from some responses) # Plus the three wrapper shapes wn actually uses: # - {"result": {"mls_group_id": ...}} (groups create) # - {"group": {"mls_group_id": ...}, "membership": ...} (groups invites[0]) # - {"mls_group_id": ...} (bare) # Input: JSON string via stdin; optional 2nd arg = field name (default: mls_group_id) jq_group_id() { local field="${1:-mls_group_id}" jq -r --arg f "$field" ' def byte2hex: . as $n | [($n / 16 | floor), ($n % 16)] | map(if . < 10 then (48 + .) else (87 + .) end) | implode; (.group // .result // .) | (.group // .) | .[$f] | if type == "string" then . elif (type == "object" and (.value.vec != null)) then [.value.vec[] | byte2hex] | join("") elif type == "array" then [.[] | byte2hex] | join("") else empty end ' 2>/dev/null || true } # ------- polling helpers ----------------------------------------------------- # Snapshot currently-pending invites on as a comma-separated list of # gids. Use this BEFORE triggering a publish that should produce a new # welcome, then pass the result to `wait_for_invite` as the ignore list so # a stale welcome left over from a previous harness run (wnd persists # pending invites across restarts) doesn't register as the arrival we # just triggered. snapshot_invites() { local who="$1" wnfn if [[ "$who" == "B" ]]; then wnfn=wn_b else wnfn=wn_c; fi local raw raw=$("$wnfn" --json groups invites 2>/dev/null || true) # jq_group_id reads one invite at a time, so iterate the array in shell # rather than trying to handle all shapes in a single jq expression. local gids=() while IFS= read -r one; do [[ -z "$one" || "$one" == "null" ]] && continue local g g=$(printf '%s' "$one" | jq_group_id) [[ -n "$g" ]] && gids+=("$g") done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null) # bash 3.2 (stock macOS) treats "${gids[*]}" on an empty array as an # unbound reference under `set -u`, so guard the expansion. if (( ${#gids[@]} > 0 )); then local IFS=',' printf '%s' "${gids[*]}" fi } # wait_for_invite [ignore-csv] # Echoes the first pending group_id that isn't listed in # (comma-separated hex gids). Use the ignore list to skip welcomes left # pending by previous harness runs — otherwise `wait_for_invite` can # fire on a stale invite and the caller ends up driving a group the # publisher isn't a member of, which then looks like a kind:445 drop. # Returns 1 on timeout. # # Also prints a heartbeat every ~10s with: # - elapsed/remaining time # - current count of pending welcomes (total, pre-filter) # - last relevant line of wnd stderr (grep for welcome/giftwrap/subscribe/error) # so a stalled poll gives the operator something to forward. wait_for_invite() { local who="$1" timeout="${2:-60}" ignore_csv="${3:-}" deadline gid start last_hb local wnfn data_dir if [[ "$who" == "B" ]]; then wnfn=wn_b; data_dir="$B_DIR" else wnfn=wn_c; data_dir="$C_DIR"; fi start=$(date +%s) deadline=$(( start + timeout )) last_hb=$start while [[ $(date +%s) -lt $deadline ]]; do # Post-v0.2 `wn --json groups invites` returns `{"result": [...]}` # (older builds returned the bare array). Peel the wrapper when # present so a pending invite is actually detected. local raw raw=$("$wnfn" --json groups invites 2>/dev/null || true) # Walk every pending invite (not just .[0]) so we skip past stales. while IFS= read -r one; do [[ -z "$one" || "$one" == "null" ]] && continue gid=$(printf '%s' "$one" | jq_group_id) [[ -z "$gid" ]] && continue if [[ ",$ignore_csv," != *",$gid,"* ]]; then printf '%s\n' "$gid" return 0 fi done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null) # Heartbeat every ~10s. local now=$(date +%s) if (( now - last_hb >= 10 )); then local elapsed=$(( now - start )) remaining=$(( deadline - now )) local pending pending=$(printf '%s' "$raw" | jq '(.result // .) | length' 2>/dev/null || echo "?") local recent="" if [[ -f "$data_dir/logs/stderr.log" ]]; then recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \ | grep -iE 'welcome|giftwrap|gift_wrap|mls|subscribe|1059|444|error|warn' \ | tail -n 1 || true) fi info "$who wait_for_invite +${elapsed}s (remaining ${remaining}s) pending=$pending ignoring=${ignore_csv:-} tail: ${recent:-}" last_hb=$now fi sleep 2 done return 1 } # wait_for_message # Returns 0 if a message containing the substring shows up via wn messages list. wait_for_message() { local who="$1" gid="$2" needle="$3" timeout="${4:-30}" local deadline payload deadline=$(( $(date +%s) + timeout )) while [[ $(date +%s) -lt $deadline ]]; do if [[ "$who" == "B" ]]; then payload=$(wn_b_json messages list "$gid" --limit 20 2>/dev/null || true) else payload=$(wn_c_json messages list "$gid" --limit 20 2>/dev/null || true) fi if [[ -n "${payload:-}" ]] && \ printf '%s' "$payload" | jq -e --arg n "$needle" \ '(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \ >/dev/null 2>&1; then return 0 fi sleep 2 done return 1 } # wait_for_member wait_for_member() { local who="$1" gid="$2" pubkey="$3" timeout="${4:-30}" local deadline payload deadline=$(( $(date +%s) + timeout )) while [[ $(date +%s) -lt $deadline ]]; do if [[ "$who" == "B" ]]; then payload=$(wn_b_json groups members "$gid" 2>/dev/null || true) else payload=$(wn_c_json groups members "$gid" 2>/dev/null || true) fi if printf '%s' "${payload:-}" | jq -e --arg p "$pubkey" \ '(.result // .) | .[]? | select((.pubkey // .public_key // "") == $p)' \ >/dev/null 2>&1; then return 0 fi sleep 2 done return 1 } # ------- output parsing ------------------------------------------------------ # `wn` prints either JSON (with --json) or a yaml-ish "key: value" pretty form. # This helper extracts the pubkey (npub) from whichever form we got. extract_pubkey() { local raw="$1" v="" # JSON: single object v=$(printf '%s' "$raw" | jq -r '.pubkey // .npub // .public_key // empty' 2>/dev/null || true) if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi # JSON: {"result": [ {"pubkey": …}, … ]} — post-v0.2 `wn --json whoami` shape v=$(printf '%s' "$raw" | jq -r '.result[0].pubkey // .result[0].npub // .result[0].public_key // empty' 2>/dev/null || true) if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi # JSON: array of accounts (whoami may return a list) v=$(printf '%s' "$raw" | jq -r '.[0].pubkey // .[0].npub // .[0].public_key // empty' 2>/dev/null || true) if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi # yaml-ish "pubkey: npub1..." v=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*pubkey[[:space:]]*:[[:space:]]*//p' | head -n1) if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi # yaml-ish "npub: npub1..." (alternative key) v=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*npub[[:space:]]*:[[:space:]]*//p' | head -n1) if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi } # Pure-awk bech32 decoder for npub1... → 32-byte lowercase hex. Needed because # `wn users show` only resolves identities the local daemon already knows # about, so A's npub (the Amethyst account) can't be converted via wn — and a # failed conversion leaks the raw npub into hex-equality checks like the # member-list assertion in Test 02, which then fails with # "member list missing npub1…". bech32_decode_npub() { local npub="$1" [[ "$npub" == npub1* ]] || return 1 local body="${npub#npub1}" # Need at least 6 chars of checksum + some data. (( ${#body} > 6 )) || return 1 local hex hex=$(awk -v data="$body" 'BEGIN { charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" for (i = 0; i < 32; i++) map[substr(charset, i + 1, 1)] = i data = substr(data, 1, length(data) - 6) acc = 0; bits = 0; out = "" for (i = 1; i <= length(data); i++) { c = substr(data, i, 1) if (!(c in map)) exit 1 acc = acc * 32 + map[c] bits += 5 while (bits >= 8) { bits -= 8 pow = 1 for (j = 0; j < bits; j++) pow *= 2 byte = int(acc / pow) acc -= byte * pow out = out sprintf("%02x", byte) } } print out }' 2>/dev/null) || return 1 # 32-byte x-only pubkey = 64 hex chars. [[ ${#hex} -eq 64 ]] || return 1 printf '%s' "$hex" } # npub -> hex conversion. Tries `wn users show` first (handy when it works, # since it also validates the npub against the daemon's view), then falls # back to a local bech32 decode so unknown npubs still convert. Returns the # input unchanged only if both paths fail, which should never happen for a # well-formed npub. npub_to_hex() { local npub="$1" raw hex raw=$(wn_b --json users show "$npub" 2>/dev/null || true) hex=$(printf '%s' "$raw" \ | jq -r '(.result // .) | (.pubkey // .public_key // .hex // .[0]?.pubkey // .[0]?.public_key // empty)' \ 2>/dev/null || true) if [[ -z "$hex" || "$hex" == "null" ]]; then # fallback to yaml-ish output raw=$(wn_b users show "$npub" 2>/dev/null || true) hex=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*\(public_key\|hex\)[[:space:]]*:[[:space:]]*//p' | head -n1) fi if [[ -n "$hex" && "$hex" != "null" ]]; then printf '%s' "$hex" return fi if hex=$(bech32_decode_npub "$npub"); then printf '%s' "$hex" return fi printf '%s' "$npub" } # ------- state file ---------------------------------------------------------- # Simple key=value store at $STATE_DIR/run.env STATE_FILE_RUNTIME="${STATE_DIR}/run.env" save_state() { local key="$1" value="$2" : >"${STATE_FILE_RUNTIME}.tmp" if [[ -f "$STATE_FILE_RUNTIME" ]]; then grep -v "^${key}=" "$STATE_FILE_RUNTIME" >>"${STATE_FILE_RUNTIME}.tmp" || true fi printf '%s=%s\n' "$key" "$value" >>"${STATE_FILE_RUNTIME}.tmp" mv "${STATE_FILE_RUNTIME}.tmp" "$STATE_FILE_RUNTIME" } load_state() { local key="$1" [[ -f "$STATE_FILE_RUNTIME" ]] || return 1 grep "^${key}=" "$STATE_FILE_RUNTIME" 2>/dev/null | tail -n1 | cut -d= -f2- } # ------- daemon diagnostics -------------------------------------------------- # Dumps everything we know about a daemon's state. Called before polling for # invites (baseline) and on every invite-poll failure so the operator can # forward a single log file that explains what the daemon saw (or didn't). # # Output is tee'd to both stderr (for live viewing) and $LOG_FILE (for forward). dump_daemon_diagnostics() { local who="$1" tag="${2:-diagnostics}" data_dir socket wnfn npub hex if [[ "$who" == "B" ]]; then data_dir="$B_DIR"; socket="$B_SOCKET"; wnfn=wn_b npub="${B_NPUB:-?}"; hex="${B_HEX:-?}" else data_dir="$C_DIR"; socket="$C_SOCKET"; wnfn=wn_c npub="${C_NPUB:-?}"; hex="${C_HEX:-?}" fi printf '\n%s%s---- [%s] %s daemon diagnostics ----%s\n' \ "$C_BOLD" "$C_CYAN" "$tag" "$who" "$C_RESET" >&2 printf '\n==== [%s] %s daemon diagnostics ====\n' "$tag" "$who" >>"$LOG_FILE" info "$who identity npub=$npub" info "$who identity hex =$hex" info "$who socket $socket" # Relay list grouped by type — this is the critical bit for invite # debugging. whitenoise-rs subscribes to kind:1059 on Inbox relays only, # so if Amethyst doesn't see $B_NPUB's kind:10050 list pointing at these # URLs, the gift wrap will never reach B. step "$who relays (per type):" for t in nip65 inbox key_package; do local raw urls raw=$("$wnfn" --json relays list --type "$t" 2>/dev/null || true) urls=$(printf '%s' "$raw" \ | jq -r '(.result // .) | .[]? | (.url // .relay.url // empty)' 2>/dev/null \ | paste -sd',' -) info " $t: ${urls:-}" printf ' %s %s raw: %s\n' "$who" "$t" "$raw" >>"$LOG_FILE" done # Connection status per relay — shows which sockets are actually live. step "$who relay connection status:" "$wnfn" --json relays list 2>/dev/null \ | jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \ | tee -a "$LOG_FILE" >&2 || true # Daemon-level subscription health. step "$who wn debug health:" local health health=$("$wnfn" --json debug health 2>/dev/null || "$wnfn" debug health 2>/dev/null || true) printf ' %s\n' "${health:-}" | tee -a "$LOG_FILE" >&2 # Relay-control state (subscriptions, filters, planes). Truncated to keep # stderr readable; full copy still goes to $LOG_FILE. step "$who wn debug relay-control-state (truncated to stderr; full in $LOG_FILE):" local rcs rcs=$("$wnfn" --json debug relay-control-state 2>/dev/null || "$wnfn" debug relay-control-state 2>/dev/null || true) printf '%s\n' "${rcs:-}" | head -n 20 | sed 's/^/ /' >&2 printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-}" >>"$LOG_FILE" # Pending welcomes already decrypted by the daemon. Non-empty here while # wait_for_invite is polling would be a race worth noticing. step "$who pending invites:" local inv inv=$("$wnfn" --json groups invites 2>/dev/null || true) printf ' %s\n' "${inv:-}" | tee -a "$LOG_FILE" >&2 # Tail of daemon stderr — contains the live 1059/welcome processing logs. step "$who wnd stderr (last 80 lines from $data_dir/logs/stderr.log):" if [[ -f "$data_dir/logs/stderr.log" ]]; then tail -n 80 "$data_dir/logs/stderr.log" 2>/dev/null | sed 's/^/ /' | tee -a "$LOG_FILE" >&2 || true else info " (no stderr log at $data_dir/logs/stderr.log)" fi printf '%s%s---- end %s diagnostics ----%s\n\n' \ "$C_BOLD" "$C_CYAN" "$who" "$C_RESET" >&2 printf '==== end [%s] %s diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE" } # Asks the operator to capture Amethyst's MarmotDbg logcat so the script log # ends up with both sides of the pipe in one place. prompt_amethyst_logcat() { local note="${1:-paste below}" prompt_human "On the host running adb, capture Amethyst's Marmot logs: adb logcat -d -v time | grep -E 'MarmotDbg|Marmot |MlsWelcome|GiftWrap' \\ | tail -n 200 > /tmp/amethyst-marmot.log Then paste /tmp/amethyst-marmot.log contents here so the failure report has both the Amethyst-side publish log and the wn-side subscription log ($note)." } # Dump everything we know about an outbound kind:445 from a wn daemon, so # when the operator reports "Amethyst didn't see it" we can tell them which # side of the wire to investigate: # # - `messages list` output (did the daemon persist the message locally, # i.e. is this a relay-reach issue, a membership issue, or a pure # Amethyst-receive issue?) # - `groups show` metadata (which MLS-extension relays is wn using for # this group — these are the SAME relays Amethyst subscribes to, so if # the lists don't match that's the bug) # - live relay connection status # # Output is tee'd to stderr (live) and $LOG_FILE (forward). dump_outbound_diagnostics() { local who="$1" gid="$2" needle="${3:-}" tag="${4:-post-send}" local wnfn if [[ "$who" == "B" ]]; then wnfn=wn_b else wnfn=wn_c; fi printf '\n%s%s---- [%s] %s outbound diagnostics (group %.8s…) ----%s\n' \ "$C_BOLD" "$C_CYAN" "$tag" "$who" "$gid" "$C_RESET" >&2 printf '\n==== [%s] %s outbound diagnostics (group %s) ====\n' "$tag" "$who" "$gid" >>"$LOG_FILE" step "$who local view of latest messages in group" local list_raw list_raw=$("$wnfn" --json messages list "$gid" --limit 5 2>/dev/null || true) printf ' %s\n' "${list_raw:-}" >>"$LOG_FILE" printf '%s' "$list_raw" \ | jq -r '(.result // .) | .[]? | " \(.id // .event_id // "?") \((.content // .text // "") | tostring | .[0:80])"' 2>/dev/null \ | tee -a "$LOG_FILE" >&2 || true if [[ -n "$needle" ]]; then if printf '%s' "$list_raw" | jq -e --arg n "$needle" \ '(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \ >/dev/null 2>&1; then info "$who local store contains \"$needle\" — send reached wn's own DB (kind:445 went out)" else warn "$who local store does NOT contain \"$needle\" — `messages send` never persisted; check stderr.log" fi fi step "$who MLS group metadata (relays here are what Amethyst subscribes on)" local meta_raw meta_raw=$("$wnfn" --json groups show "$gid" 2>/dev/null || true) printf ' %s\n' "${meta_raw:-}" >>"$LOG_FILE" local meta_relays meta_relays=$(printf '%s' "$meta_raw" \ | jq -r '(.result // .) | (.relays // .group.relays // [])[]? | (. | tostring)' 2>/dev/null \ | paste -sd',' -) info " group relays (MLS extension): ${meta_relays:-}" local meta_name meta_epoch meta_name=$(printf '%s' "$meta_raw" | jq -r '(.result // .) | (.name // .group.name // "?")' 2>/dev/null) meta_epoch=$(printf '%s' "$meta_raw" | jq -r '(.result // .) | (.epoch // .group.epoch // "?")' 2>/dev/null) info " group name: $meta_name epoch: $meta_epoch" step "$who relay connection status right now" "$wnfn" --json relays list 2>/dev/null \ | jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \ | tee -a "$LOG_FILE" >&2 || true printf '%s%s---- end outbound diagnostics ----%s\n\n' \ "$C_BOLD" "$C_CYAN" "$C_RESET" >&2 printf '==== end [%s] %s outbound diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE" } # ------- group cleanup ------------------------------------------------------- cleanup_group() { local gid="$1" who="${2:-B}" [[ -z "$gid" ]] && return 0 if [[ "$who" == "B" ]]; then wn_b groups leave "$gid" >/dev/null 2>&1 || true else wn_c groups leave "$gid" >/dev/null 2>&1 || true fi }