Files
amethyst/tools/marmot-interop/lib.sh
T
Claude d1ea9c39e8 fix(marmot-interop): guard empty-array expansion in snapshot_invites
bash 3.2 (stock on macOS) raises "unbound variable" on "${gids[*]}"
when the array is empty under `set -u`. The happy-path sanity check
still worked (the array was populated), but the shell error aborted
execution before the actual test cases ran on a fresh state/ directory.

Only expand when the array has at least one entry.
2026-04-22 18:53:17 +00:00

586 lines
25 KiB
Bash

# 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 <test-id> <pass|fail|skip> [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 <Enter>.
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 <B|C> 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 <B|C> <timeout-seconds> [ignore-csv]
# Echoes the first pending group_id that isn't listed in <ignore-csv>
# (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:-<none>} tail: ${recent:-<no relevant wnd log yet>}"
last_hb=$now
fi
sleep 2
done
return 1
}
# wait_for_message <B|C> <group_id> <substring> <timeout>
# 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 <B|C> <group_id> <pubkey-hex> <timeout>
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:-<none>}"
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:-<no output>}" | 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:-<no output>}" | head -n 20 | sed 's/^/ /' >&2
printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-<no output>}" >>"$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:-<no output>}" | 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:-<no output>}" >>"$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:-<no output>}" >>"$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:-<none — Amethyst will fall back to homeRelays>}"
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
}