mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
feat(cli): list huddles and their participants (#8830)
## Summary This change adds two read-only commands to the Buzz CLI. - `buzz huddles list --channel <uuid> [--active] [--limit n]` lists the huddles of a channel. - `buzz huddles participants --channel <uuid> --huddle <id>` shows who is in a huddle now. The commands do not join, start, or end a huddle. They do not touch audio. They do not write to the relay. ## Design The CLI rebuilds huddle state from relay-signed lifecycle events (kinds 48100 to 48103). It uses a Rust port of desktop's `HuddlePresenceTracker`. The CLI and the app therefore agree on rosters. The port follows these desktop rules: - Only the relay can sign a join or a leave. - Only the relay or the huddle creator can end a huddle. - Events replay in desktop's order: time, then start, join/leave, end, then relay roster revision, then event id. - Each admission has an ID. A late leave for an old admission does not remove a rejoined participant. - A join from a newer generation replaces the older roster. A leave from an older generation is ignored. A huddle has one of three states: - `active`: no end event, and the start is less than one hour old. - `ended`: a valid end event exists. - `stale`: no end event, and the start is more than one hour old. Desktop shows this state as ended. The CLI keeps it separate because no end event proves it. Every query names the four lifecycle kinds and the `#h` channel. A command scans at most 5000 newest events. The output always has `complete`, `truncated`, `events_scanned`, and `event_bound`. - If the huddle start is inside the scan, the roster is exact. `complete` is `true`. - If the start is not inside the scan, `participants` returns `state: "unknown"` and `complete: false`. It does not return a partial roster. - A huddle that does not exist, with no truncation, returns exit code 1. Deviations from desktop and from CLI convention: - The HTTP `/query` endpoint does not serve liveness events (kind 48104). Only the WebSocket REQ path creates them. The CLI cannot check liveness. Every output has `liveness_checked: false`. Generations are never authoritative in the CLI. - Desktop keeps the roster in a Set, so its order is not defined. The CLI sorts by join time, then by pubkey. - Desktop also adds backing-channel members as a fallback roster. The CLI does not. It reports only what the lifecycle events show. - Desktop's roster view lists the creator from the start event. The CLI lists the creator only after the relay records their join. Desktop's roster view also uses a second event comparator. The CLI uses the presence tracker's comparator. - The commands return one JSON object, not a bare array. The object needs room for the truncation fields. Silent truncation would break Rule 1. The change also updates `crates/buzz-cli/README.md`. This PR does not change `desktop/` (deprecated; see #8840). ## Testing What ran: - `cargo test -p buzz-cli -p buzz-sdk`: 518 and 322 tests passed. - `cargo clippy -p buzz-cli -p buzz-sdk --all-targets -- -D warnings`: clean. - `cargo fmt --all --check`: clean. - `just file-size-check`: clean. The new tests call the production functions. A test also checks exit codes for relay-info failures: a 500 and an unreachable relay exit 2, and a 403 exits 3. They parse arguments, build output from events, and run `run_list` and `run_participants` against a local fake relay. The fake relay serves NIP-11 and `/query` with the same cursor paging as the real bridge. The tests cover rejoin, leave without join, ended huddle, stale generation, bad end signer, forged join, same-second ordering, and the event bound. Interop evidence: - `crates/buzz-cli/tests/fixtures/huddle_sequences.json` holds 14 recorded event sequences. - The Rust tests check each sequence against the expected roster and ended state. - `crates/buzz-cli/tests/huddle_interop.mjs` replays the same file through desktop's own `HuddlePresenceTracker` under node. All 14 sequences pass with `INTEROP OK`. - Run it with: `node --import ./desktop/test-loader.mjs --experimental-strip-types crates/buzz-cli/tests/huddle_interop.mjs` Mutation checks. I broke each guard once and confirmed a test failed. I then restored the code. - Relay-only author check on join and leave. - End signer check. - Ignore events after an end. - Roster revision comparison. - Ignore a leave from an older generation. - One-hour stale window. - Tie rule for the oldest scanned second. - Unknown state on truncation. - The truncation flag. - Kinds in the query filter. - `--active` filter. - Required relay `self` pubkey. All 12 mutations failed at least one test. A 13th mutation, which wraps relay-info errors as `Other`, also fails a test. No live relay was used. No event was written. ## Manual test steps You need a relay URL, a key, and a channel UUID where someone has run a huddle. 1. Build the CLI: `cargo build --release -p buzz-cli` 2. Set `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY`. 3. Run `./target/release/buzz huddles list --channel <uuid>`. - Success: the output is one JSON object with a `huddles` array. Newest huddle is first. Each huddle has `state`, `started_at`, and `ended_at`. `complete` is `true`. 4. Start a huddle in the desktop app. Run `./target/release/buzz huddles list --channel <uuid> --active`. - Success: the new huddle appears with `state: "active"`. 5. Copy its `huddle` value. Run `./target/release/buzz huddles participants --channel <uuid> --huddle <huddle-id>`. - Success: `participants` lists the same people as the desktop huddle window. 6. Have one person leave, then run the command again. - Success: that person is gone from `participants`. 7. End the huddle. Run the `participants` command again. - Success: `state` is `ended`, `ended_at` has a time, and `participants` is empty. 8. Run the `participants` command with a random UUID as `--huddle`. - Success: the command exits with code 1 and prints a not-found error. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: bmcanally <bmcanally@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,10 @@ buzz channels topic --channel <uuid> --topic "New topic"
|
||||
buzz reactions add --event <event-id> --emoji "👍"
|
||||
buzz reactions get --event <event-id>
|
||||
|
||||
# Huddles (read-only)
|
||||
buzz huddles list --channel <uuid> --active # huddles with state and start/end times
|
||||
buzz huddles participants --channel <uuid> --huddle <huddle-id>
|
||||
|
||||
# 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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, CliError> {
|
||||
pub(crate) fn normalize_relay_self_hex(self_hex: &str) -> Result<String, CliError> {
|
||||
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}"
|
||||
|
||||
455
crates/buzz-cli/src/commands/huddle_tracker.rs
Normal file
455
crates/buzz-cli/src/commands/huddle_tracker.rs
Normal file
@@ -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<String>,
|
||||
session: Option<String>,
|
||||
roster_revision: Option<u64>,
|
||||
admission_id: Option<String>,
|
||||
generation: Option<String>,
|
||||
}
|
||||
|
||||
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<Self, CliError> {
|
||||
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::<Value>(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<u64> {
|
||||
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<u64>,
|
||||
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<u64>,
|
||||
end: Option<Admission>,
|
||||
admissions: HashMap<String, HashMap<String, Admission>>,
|
||||
legacy: HashMap<String, Admission>,
|
||||
latest_roster_revision: Option<u64>,
|
||||
latest_roster_created_at: u64,
|
||||
latest_roster_event_id: String,
|
||||
generation: Option<String>,
|
||||
departed: VecDeque<(String, Admission)>,
|
||||
compacted_revision_floor: Option<u64>,
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
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<String, HuddleSession>,
|
||||
}
|
||||
|
||||
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<String, HuddleSession> {
|
||||
&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();
|
||||
}
|
||||
}
|
||||
}
|
||||
926
crates/buzz-cli/src/commands/huddles.rs
Normal file
926
crates/buzz-cli/src/commands/huddles.rs
Normal file
@@ -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<LifecycleEvent>,
|
||||
truncated: bool,
|
||||
oldest_created_at: Option<u64>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
fn from_events(raw: Vec<Value>, bound: u32) -> Result<Self, CliError> {
|
||||
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<String, CliError> {
|
||||
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<Window, CliError> {
|
||||
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<Value> = 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<Value, CliError> {
|
||||
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<Value> = 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<u64, CliError> {
|
||||
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<Value, CliError> {
|
||||
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<Value, CliError> {
|
||||
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<String> {
|
||||
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::<String>::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<Value> = 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<String> =
|
||||
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<Vec<Value>>,
|
||||
filters: Arc<Mutex<Vec<Value>>>,
|
||||
self_pubkey: Option<&'static str>,
|
||||
}
|
||||
|
||||
async fn fake_relay(
|
||||
events: Vec<Value>,
|
||||
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<FakeRelay>| 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<FakeRelay>, body: Bytes| async move {
|
||||
let filters: Vec<Value> = serde_json::from_slice(&body).unwrap();
|
||||
let filter = filters[0].clone();
|
||||
relay.filters.lock().unwrap().push(filter.clone());
|
||||
let mut matching: Vec<Value> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
949
crates/buzz-cli/tests/fixtures/huddle_sequences.json
vendored
Normal file
949
crates/buzz-cli/tests/fixtures/huddle_sequences.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
24
crates/buzz-cli/tests/huddle_interop.mjs
Normal file
24
crates/buzz-cli/tests/huddle_interop.mjs
Normal file
@@ -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");
|
||||
Reference in New Issue
Block a user