diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 29ec19a04c..28d83d3c53 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -53,6 +53,10 @@ buzz channels topic --channel --topic "New topic" buzz reactions add --event --emoji "👍" buzz reactions get --event +# Huddles (read-only) +buzz huddles list --channel --active # huddles with state and start/end times +buzz huddles participants --channel --huddle + # GIFs (requires relay to advertise buzz-gif / KLIPY) buzz gifs search # trending GIFs buzz gifs search --query "celebration" # search GIFs @@ -147,6 +151,8 @@ The table below mirrors that tree for readers who are not at a terminal. | `reactions` | `add` | React to a message | | | `remove` | Remove a reaction | | | `get` | List reactions | +| `huddles` | `list` | List a channel's huddles (read-only) | +| | `participants` | Show who is in a huddle (read-only) | | `gifs` | `search` | Search or browse trending GIFs (requires relay buzz-gif support) | | | `share` | Report a selected GIF to the provider's Recents | | `dms` | `list` | List DM conversations | @@ -188,6 +194,17 @@ The table below mirrors that tree for readers who are not at a terminal. | | `patch` | Apply unified diff to memory value | | | `rm` | Publish a tombstone to delete memory | +## Huddles + +`huddles list` and `huddles participants` rebuild huddle state from the relay-signed lifecycle +events (kinds 48100–48103) with the same rules as the desktop app. They never join, start, or end +a huddle. Each scans at most 5000 newest events of the channel. If older events may be missing, +`complete` is `false` and `truncated` is `true`. A huddle with no end event is `active`, then +`stale` once its start is over an hour old; desktop shows `stale` as ended. The relay's liveness +snapshots are not served over HTTP, so `liveness_checked` is always `false`. +Rosters come from the relay's join and leave events only. Desktop's roster view also lists the +creator from the start event; the CLI lists the creator once the relay records their join. + ## Architecture ``` diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index b9b83c3498..b8e4640dd8 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -104,6 +104,13 @@ cargo clippy -p buzz-cli -- -D warnings # Expected: zero warnings ``` +When `desktop/src/features/huddle/lib/huddlePresence.ts` changes, also replay the huddle fixtures +through desktop's tracker (run `pnpm install --frozen-lockfile` in `desktop/` first): + +```bash +node --import ./desktop/test-loader.mjs --experimental-strip-types crates/buzz-cli/tests/huddle_interop.mjs +``` + --- ## 6. Live Testing — Command by Command diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 568249d082..fc32edcb9f 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -394,7 +394,7 @@ fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Optio /// normalize it to lowercase, so the archived-identities query filter and /// the author comparison in [`verify_archived_event`] agree regardless of /// the case the relay published `self` in. -fn normalize_relay_self_hex(self_hex: &str) -> Result { +pub(crate) fn normalize_relay_self_hex(self_hex: &str) -> Result { if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) { return Err(CliError::Other(format!( "relay 'self' field is not a valid 64-hex pubkey: {self_hex}" diff --git a/crates/buzz-cli/src/commands/huddle_tracker.rs b/crates/buzz-cli/src/commands/huddle_tracker.rs new file mode 100644 index 0000000000..48d77c0650 --- /dev/null +++ b/crates/buzz-cli/src/commands/huddle_tracker.rs @@ -0,0 +1,455 @@ +//! Huddle lifecycle reconstruction. +//! +//! A port of desktop's `HuddlePresenceTracker` +//! (`desktop/src/features/huddle/lib/huddlePresence.ts`) so the CLI and the app +//! agree on who is in a huddle and whether it ended. Keep the two in step; the +//! unit tests replay recorded sequences that desktop's own tracker also accepts. +//! +//! The CLI reads over HTTP, where the relay's synthetic liveness snapshots +//! (kind 48104) are not served, so generations are never authoritative here. + +use std::collections::{HashMap, VecDeque}; + +use buzz_core::kind::{ + KIND_HUDDLE_ENDED, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_STARTED, +}; +use serde_json::Value; + +use crate::error::CliError; + +/// Departed admissions remembered per session before older ones are compacted. +const MAX_DEPARTED_ADMISSIONS: usize = 1_000; + +/// Largest integer JavaScript represents exactly (`Number.MAX_SAFE_INTEGER`). +const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + +/// One huddle lifecycle event, reduced to the fields the tracker reads. +#[derive(Debug, Clone)] +pub(crate) struct LifecycleEvent { + pub id: String, + pub kind: u32, + pub pubkey: String, + pub created_at: u64, + participant: Option, + session: Option, + roster_revision: Option, + admission_id: Option, + generation: Option, +} + +impl LifecycleEvent { + /// Reduce a relay event JSON object. Content that is not the expected + /// JSON yields an event with no session, which the tracker ignores. + pub(crate) fn parse(event: &Value) -> Result { + let field = |name: &str| { + event + .get(name) + .ok_or_else(|| CliError::Other(format!("relay event missing {name}"))) + }; + let id = field("id")? + .as_str() + .ok_or_else(|| CliError::Other("relay event id is not a string".into()))? + .to_string(); + let kind = field("kind")? + .as_u64() + .and_then(|kind| u32::try_from(kind).ok()) + .ok_or_else(|| CliError::Other(format!("relay event {id} has an invalid kind")))?; + let pubkey = normalize_pubkey( + field("pubkey")? + .as_str() + .ok_or_else(|| CliError::Other(format!("relay event {id} has no pubkey")))?, + ); + let created_at = field("created_at")?.as_u64().ok_or_else(|| { + CliError::Other(format!("relay event {id} has an invalid created_at")) + })?; + let tags = field("tags")? + .as_array() + .ok_or_else(|| CliError::Other(format!("relay event {id} tags are not an array")))?; + let participant = tags + .iter() + .find(|tag| tag.get(0).and_then(Value::as_str) == Some("p")) + .and_then(|tag| tag.get(1).and_then(Value::as_str)) + .map(normalize_pubkey) + .or_else(|| (!pubkey.is_empty()).then(|| pubkey.clone())) + .filter(|participant| !participant.is_empty()); + + let content = event + .get("content") + .and_then(Value::as_str) + .and_then(|content| serde_json::from_str::(content).ok()) + .unwrap_or(Value::Null); + let text = |name: &str| { + content + .get(name) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + Ok(Self { + id, + kind, + pubkey, + created_at, + participant, + session: text("ephemeral_channel_id"), + roster_revision: content.get("roster_revision").and_then(safe_integer), + admission_id: text("admission_id"), + generation: text("generation"), + }) + } +} + +fn normalize_pubkey(value: &str) -> String { + value.trim().to_lowercase() +} + +fn safe_integer(value: &Value) -> Option { + if let Some(n) = value.as_u64() { + return (n <= MAX_SAFE_INTEGER).then_some(n); + } + let f = value.as_f64()?; + (f >= 0.0 && f.fract() == 0.0 && f <= MAX_SAFE_INTEGER as f64).then_some(f as u64) +} + +fn lifecycle_phase(kind: u32) -> u8 { + match kind { + KIND_HUDDLE_STARTED => 0, + KIND_HUDDLE_ENDED => 2, + _ => 1, + } +} + +/// Order lifecycle events as desktop does: time, then start < join/leave < +/// end, then the relay's roster revision, then event id. +pub(crate) fn compare_lifecycle_events( + left: &LifecycleEvent, + right: &LifecycleEvent, +) -> std::cmp::Ordering { + left.created_at + .cmp(&right.created_at) + .then_with(|| lifecycle_phase(left.kind).cmp(&lifecycle_phase(right.kind))) + .then_with(|| match (left.roster_revision, right.roster_revision) { + (Some(l), Some(r)) => l.cmp(&r), + _ => std::cmp::Ordering::Equal, + }) + .then_with(|| left.id.cmp(&right.id)) +} + +#[derive(Debug, Clone)] +struct Admission { + present: bool, + roster_revision: Option, + created_at: u64, + event_id: String, +} + +fn is_newer(candidate: &Admission, existing: Option<&Admission>) -> bool { + let Some(existing) = existing else { + return true; + }; + if let (Some(c), Some(e)) = (candidate.roster_revision, existing.roster_revision) { + if c != e { + return c > e; + } + } + if candidate.created_at != existing.created_at { + return candidate.created_at > existing.created_at; + } + candidate.event_id > existing.event_id +} + +/// The reconstructed state of one huddle. +#[derive(Debug, Clone)] +pub(crate) struct HuddleSession { + pub start_creator: String, + pub start_created_at: u64, + pub start_event_id: String, + pub ended_at: Option, + end: Option, + admissions: HashMap>, + legacy: HashMap, + latest_roster_revision: Option, + latest_roster_created_at: u64, + latest_roster_event_id: String, + generation: Option, + departed: VecDeque<(String, Admission)>, + compacted_revision_floor: Option, +} + +impl HuddleSession { + fn clear_roster(&mut self) { + self.admissions.clear(); + self.legacy.clear(); + self.departed.clear(); + self.compacted_revision_floor = None; + } + + fn departed_position(&self, admission_id: &str) -> Option { + self.departed.iter().position(|(id, _)| id == admission_id) + } + + /// Present participants with the time they were first admitted, oldest + /// admission first, ties broken by pubkey. + pub(crate) fn participants(&self) -> Vec<(String, u64)> { + let mut roster: HashMap<&str, u64> = HashMap::new(); + for (participant, admissions) in &self.admissions { + for admission in admissions.values().filter(|a| a.present) { + roster + .entry(participant) + .and_modify(|at| *at = (*at).min(admission.created_at)) + .or_insert(admission.created_at); + } + } + for (participant, admission) in &self.legacy { + let has_admissions = self + .admissions + .get(participant) + .is_some_and(|admissions| !admissions.is_empty()); + if !has_admissions && admission.present { + roster.insert(participant, admission.created_at); + } + } + let mut roster: Vec<(String, u64)> = roster + .into_iter() + .map(|(participant, at)| (participant.to_string(), at)) + .collect(); + roster.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + roster + } +} + +/// Reconstructs huddles from relay-authenticated lifecycle events. +pub(crate) struct HuddleTracker { + relay_self: String, + sessions: HashMap, +} + +impl HuddleTracker { + pub(crate) fn new(relay_self_pubkey: &str) -> Self { + Self { + relay_self: normalize_pubkey(relay_self_pubkey), + sessions: HashMap::new(), + } + } + + /// Replay events in desktop's lifecycle order. + pub(crate) fn apply_history(&mut self, events: &[LifecycleEvent]) { + let mut sorted: Vec<&LifecycleEvent> = events.iter().collect(); + sorted.sort_by(|a, b| compare_lifecycle_events(a, b)); + for event in sorted { + self.apply(event); + } + } + + pub(crate) fn sessions(&self) -> &HashMap { + &self.sessions + } + + fn apply(&mut self, event: &LifecycleEvent) { + if self.relay_self.is_empty() { + return; + } + let Some(session_id) = event.session.as_deref() else { + return; + }; + + if event.kind == KIND_HUDDLE_STARTED { + if event.pubkey.is_empty() { + return; + } + if let Some(existing) = self.sessions.get(session_id) { + if existing.start_event_id == event.id + || event.created_at < existing.start_created_at + || (event.created_at == existing.start_created_at + && event.id > existing.start_event_id) + { + return; + } + } + self.sessions.insert( + session_id.to_string(), + HuddleSession { + start_creator: event.pubkey.clone(), + start_created_at: event.created_at, + start_event_id: event.id.clone(), + ended_at: None, + end: None, + admissions: HashMap::new(), + legacy: HashMap::new(), + latest_roster_revision: None, + latest_roster_created_at: 0, + latest_roster_event_id: String::new(), + generation: None, + departed: VecDeque::new(), + compacted_revision_floor: None, + }, + ); + return; + } + + let Some(session) = self.sessions.get_mut(session_id) else { + return; + }; + let generation = event.generation.as_deref(); + + if event.kind == KIND_HUDDLE_ENDED { + if event.pubkey != self.relay_self && event.pubkey != session.start_creator { + return; + } + if let (Some(generation), Some(current)) = (generation, session.generation.as_deref()) { + if generation != current { + return; + } + } + if let Some(generation) = generation { + session.generation = Some(generation.to_string()); + } + let next_end = Admission { + present: false, + roster_revision: None, + created_at: event.created_at, + event_id: event.id.clone(), + }; + if !is_newer(&next_end, session.end.as_ref()) { + return; + } + session.ended_at = Some(event.created_at); + session.end = Some(next_end); + session.clear_roster(); + return; + } + + if session.end.is_some() { + return; + } + if event.kind != KIND_HUDDLE_PARTICIPANT_JOINED + && event.kind != KIND_HUDDLE_PARTICIPANT_LEFT + { + return; + } + if event.pubkey != self.relay_self { + return; + } + let Some(participant) = event.participant.as_deref() else { + return; + }; + + if let (Some(generation), Some(current)) = (generation, session.generation.as_deref()) { + if generation != current { + if event.kind == KIND_HUDDLE_PARTICIPANT_LEFT { + return; + } + session.clear_roster(); + } + } + + let present = event.kind == KIND_HUDDLE_PARTICIPANT_JOINED; + let next = Admission { + present, + roster_revision: event.roster_revision, + created_at: event.created_at, + event_id: event.id.clone(), + }; + + let is_after_latest_roster_event = event.created_at > session.latest_roster_created_at + || (event.created_at == session.latest_roster_created_at + && event.id > session.latest_roster_event_id); + if let (Some(revision), Some(latest)) = + (event.roster_revision, session.latest_roster_revision) + { + let different_generation = match (generation, session.generation.as_deref()) { + (Some(g), Some(current)) => g != current, + _ => true, + }; + if revision < latest + && is_after_latest_roster_event + && different_generation + && session + .compacted_revision_floor + .is_none_or(|floor| revision > floor) + { + session.clear_roster(); + } + } + if let Some(generation) = generation { + session.generation = Some(generation.to_string()); + } + + if !present { + if let (Some(revision), Some(floor)) = + (next.roster_revision, session.compacted_revision_floor) + { + if revision <= floor { + return; + } + } + } + + if let Some(admission_id) = event.admission_id.as_deref() { + let departed_at = session.departed_position(admission_id); + let existing = session + .admissions + .get(participant) + .and_then(|admissions| admissions.get(admission_id)) + .or_else(|| departed_at.map(|index| &session.departed[index].1)); + if !is_newer(&next, existing) { + return; + } + if existing.is_none() { + if let (Some(revision), Some(floor)) = + (next.roster_revision, session.compacted_revision_floor) + { + if revision <= floor { + return; + } + } + } + if let Some(index) = departed_at { + if present { + session.departed.remove(index); + } + } + if present { + session + .admissions + .entry(participant.to_string()) + .or_default() + .insert(admission_id.to_string(), next); + } else { + if let Some(admissions) = session.admissions.get_mut(participant) { + admissions.remove(admission_id); + if admissions.is_empty() { + session.admissions.remove(participant); + } + } + if let Some(index) = session.departed_position(admission_id) { + session.departed.remove(index); + } + session.departed.push_back((admission_id.to_string(), next)); + while session.departed.len() > MAX_DEPARTED_ADMISSIONS { + let Some((_, oldest)) = session.departed.pop_front() else { + break; + }; + if let Some(revision) = oldest.roster_revision { + session.compacted_revision_floor = Some( + session + .compacted_revision_floor + .map_or(revision, |f| f.max(revision)), + ); + } + } + } + } else { + if !is_newer(&next, session.legacy.get(participant)) { + return; + } + session.legacy.insert(participant.to_string(), next); + } + if event.roster_revision.is_some() && is_after_latest_roster_event { + session.latest_roster_revision = event.roster_revision; + session.latest_roster_created_at = event.created_at; + session.latest_roster_event_id = event.id.clone(); + } + } +} diff --git a/crates/buzz-cli/src/commands/huddles.rs b/crates/buzz-cli/src/commands/huddles.rs new file mode 100644 index 0000000000..db931b1691 --- /dev/null +++ b/crates/buzz-cli/src/commands/huddles.rs @@ -0,0 +1,926 @@ +//! Read-only huddle inspection: `buzz huddles list` and `buzz huddles participants`. +//! +//! State is derived exactly as desktop derives it (see [`super::huddle_tracker`]). +//! A huddle with no valid end event is `active` until its start is older than +//! desktop's one-hour joinable window, after which it is `stale`: desktop shows +//! it as ended, but no end event proves it. + +use std::collections::HashSet; + +use buzz_core::kind::{ + KIND_HUDDLE_ENDED, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_STARTED, +}; +use serde_json::{json, Value}; + +use super::huddle_tracker::{HuddleSession, HuddleTracker, LifecycleEvent}; +use crate::client::BuzzClient; +use crate::error::CliError; +use crate::validate::parse_uuid; + +/// Lifecycle events scanned per command. Reaching it is reported in the output. +const MAX_LIFECYCLE_EVENTS: u32 = 5_000; + +/// Matches desktop's `HUDDLE_JOINABLE_WINDOW_SECONDS`. +const JOINABLE_WINDOW_SECONDS: u64 = 60 * 60; + +/// The newest lifecycle events of one channel, and whether older ones exist. +struct Window { + events: Vec, + truncated: bool, + oldest_created_at: Option, +} + +impl Window { + fn from_events(raw: Vec, bound: u32) -> Result { + let bound = bound as usize; + let truncated = raw.len() > bound; + let mut seen = HashSet::new(); + let mut events = Vec::new(); + for value in raw.iter().take(bound) { + let event = LifecycleEvent::parse(value)?; + if seen.insert(event.id.clone()) { + events.push(event); + } + } + let oldest_created_at = events.iter().map(|event| event.created_at).min(); + Ok(Self { + events, + truncated, + oldest_created_at, + }) + } + + /// Whether every event at or after `created_at` was scanned. Events in the + /// oldest scanned second may have been cut off, so `created_at` must be + /// strictly newer than it. + fn covers(&self, created_at: u64) -> bool { + !self.truncated + || self + .oldest_created_at + .is_some_and(|oldest| created_at > oldest) + } + + fn scan_json(&self, bound: u32) -> Value { + json!({ + "truncated": self.truncated, + "events_scanned": self.events.len(), + "event_bound": bound, + "liveness_checked": false, + }) + } +} + +async fn fetch_relay_self(client: &BuzzClient) -> Result { + let raw = client.get_public("/").await?; + let info: Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("relay info document is not valid JSON: {e}")))?; + let self_hex = info.get("self").and_then(Value::as_str).ok_or_else(|| { + CliError::Other( + "relay info document has no 'self' pubkey; huddle events cannot be authenticated" + .into(), + ) + })?; + super::agents::normalize_relay_self_hex(self_hex) +} + +async fn fetch_window(client: &BuzzClient, channel: &str, bound: u32) -> Result { + let filter = json!({ + "kinds": [ + KIND_HUDDLE_STARTED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_ENDED, + ], + "#h": [channel], + }); + let probe = bound + .checked_add(1) + .ok_or_else(|| CliError::Other("query bound is too large".into()))?; + let raw = client.query_paginated(filter, probe).await?; + Window::from_events(raw, bound) +} + +fn session_state(session: &HuddleSession, now: u64) -> &'static str { + if session.ended_at.is_some() { + "ended" + } else if now.saturating_sub(session.start_created_at) > JOINABLE_WINDOW_SECONDS { + "stale" + } else { + "active" + } +} + +fn track(window: &Window, relay_self: &str) -> HuddleTracker { + let mut tracker = HuddleTracker::new(relay_self); + tracker.apply_history(&window.events); + tracker +} + +fn build_list( + window: &Window, + relay_self: &str, + channel: &str, + now: u64, + active_only: bool, + limit: u32, + bound: u32, +) -> Value { + let tracker = track(window, relay_self); + let mut sessions: Vec<(&String, &HuddleSession)> = tracker.sessions().iter().collect(); + sessions.sort_by(|a, b| { + b.1.start_created_at + .cmp(&a.1.start_created_at) + .then_with(|| a.1.start_event_id.cmp(&b.1.start_event_id)) + }); + let selected: Vec<(&String, &HuddleSession)> = sessions + .into_iter() + .filter(|(_, session)| !active_only || session_state(session, now) == "active") + .take(limit as usize) + .collect(); + + // Truncation hides only older huddles; the newest `limit` are exact when + // all of them lie wholly inside the scanned window. + let complete = !window.truncated + || (selected.len() == limit as usize + && selected + .iter() + .all(|(_, session)| window.covers(session.start_created_at))); + + let huddles: Vec = selected + .iter() + .map(|(id, session)| { + json!({ + "huddle": id, + "channel": channel, + "state": session_state(session, now), + "started_at": session.start_created_at, + "ended_at": session.ended_at, + "started_by": session.start_creator, + "start_event": session.start_event_id, + "participant_count": session.participants().len(), + }) + }) + .collect(); + + let mut out = window.scan_json(bound); + out["complete"] = json!(complete); + out["huddles"] = json!(huddles); + out +} + +fn build_participants( + window: &Window, + relay_self: &str, + channel: &str, + huddle: &str, + now: u64, + bound: u32, +) -> Result { + let tracker = track(window, relay_self); + let mut out = window.scan_json(bound); + out["channel"] = json!(channel); + out["huddle"] = json!(huddle); + + let Some(session) = tracker.sessions().get(huddle) else { + if window.truncated { + out["state"] = json!("unknown"); + out["complete"] = json!(false); + out["participants"] = json!([]); + out["note"] = json!( + "the huddle's start is older than the scanned events; its roster is not known" + ); + return Ok(out); + } + return Err(CliError::NotFound(format!( + "no huddle {huddle} found in channel {channel}" + ))); + }; + + let participants: Vec = session + .participants() + .into_iter() + .map(|(pubkey, joined_at)| json!({ "pubkey": pubkey, "joined_at": joined_at })) + .collect(); + out["state"] = json!(session_state(session, now)); + out["started_at"] = json!(session.start_created_at); + out["ended_at"] = json!(session.ended_at); + out["complete"] = json!(window.covers(session.start_created_at)); + out["participants"] = json!(participants); + Ok(out) +} + +fn now_seconds() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .map_err(|e| CliError::Other(format!("system clock is before the Unix epoch: {e}"))) +} + +async fn run_list( + client: &BuzzClient, + channel: &str, + active_only: bool, + limit: u32, + bound: u32, + now: u64, +) -> Result { + let channel = parse_uuid(channel)?.to_string(); + let relay_self = fetch_relay_self(client).await?; + let window = fetch_window(client, &channel, bound).await?; + Ok(build_list( + &window, + &relay_self, + &channel, + now, + active_only, + limit, + bound, + )) +} + +async fn run_participants( + client: &BuzzClient, + channel: &str, + huddle: &str, + bound: u32, + now: u64, +) -> Result { + let channel = parse_uuid(channel)?.to_string(); + let huddle = parse_uuid(huddle)?.to_string(); + let relay_self = fetch_relay_self(client).await?; + let window = fetch_window(client, &channel, bound).await?; + build_participants(&window, &relay_self, &channel, &huddle, now, bound) +} + +pub async fn cmd_list( + client: &BuzzClient, + channel: &str, + active_only: bool, + limit: u32, +) -> Result<(), CliError> { + let out = run_list( + client, + channel, + active_only, + limit, + MAX_LIFECYCLE_EVENTS, + now_seconds()?, + ) + .await?; + println!("{out}"); + Ok(()) +} + +pub async fn cmd_participants( + client: &BuzzClient, + channel: &str, + huddle: &str, +) -> Result<(), CliError> { + let out = run_participants( + client, + channel, + huddle, + MAX_LIFECYCLE_EVENTS, + now_seconds()?, + ) + .await?; + println!("{out}"); + Ok(()) +} + +pub async fn dispatch(cmd: crate::HuddlesCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::HuddlesCmd; + match cmd { + HuddlesCmd::List { + channel, + active, + limit, + } => cmd_list(client, &channel, active, limit).await, + HuddlesCmd::Participants { channel, huddle } => { + cmd_participants(client, &channel, &huddle).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + use axum::{body::Bytes, extract::State, routing::post, Router}; + use clap::Parser; + use nostr::Keys; + + const CHANNEL: &str = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const HUDDLE: &str = "11111111-1111-4111-8111-111111111111"; + const OTHER_HUDDLE: &str = "22222222-2222-4222-8222-222222222222"; + const RELAY: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const ALICE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const BOB: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const CAROL: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + const EVE: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + + /// Recorded sequences shared with `tests/huddle_interop.mjs`, which replays + /// the same events through desktop's own tracker. + const SEQUENCES: &str = include_str!("../../tests/fixtures/huddle_sequences.json"); + + fn event( + id: u32, + kind: u32, + pubkey: &str, + created_at: u64, + tags: &[&[&str]], + content: Value, + ) -> Value { + json!({ + "id": format!("{id:064x}"), + "kind": kind, + "pubkey": pubkey, + "created_at": created_at, + "tags": tags, + "content": content.to_string(), + "sig": "", + }) + } + + fn lifecycle(raw: &[Value]) -> Window { + Window::from_events(raw.to_vec(), MAX_LIFECYCLE_EVENTS).unwrap() + } + + fn started(id: u32, at: u64, creator: &str, huddle: &str) -> Value { + event( + id, + KIND_HUDDLE_STARTED, + creator, + at, + &[&["h", CHANNEL]], + json!({ "ephemeral_channel_id": huddle }), + ) + } + + fn joined(id: u32, at: u64, who: &str, revision: u64, admission: &str) -> Value { + event( + id, + KIND_HUDDLE_PARTICIPANT_JOINED, + RELAY, + at, + &[&["h", CHANNEL], &["p", who]], + json!({ + "ephemeral_channel_id": HUDDLE, + "roster_revision": revision, + "admission_id": admission, + "generation": "1", + }), + ) + } + + fn left(id: u32, at: u64, who: &str, revision: u64, admission: &str) -> Value { + event( + id, + KIND_HUDDLE_PARTICIPANT_LEFT, + RELAY, + at, + &[&["h", CHANNEL], &["p", who]], + json!({ + "ephemeral_channel_id": HUDDLE, + "roster_revision": revision, + "admission_id": admission, + "generation": "1", + }), + ) + } + + fn ended(id: u32, at: u64, signer: &str) -> Value { + event( + id, + KIND_HUDDLE_ENDED, + signer, + at, + &[&["h", CHANNEL]], + json!({ "ephemeral_channel_id": HUDDLE }), + ) + } + + fn roster(out: &Value) -> Vec { + out["participants"] + .as_array() + .unwrap() + .iter() + .map(|p| p["pubkey"].as_str().unwrap().to_string()) + .collect() + } + + fn participants_of(raw: &[Value], now: u64) -> Value { + build_participants( + &lifecycle(raw), + RELAY, + CHANNEL, + HUDDLE, + now, + MAX_LIFECYCLE_EVENTS, + ) + .unwrap() + } + + #[test] + fn rejoin_keeps_one_entry_and_a_stale_leave_does_not_remove_it() { + let raw = [ + started(1, 100, ALICE, HUDDLE), + joined(2, 101, BOB, 1, "adm-1"), + left(3, 102, BOB, 2, "adm-1"), + joined(4, 103, BOB, 3, "adm-2"), + // A leave for the first admission delivered after the rejoin. + left(5, 104, BOB, 2, "adm-1"), + ]; + let out = participants_of(&raw, 200); + assert_eq!(roster(&out), vec![BOB]); + assert_eq!(out["participants"][0]["joined_at"], 103); + assert_eq!(out["state"], "active"); + assert_eq!(out["complete"], true); + } + + #[test] + fn leave_without_join_adds_nobody_and_a_later_join_still_counts() { + let raw = [ + started(1, 100, ALICE, HUDDLE), + left(2, 101, BOB, 1, "adm-1"), + joined(3, 102, CAROL, 2, "adm-2"), + ]; + assert_eq!(roster(&participants_of(&raw, 200)), vec![CAROL]); + + let raw = [ + started(1, 100, ALICE, HUDDLE), + left(2, 101, BOB, 2, "adm-1"), + joined(3, 102, BOB, 1, "adm-1"), + ]; + assert_eq!( + roster(&participants_of(&raw, 200)), + Vec::::new(), + "a join older than its recorded leave must not resurrect the participant" + ); + } + + #[test] + fn ended_huddle_has_an_empty_roster_and_ignores_later_joins() { + let raw = [ + started(1, 100, ALICE, HUDDLE), + joined(2, 101, BOB, 1, "adm-1"), + ended(3, 110, RELAY), + joined(4, 120, CAROL, 2, "adm-2"), + ]; + let out = participants_of(&raw, 200); + assert_eq!(out["state"], "ended"); + assert_eq!(out["ended_at"], 110); + assert!(roster(&out).is_empty()); + } + + #[test] + fn end_from_a_stranger_is_ignored() { + let raw = [ + started(1, 100, ALICE, HUDDLE), + joined(2, 101, BOB, 1, "adm-1"), + ended(3, 110, EVE), + ]; + let out = participants_of(&raw, 200); + assert_eq!(out["state"], "active"); + assert_eq!(roster(&out), vec![BOB]); + let by_creator = [raw[0].clone(), raw[1].clone(), ended(5, 110, ALICE)]; + assert_eq!(participants_of(&by_creator, 200)["state"], "ended"); + } + + #[test] + fn missing_end_event_turns_stale_after_the_joinable_window() { + let raw = [ + started(1, 1_000, ALICE, HUDDLE), + joined(2, 1_001, BOB, 1, "adm-1"), + ]; + let fresh = participants_of(&raw, 1_000 + JOINABLE_WINDOW_SECONDS); + assert_eq!(fresh["state"], "active"); + let stale = participants_of(&raw, 1_000 + JOINABLE_WINDOW_SECONDS + 1); + assert_eq!(stale["state"], "stale"); + assert_eq!(stale["ended_at"], Value::Null); + assert_eq!(roster(&stale), vec![BOB], "stale is not an end event"); + assert_eq!(stale["liveness_checked"], false); + } + + #[test] + fn untrusted_lifecycle_events_never_reach_the_roster() { + let forged_join = event( + 9, + KIND_HUDDLE_PARTICIPANT_JOINED, + EVE, + 102, + &[&["h", CHANNEL], &["p", EVE]], + json!({ "ephemeral_channel_id": HUDDLE, "admission_id": "x" }), + ); + let raw = [started(1, 100, ALICE, HUDDLE), forged_join]; + assert!(roster(&participants_of(&raw, 200)).is_empty()); + } + + #[test] + fn same_second_join_and_leave_follow_the_relay_roster_revision() { + // Delivered newest-first with an identical timestamp; revision decides. + let raw = [ + left(4, 101, BOB, 2, "adm-1"), + joined(3, 101, BOB, 1, "adm-1"), + started(1, 100, ALICE, HUDDLE), + ]; + assert!(roster(&participants_of(&raw, 200)).is_empty()); + } + + #[test] + fn recorded_sequences_match_the_expected_rosters() { + let sequences: Vec = serde_json::from_str(SEQUENCES).unwrap(); + assert!(sequences.len() >= 4); + for sequence in sequences { + let name = sequence["name"].as_str().unwrap(); + let relay_self = sequence["relay_self"].as_str().unwrap(); + let huddle = sequence["huddle"].as_str().unwrap(); + let events = sequence["events"].as_array().unwrap().clone(); + let window = Window::from_events(events, MAX_LIFECYCLE_EVENTS).unwrap(); + let out = build_participants(&window, relay_self, CHANNEL, huddle, 0, 0).unwrap(); + let mut got = roster(&out); + got.sort(); + let want: Vec = + serde_json::from_value(sequence["expected_participants"].clone()).unwrap(); + assert_eq!(got, want, "{name}: roster"); + assert_eq!( + out["state"] == "ended", + sequence["expected_ended"].as_bool().unwrap(), + "{name}: ended" + ); + } + } + + #[test] + fn list_orders_newest_first_and_filters_active() { + let other = |id: u32, at: u64, kind: u32, signer: &str| { + event( + id, + kind, + signer, + at, + &[&["h", CHANNEL]], + json!({ "ephemeral_channel_id": OTHER_HUDDLE }), + ) + }; + let raw = [ + other(10, 50, KIND_HUDDLE_STARTED, CAROL), + other(11, 60, KIND_HUDDLE_ENDED, RELAY), + started(1, 100, ALICE, HUDDLE), + joined(2, 101, BOB, 1, "adm-1"), + ]; + let window = lifecycle(&raw); + let all = build_list( + &window, + RELAY, + CHANNEL, + 200, + false, + 20, + MAX_LIFECYCLE_EVENTS, + ); + let huddles = all["huddles"].as_array().unwrap(); + assert_eq!(huddles.len(), 2); + assert_eq!(huddles[0]["huddle"], HUDDLE); + assert_eq!(huddles[0]["state"], "active"); + assert_eq!(huddles[0]["participant_count"], 1); + assert_eq!(huddles[1]["huddle"], OTHER_HUDDLE); + assert_eq!(huddles[1]["state"], "ended"); + assert_eq!(huddles[1]["ended_at"], 60); + assert_eq!(all["complete"], true); + + let active = build_list(&window, RELAY, CHANNEL, 200, true, 20, MAX_LIFECYCLE_EVENTS); + assert_eq!(active["huddles"].as_array().unwrap().len(), 1); + + let stale = build_list( + &window, + RELAY, + CHANNEL, + 100 + JOINABLE_WINDOW_SECONDS + 1, + true, + 20, + MAX_LIFECYCLE_EVENTS, + ); + assert!( + stale["huddles"].as_array().unwrap().is_empty(), + "stale huddles are not active" + ); + + let one = build_list(&window, RELAY, CHANNEL, 200, false, 1, MAX_LIFECYCLE_EVENTS); + assert_eq!(one["huddles"].as_array().unwrap().len(), 1); + } + + #[test] + fn truncated_scan_is_reported_not_returned_as_a_full_roster() { + let raw = vec![ + joined(5, 104, CAROL, 3, "adm-3"), + joined(4, 103, BOB, 2, "adm-2"), + joined(3, 102, ALICE, 1, "adm-1"), + started(1, 100, ALICE, HUDDLE), + ]; + + let window = Window::from_events(raw.clone(), 2).unwrap(); + assert!(window.truncated); + let out = build_participants(&window, RELAY, CHANNEL, HUDDLE, 200, 2).unwrap(); + assert_eq!(out["state"], "unknown"); + assert_eq!(out["complete"], false); + assert_eq!(out["truncated"], true); + assert_eq!(out["event_bound"], 2); + assert!(roster(&out).is_empty()); + + let list = build_list(&window, RELAY, CHANNEL, 200, false, 20, 2); + assert_eq!(list["complete"], false); + + // The start is inside the window, so the roster is exact even though + // older history exists. + let mut tail = raw.clone(); + tail.push(started(6, 20, CAROL, OTHER_HUDDLE)); + tail.push(started( + 7, + 10, + CAROL, + "33333333-3333-4333-8333-333333333333", + )); + let window = Window::from_events(tail, 5).unwrap(); + assert!(window.truncated); + let out = build_participants(&window, RELAY, CHANNEL, HUDDLE, 200, 5).unwrap(); + assert_eq!(out["complete"], true); + assert_eq!(roster(&out), vec![ALICE, BOB, CAROL]); + let list = build_list(&window, RELAY, CHANNEL, 200, false, 1, 5); + assert_eq!(list["complete"], true); + assert_eq!(list["huddles"][0]["huddle"], HUDDLE); + + // An event sharing the oldest scanned second may have been cut off. + let tie = vec![ + joined(3, 100, BOB, 1, "adm-1"), + started(1, 100, ALICE, HUDDLE), + joined(2, 100, CAROL, 2, "adm-2"), + ]; + let window = Window::from_events(tie, 2).unwrap(); + let out = build_participants(&window, RELAY, CHANNEL, HUDDLE, 200, 2).unwrap(); + assert_eq!(out["complete"], false); + } + + #[test] + fn unknown_huddle_is_not_found_when_nothing_was_cut_off() { + let window = lifecycle(&[started(1, 100, ALICE, HUDDLE)]); + let err = build_participants(&window, RELAY, CHANNEL, OTHER_HUDDLE, 200, 10).unwrap_err(); + assert!(matches!(err, CliError::NotFound(_))); + } + + #[test] + fn malformed_relay_events_fail_instead_of_being_skipped() { + assert!(Window::from_events(vec![json!({"id": "x"})], 10).is_err()); + let no_session = event( + 1, + KIND_HUDDLE_STARTED, + ALICE, + 1, + &[], + json!("not an object"), + ); + let window = Window::from_events(vec![no_session], 10).unwrap(); + assert!( + build_list(&window, RELAY, CHANNEL, 2, false, 20, 10)["huddles"] + .as_array() + .unwrap() + .is_empty() + ); + } + + #[derive(Clone)] + struct FakeRelay { + events: Arc>, + filters: Arc>>, + self_pubkey: Option<&'static str>, + } + + async fn fake_relay( + events: Vec, + self_pubkey: Option<&'static str>, + ) -> (String, FakeRelay) { + let relay = FakeRelay { + events: Arc::new(events), + filters: Arc::new(Mutex::new(Vec::new())), + self_pubkey, + }; + let app = Router::new() + .route( + "/", + axum::routing::get(|State(relay): State| async move { + let body = match relay.self_pubkey { + Some(pk) => json!({ "self": pk }), + None => json!({}), + }; + ( + [("content-type", "application/nostr+json")], + body.to_string(), + ) + }), + ) + .route( + "/query", + post(|State(relay): State, body: Bytes| async move { + let filters: Vec = serde_json::from_slice(&body).unwrap(); + let filter = filters[0].clone(); + relay.filters.lock().unwrap().push(filter.clone()); + let mut matching: Vec = relay + .events + .iter() + .filter(|e| { + filter["kinds"].as_array().unwrap().contains(&e["kind"]) + && e["tags"].as_array().unwrap().iter().any(|t| { + t[0] == "h" && filter["#h"].as_array().unwrap().contains(&t[1]) + }) + }) + .cloned() + .collect(); + matching.sort_by(|a, b| { + b["created_at"] + .as_u64() + .cmp(&a["created_at"].as_u64()) + .then_with(|| a["id"].as_str().cmp(&b["id"].as_str())) + }); + if let (Some(until), Some(before)) = + (filter["until"].as_u64(), filter["before_id"].as_str()) + { + matching.retain(|e| { + let at = e["created_at"].as_u64().unwrap(); + at < until || (at == until && e["id"].as_str().unwrap() > before) + }); + } + matching.truncate(filter["limit"].as_u64().unwrap() as usize); + axum::Json(matching) + }), + ) + .with_state(relay.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), relay) + } + + fn client(base: &str) -> BuzzClient { + BuzzClient::new(base.to_string(), Keys::generate(), None, None).unwrap() + } + + #[tokio::test] + async fn commands_query_named_kinds_with_a_bound_and_report_truncation() { + let mut events = vec![started(1, 100, ALICE, HUDDLE)]; + for i in 0..6u32 { + let who = [ALICE, BOB, CAROL][i as usize % 3]; + events.push(joined( + 10 + i, + 101 + i as u64, + who, + i as u64, + &format!("adm-{i}"), + )); + } + let (base, relay) = fake_relay(events, Some(RELAY)).await; + let client = client(&base); + + let full = run_participants(&client, CHANNEL, HUDDLE, 100, 200) + .await + .unwrap(); + assert_eq!(full["complete"], true); + assert_eq!(roster(&full), vec![ALICE, BOB, CAROL]); + + let cut = run_participants(&client, CHANNEL, HUDDLE, 3, 200) + .await + .unwrap(); + assert_eq!(cut["complete"], false); + assert_eq!(cut["truncated"], true); + assert_eq!(cut["state"], "unknown"); + + let list = run_list(&client, CHANNEL, false, 20, 100, 200) + .await + .unwrap(); + assert_eq!(list["huddles"][0]["participant_count"], 3); + + let filters = relay.filters.lock().unwrap(); + assert!(!filters.is_empty()); + for filter in filters.iter() { + assert_eq!( + filter["kinds"], + json!([ + KIND_HUDDLE_STARTED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_ENDED + ]) + ); + assert_eq!(filter["#h"], json!([CHANNEL])); + assert!(filter["limit"].as_u64().unwrap() <= 500); + } + } + + #[tokio::test] + async fn relay_without_a_self_key_is_an_error_not_an_empty_roster() { + let (base, _relay) = fake_relay(vec![started(1, 100, ALICE, HUDDLE)], None).await; + let err = run_list(&client(&base), CHANNEL, false, 20, 10, 200) + .await + .unwrap_err(); + assert!(err.to_string().contains("self")); + } + + #[tokio::test] + async fn relay_info_failures_keep_their_exit_codes() { + use crate::error::exit_code; + + let failing = |status: axum::http::StatusCode| async move { + let app = Router::new().route("/", axum::routing::get(move || async move { status })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") + }; + let err = run_list( + &client(&failing(axum::http::StatusCode::INTERNAL_SERVER_ERROR).await), + CHANNEL, + false, + 20, + 10, + 200, + ) + .await + .unwrap_err(); + assert_eq!(exit_code(&err), 2, "{err}"); + let err = run_participants( + &client(&failing(axum::http::StatusCode::FORBIDDEN).await), + CHANNEL, + HUDDLE, + 10, + 200, + ) + .await + .unwrap_err(); + assert_eq!(exit_code(&err), 3, "{err}"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let dead = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let err = run_list(&client(&dead), CHANNEL, false, 20, 10, 200) + .await + .unwrap_err(); + assert_eq!(exit_code(&err), 2, "{err}"); + } + + #[tokio::test] + async fn bad_ids_are_usage_errors() { + let (base, relay) = fake_relay(vec![], Some(RELAY)).await; + let client = client(&base); + assert!(matches!( + run_list(&client, "not-a-uuid", false, 20, 10, 0).await, + Err(CliError::Usage(_)) + )); + assert!(matches!( + run_participants(&client, CHANNEL, "nope", 10, 0).await, + Err(CliError::Usage(_)) + )); + assert!(relay.filters.lock().unwrap().is_empty()); + } + + #[test] + fn arguments_parse_to_the_documented_commands() { + let cli = crate::Cli::try_parse_from([ + "buzz", + "huddles", + "list", + "--channel", + CHANNEL, + "--active", + "--limit", + "5", + ]) + .unwrap(); + assert!(matches!( + cli.command, + crate::Cmd::Huddles(crate::HuddlesCmd::List { + active: true, + limit: 5, + .. + }) + )); + assert!(crate::Cli::try_parse_from([ + "buzz", + "huddles", + "list", + "--channel", + CHANNEL, + "--limit", + "0" + ]) + .is_err()); + assert!(crate::Cli::try_parse_from([ + "buzz", + "huddles", + "participants", + "--channel", + CHANNEL + ]) + .is_err()); + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index f7a3ebe9e0..830f518f82 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -6,6 +6,8 @@ pub mod emoji; pub(crate) mod event_output; pub mod feed; pub mod gifs; +mod huddle_tracker; +pub mod huddles; pub mod issues; pub mod mem; pub mod messages; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c529db3e69..19984a0b57 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -284,6 +284,9 @@ enum Cmd { /// List, open, and manage direct messages #[command(subcommand)] Dms(DmsCmd), + /// List huddles and their participants (read-only) + #[command(subcommand)] + Huddles(HuddlesCmd), /// Look up users and manage profiles and presence #[command(subcommand)] Users(UsersCmd), @@ -1134,6 +1137,31 @@ pub enum WorkflowsCmd { }, } +#[derive(Subcommand)] +pub enum HuddlesCmd { + /// List a channel's huddles with their state and start/end times + List { + /// Channel UUID the huddles were started in + #[arg(long)] + channel: String, + /// Only huddles that are still active + #[arg(long)] + active: bool, + /// Maximum number of huddles to return, newest first + #[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u32).range(1..=200))] + limit: u32, + }, + /// Show who is currently in a huddle + Participants { + /// Channel UUID the huddle was started in + #[arg(long)] + channel: String, + /// Huddle ID (the `huddle` field of `huddles list`) + #[arg(long)] + huddle: String, + }, +} + #[derive(Subcommand)] pub enum FeedCmd { /// Get recent activity feed entries @@ -2274,6 +2302,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, Cmd::Gifs(sub) => commands::gifs::dispatch(sub, &client).await, Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await, + Cmd::Huddles(sub) => commands::huddles::dispatch(sub, &client).await, Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await, Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await, Cmd::Feed(sub) => commands::feed::dispatch(sub, &client, &cli.format).await, @@ -2464,6 +2493,7 @@ mod tests { "emoji", "feed", "gifs", + "huddles", "issues", "media", "mem", @@ -2677,6 +2707,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), + ("huddles", 2), ("issues", 6), ("media", 1), ("messages", 8), diff --git a/crates/buzz-cli/tests/fixtures/huddle_sequences.json b/crates/buzz-cli/tests/fixtures/huddle_sequences.json new file mode 100644 index 0000000000..6d8caa1be8 --- /dev/null +++ b/crates/buzz-cli/tests/fixtures/huddle_sequences.json @@ -0,0 +1,949 @@ +[ + { + "name": "rejoin_with_late_stale_leave", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000005", + "kind": 48102, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 104, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 2, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000004", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 103, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 3, \"admission_id\": \"a2\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000003", + "kind": 48102, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 102, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 2, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000002", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000001", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected_ended": false + }, + { + "name": "rejoin_on_second_device", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000009", + "kind": 48102, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 103, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 3, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000008", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 102, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 2, \"admission_id\": \"a2\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000007", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000006", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected_ended": false + }, + { + "name": "leave_without_join", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "000000000000000000000000000000000000000000000000000000000000000c", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 102, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 2, \"admission_id\": \"a2\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000000b", + "kind": 48102, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000000a", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ], + "expected_ended": false + }, + { + "name": "join_older_than_recorded_leave", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "000000000000000000000000000000000000000000000000000000000000000f", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 102, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000000e", + "kind": 48102, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 2, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000000d", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [], + "expected_ended": false + }, + { + "name": "ended_by_relay", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000013", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 120, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 2, \"admission_id\": \"a2\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000012", + "kind": 48103, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 110, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000011", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000010", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [], + "expected_ended": true + }, + { + "name": "ended_by_creator", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000016", + "kind": 48103, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 110, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000015", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000014", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [], + "expected_ended": true + }, + { + "name": "end_from_stranger_ignored", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000019", + "kind": 48103, + "pubkey": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "created_at": 110, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000018", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000017", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected_ended": false + }, + { + "name": "forged_join_ignored", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "000000000000000000000000000000000000000000000000000000000000001b", + "kind": 48101, + "pubkey": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"admission_id\": \"x\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000001a", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [], + "expected_ended": false + }, + { + "name": "same_second_revision_order", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "000000000000000000000000000000000000000000000000000000000000001e", + "kind": 48102, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 2, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000001d", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000001c", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [], + "expected_ended": false + }, + { + "name": "stale_generation_cleared_by_newer_join", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000022", + "kind": 48102, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 103, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 6, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000021", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 102, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a2\", \"generation\": \"2\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000020", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 5, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000001f", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ], + "expected_ended": false + }, + { + "name": "stale_generation_end_ignored", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000025", + "kind": 48103, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 110, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000024", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"2\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000023", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected_ended": false + }, + { + "name": "relay_restart_lower_revision_clears", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000028", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 102, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a2\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000027", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 7, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000026", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ], + "expected_ended": false + }, + { + "name": "legacy_events_without_admission", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "000000000000000000000000000000000000000000000000000000000000002c", + "kind": 48102, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 103, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000002b", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 102, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000002a", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000029", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ], + "expected_ended": false + }, + { + "name": "restart_with_new_start", + "relay_self": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "huddle": "11111111-1111-4111-8111-111111111111", + "events": [ + { + "id": "0000000000000000000000000000000000000000000000000000000000000031", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 121, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a2\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "0000000000000000000000000000000000000000000000000000000000000030", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 120, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000002f", + "kind": 48103, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 110, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000002e", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 101, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ], + [ + "p", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\", \"roster_revision\": 1, \"admission_id\": \"a1\", \"generation\": \"1\"}", + "sig": "" + }, + { + "id": "000000000000000000000000000000000000000000000000000000000000002d", + "kind": 48100, + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 100, + "tags": [ + [ + "h", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50" + ] + ], + "content": "{\"ephemeral_channel_id\": \"11111111-1111-4111-8111-111111111111\"}", + "sig": "" + } + ], + "expected_participants": [ + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ], + "expected_ended": false + } +] \ No newline at end of file diff --git a/crates/buzz-cli/tests/huddle_interop.mjs b/crates/buzz-cli/tests/huddle_interop.mjs new file mode 100644 index 0000000000..3af5e0ed51 --- /dev/null +++ b/crates/buzz-cli/tests/huddle_interop.mjs @@ -0,0 +1,24 @@ +// Replays the recorded huddle sequences through desktop's own tracker and +// checks it agrees with the rosters the CLI tests expect. +// node --import ./desktop/test-loader.mjs --experimental-strip-types \ +// crates/buzz-cli/tests/huddle_interop.mjs +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const { HuddlePresenceTracker, applyHuddleLifecycleHistory } = await import( + new URL("../../../desktop/src/features/huddle/lib/huddlePresence.ts", import.meta.url) +); +const sequences = JSON.parse( + readFileSync(new URL("./fixtures/huddle_sequences.json", import.meta.url), "utf8"), +); + +for (const sequence of sequences) { + const tracker = new HuddlePresenceTracker(sequence.relay_self); + applyHuddleLifecycleHistory(tracker, sequence.events); + const roster = [...tracker.snapshot()].sort(); + const ended = Boolean(tracker.sessions.get(sequence.huddle)?.endState); + assert.deepEqual(roster, sequence.expected_participants, `${sequence.name}: roster`); + assert.equal(ended, sequence.expected_ended, `${sequence.name}: ended`); + console.log(`ok ${sequence.name}`); +} +console.log("INTEROP OK");