diff --git a/crates/omachat-ctl/src/main.rs b/crates/omachat-ctl/src/main.rs index 3c51cbd..5ddb258 100644 --- a/crates/omachat-ctl/src/main.rs +++ b/crates/omachat-ctl/src/main.rs @@ -13,7 +13,7 @@ async fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(CliError::Usage(message)) => { eprintln!( - "{message}\nusage: omachat-ctl [--socket PATH] status [--json] | fingerprint [--qr] | join GEOHASH | leave GEOHASH | send CONVERSATION TEXT | join-room RELAY GROUP [--invite CODE] | leave-room RELAY GROUP | rooms [--json] | discover-dm-relays PUBLIC_KEY | discover-nip65-relays PUBLIC_KEY | show-nip65-relays PUBLIC_KEY | discover-profile PUBLIC_KEY | show-profile PUBLIC_KEY | publish-device-profile [--json] | publish-nip65-relays [--json] | resolve-handle HANDLE [--json] | show-handle HANDLE [--json] | claim-handle HANDLE --confirm HANDLE [--json] | panic --confirm ERASE" + "{message}\nusage: omachat-ctl [--socket PATH] status [--json] | fingerprint [--qr] | join GEOHASH | leave GEOHASH | send CONVERSATION TEXT | join-room RELAY GROUP [--invite CODE] | leave-room RELAY GROUP | rooms [--json] | room-members RELAY GROUP [--json] | discover-dm-relays PUBLIC_KEY | discover-nip65-relays PUBLIC_KEY | show-nip65-relays PUBLIC_KEY | discover-profile PUBLIC_KEY | show-profile PUBLIC_KEY | publish-device-profile [--json] | publish-nip65-relays [--json] | resolve-handle HANDLE [--json] | show-handle HANDLE [--json] | claim-handle HANDLE --confirm HANDLE [--json] | panic --confirm ERASE" ); ExitCode::from(2) } @@ -147,6 +147,20 @@ fn parse_command(arguments: &[std::ffi::OsString]) -> Result<(Command, OutputMod )), ["rooms"] => Ok((Command::ListRooms, OutputMode::Human)), ["rooms", "--json"] => Ok((Command::ListRooms, OutputMode::Json)), + ["room-members", relay, group_id] => Ok(( + Command::RoomMembers { + relay: (*relay).into(), + group_id: (*group_id).into(), + }, + OutputMode::Human, + )), + ["room-members", relay, group_id, "--json"] => Ok(( + Command::RoomMembers { + relay: (*relay).into(), + group_id: (*group_id).into(), + }, + OutputMode::Json, + )), ["discover-dm-relays", public_key] => Ok(( Command::DiscoverDmRelays { public_key: (*public_key).into(), @@ -361,6 +375,21 @@ mod tests { parse_command(&args(&["rooms", "--json"])).unwrap_or_else(|_| panic!("rooms")); assert_eq!(command, Command::ListRooms); assert!(matches!(mode, OutputMode::Json)); + let (command, mode) = parse_command(&args(&[ + "room-members", + "wss://r.example", + "omarchy", + "--json", + ])) + .unwrap_or_else(|_| panic!("room-members")); + assert_eq!( + command, + Command::RoomMembers { + relay: "wss://r.example".into(), + group_id: "omarchy".into(), + } + ); + assert!(matches!(mode, OutputMode::Json)); assert!(parse_command(&args(&["join-room", "wss://r.example"])).is_err()); assert!(parse_command(&args(&["join-room", "wss://r.example", "g", "--invite"])).is_err()); } diff --git a/crates/omachat-proto/src/ipc.rs b/crates/omachat-proto/src/ipc.rs index ebfa45a..39e18b7 100644 --- a/crates/omachat-proto/src/ipc.rs +++ b/crates/omachat-proto/src/ipc.rs @@ -83,6 +83,11 @@ pub enum Command { }, /// Describe every configured room relay and its joined rooms. ListRooms, + /// Show the relay-published membership view for one NIP-29 room. + RoomMembers { + relay: String, + group_id: String, + }, Panic { confirmation: String, }, @@ -226,6 +231,11 @@ enum StrictRequestWire { version: u16, id: String, }, + RoomMembers { + version: u16, + id: String, + params: RoomParams, + }, Panic { version: u16, id: String, @@ -432,6 +442,11 @@ impl From for Request { params: RoomParams { relay, group_id }, } => (version, id, Command::LeaveRoom { relay, group_id }), StrictRequestWire::ListRooms { version, id } => (version, id, Command::ListRooms), + StrictRequestWire::RoomMembers { + version, + id, + params: RoomParams { relay, group_id }, + } => (version, id, Command::RoomMembers { relay, group_id }), StrictRequestWire::Panic { version, id, diff --git a/crates/omachat-proto/tests/ipc.rs b/crates/omachat-proto/tests/ipc.rs index 2723bd9..ca89e19 100644 --- a/crates/omachat-proto/tests/ipc.rs +++ b/crates/omachat-proto/tests/ipc.rs @@ -129,6 +129,13 @@ fn requests_preserve_the_flat_wire_format() { Command::ListRooms, r#"{"version":1,"id":"request","method":"list-rooms"}"#, ), + ( + Command::RoomMembers { + relay: "wss://rooms.example".into(), + group_id: "omarchy".into(), + }, + r#"{"version":1,"id":"request","method":"room-members","params":{"relay":"wss://rooms.example","group_id":"omarchy"}}"#, + ), ( Command::Panic { confirmation: "confirm".into(), diff --git a/crates/omachat-tui/src/lib.rs b/crates/omachat-tui/src/lib.rs index 42d7243..5255875 100644 --- a/crates/omachat-tui/src/lib.rs +++ b/crates/omachat-tui/src/lib.rs @@ -229,6 +229,12 @@ pub fn parse_input( })) } (Some("rooms"), None, None) => Ok(Some(Command::ListRooms)), + (Some("room-members"), Some(relay), Some(group_id)) if !group_id.contains(' ') => { + Ok(Some(Command::RoomMembers { + relay: relay.into(), + group_id: group_id.into(), + })) + } (Some("quit" | "detach"), None, None) => Ok(None), _ => Err("unknown or incomplete command".into()), }; @@ -271,6 +277,13 @@ mod room_command_tests { })) ); assert_eq!(parse_input("/rooms", None), Ok(Some(Command::ListRooms))); + assert_eq!( + parse_input("/room-members wss://r.example omarchy", None), + Ok(Some(Command::RoomMembers { + relay: "wss://r.example".into(), + group_id: "omarchy".into(), + })) + ); assert_eq!( parse_input("hello", Some("room:aa:omarchy")), Ok(Some(Command::Send { diff --git a/crates/omachatd/src/core.rs b/crates/omachatd/src/core.rs index 6fa8c80..16beb36 100644 --- a/crates/omachatd/src/core.rs +++ b/crates/omachatd/src/core.rs @@ -1711,6 +1711,7 @@ impl DaemonCore { } => self.join_room(&relay, group_id, invite_code).await, Command::LeaveRoom { relay, group_id } => self.leave_room(&relay, group_id).await, Command::ListRooms => self.list_rooms().await, + Command::RoomMembers { relay, group_id } => self.room_members(&relay, group_id).await, Command::Subscribe { topics } => Ok(serde_json::json!({"topics": topics})), Command::Panic { .. } | Command::Hello { .. } => Err(CoreError::InvalidCommand), } @@ -2799,6 +2800,23 @@ impl DaemonCore { Ok(serde_json::json!({ "relays": rooms.describe_all().await })) } + async fn room_members( + &self, + relay: &str, + group_id: String, + ) -> Result { + if group_id.is_empty() || group_id.len() > 128 { + return Err(CoreError::Room(crate::RoomError::InvalidGroup)); + } + self.rooms_handle()? + .relay(relay) + .cloned() + .ok_or(CoreError::RoomRelayUnknown)? + .members(group_id) + .await + .map_err(CoreError::Room) + } + async fn send_room_message( &self, relay_pubkey: &str, diff --git a/crates/omachatd/src/room_service.rs b/crates/omachatd/src/room_service.rs index 36cfdc3..c987047 100644 --- a/crates/omachatd/src/room_service.rs +++ b/crates/omachatd/src/room_service.rs @@ -217,6 +217,10 @@ enum RoomCommand { Describe { reply: oneshot::Sender, }, + Members { + group_id: String, + reply: oneshot::Sender>, + }, } /// One configured relay as seen by the core. @@ -295,6 +299,15 @@ impl RelayHandle { .await .unwrap_or_else(|_| json!({"relay": self.url, "status": "stopped"})) } + + pub async fn members(&self, group_id: String) -> Result { + let (reply, receiver) = oneshot::channel(); + self.commands + .send(RoomCommand::Members { group_id, reply }) + .await + .map_err(|_| RoomError::Stopped)?; + receiver.await.map_err(|_| RoomError::Stopped)? + } } /// Cloneable view over every room relay actor. @@ -716,7 +729,8 @@ impl RelayActor { match command { RoomCommand::Join { reply, .. } | RoomCommand::Leave { reply, .. } - | RoomCommand::Publish { reply, .. } => { + | RoomCommand::Publish { reply, .. } + | RoomCommand::Members { reply, .. } => { let _ = reply.send(Err(unavailable())); } RoomCommand::Describe { reply } => { @@ -788,6 +802,9 @@ impl RelayActor { RoomCommand::Describe { reply } => { let _ = reply.send(self.describe(subscriptions, state, vault.generation(), source)); } + RoomCommand::Members { group_id, reply } => { + let _ = reply.send(room_members(&self.url, state, &group_id)); + } } } @@ -1229,13 +1246,25 @@ fn room_summary( "lifecycle": lifecycle, "subscribed": subscribed, "member_count": group.map_or(0, |group| { - let membership = group.membership(); - membership - .snapshot() - .records() - .iter() - .filter(|record| membership.is_member(record.pubkey())) - .count() + let mut pubkeys = BTreeSet::new(); + if let Some(roster) = group.members() { + pubkeys.extend( + roster + .principals() + .iter() + .map(|member| member.pubkey().to_owned()), + ); + } + pubkeys.extend( + group + .membership() + .snapshot() + .records() + .iter() + .filter(|record| record.is_member()) + .map(|record| record.pubkey().to_owned()), + ); + pubkeys.len() }), "admin_count": group.and_then(|group| group.admins()).map_or(0, |roster| roster.principals().len()), "pinned_count": group.and_then(|group| group.pins()).map_or(0, |pins| pins.pins().len()), @@ -1244,6 +1273,85 @@ fn room_summary( }) } +fn room_members(url: &str, state: &RelayRoomState, group_id: &str) -> Result { + let group = state.group(group_id).ok_or(RoomError::UnknownRoom)?; + let membership = group.membership().snapshot(); + let published = group.members(); + let admins = group.admins(); + let mut pubkeys = BTreeSet::new(); + if let Some(roster) = published { + pubkeys.extend( + roster + .principals() + .iter() + .map(|member| member.pubkey().to_owned()), + ); + } + pubkeys.extend( + membership + .records() + .iter() + .filter(|record| record.is_member()) + .map(|record| record.pubkey().to_owned()), + ); + let members = pubkeys + .into_iter() + .map(|pubkey| { + let published = published.and_then(|roster| { + roster + .principals() + .iter() + .find(|principal| principal.pubkey() == pubkey.as_str()) + }); + let moderation = membership + .records() + .iter() + .find(|record| record.pubkey() == pubkey.as_str() && record.is_member()); + let admin = admins.and_then(|roster| { + roster + .principals() + .iter() + .find(|principal| principal.pubkey() == pubkey.as_str()) + }); + json!({ + "pubkey": pubkey, + "published_by_relay": published.is_some(), + "published_roles": published.map_or(&[][..], |principal| principal.roles()), + "moderation_member": moderation.is_some(), + "moderation_roles": moderation.map_or(&[][..], |record| record.roles()), + "relay_admin": admin.is_some(), + "relay_admin_roles": admin.map_or(&[][..], |principal| principal.roles()), + "moderator_pubkey": moderation.map(|record| record.moderator_pubkey()), + "source_event_id": moderation.map(|record| record.source_event_id()), + "updated_at": moderation.map(|record| record.created_at()), + }) + }) + .collect::>(); + let role_definitions = group.roles().map_or_else(Vec::new, |roles| { + roles + .roles() + .iter() + .map(|role| { + json!({ + "name": role.name(), + "description": role.description(), + }) + }) + .collect() + }); + Ok(json!({ + "conversation": room_conversation_id(state.relay_pubkey(), group_id), + "relay": url, + "relay_pubkey": state.relay_pubkey(), + "group_id": group_id, + "authorization": "relay-policy-only", + "completeness": "relay-published-may-be-partial", + "members": members, + "observed_member_count": members.len(), + "role_definitions": role_definitions, + })) +} + enum Reduction { Unchanged, Changed, @@ -1312,6 +1420,7 @@ pub enum RoomError { Unavailable { status: String, detail: Value }, NotJoined, InvalidGroup, + UnknownRoom, InvalidEvent, Subscription(String), Publish(String), @@ -1327,6 +1436,7 @@ impl fmt::Display for RoomError { } Self::NotJoined => formatter.write_str("room is not joined on this relay"), Self::InvalidGroup => formatter.write_str("room group ID is invalid"), + Self::UnknownRoom => formatter.write_str("room has no authenticated state"), Self::InvalidEvent => formatter.write_str("event kind does not match the room action"), Self::Subscription(detail) => write!(formatter, "room subscription failed: {detail}"), Self::Publish(detail) => { diff --git a/crates/omachatd/tests/rooms.rs b/crates/omachatd/tests/rooms.rs index 28d46dd..ea2b0d9 100644 --- a/crates/omachatd/tests/rooms.rs +++ b/crates/omachatd/tests/rooms.rs @@ -271,6 +271,21 @@ async fn rooms_join_receive_send_persist_and_restore() { ], "", ), + signed( + &RELAY_SECRET, + 39001, + vec![ + tag(&["d", "omarchy"]), + tag(&["p", &pubkey(&FORGER_SECRET), "admin"]), + ], + "", + ), + signed( + &RELAY_SECRET, + 39002, + vec![tag(&["d", "omarchy"]), tag(&["p", &pubkey(&FORGER_SECRET)])], + "", + ), signed( &FORGER_SECRET, 39000, @@ -361,6 +376,22 @@ async fn rooms_join_receive_send_persist_and_restore() { assert_eq!(room["private"], false); assert_eq!(room["subscribed"], true); assert_eq!(listed["relays"][0]["identity_source"], "self"); + let members = request( + &core, + Command::RoomMembers { + relay: url.clone(), + group_id: "omarchy".into(), + }, + ) + .await + .expect("members"); + assert_eq!(members["authorization"], "relay-policy-only"); + assert_eq!(members["completeness"], "relay-published-may-be-partial"); + assert_eq!(members["observed_member_count"], 1); + assert_eq!(members["members"][0]["pubkey"], pubkey(&FORGER_SECRET)); + assert_eq!(members["members"][0]["published_by_relay"], true); + assert_eq!(members["members"][0]["relay_admin"], true); + assert_eq!(members["members"][0]["relay_admin_roles"], json!(["admin"])); // Sending into the room publishes a signed kind 9 through that relay. let sent = request( diff --git a/packaging/completions/_omachat-ctl b/packaging/completions/_omachat-ctl index 193fd32..cec29e3 100644 --- a/packaging/completions/_omachat-ctl +++ b/packaging/completions/_omachat-ctl @@ -1,9 +1,9 @@ #compdef omachat-ctl _arguments '--socket[daemon socket]:path:_files' \ - '1:command:(status fingerprint join leave send join-room leave-room rooms panic)' \ + '1:command:(status fingerprint join leave send join-room leave-room rooms room-members panic)' \ '*::argument:->args' case $words[2] in - status|rooms) _arguments '--json[emit compact JSON]' ;; + status|rooms|room-members) _arguments '--json[emit compact JSON]' ;; join-room) _arguments '--invite[invite code]:code:' ;; fingerprint) _arguments '--qr[render ANSI QR code]' ;; panic) _arguments '--confirm[confirm destructive erase]:confirmation:(ERASE)' ;; diff --git a/packaging/completions/omachat-ctl.bash b/packaging/completions/omachat-ctl.bash index 32a88d3..5cef9e6 100644 --- a/packaging/completions/omachat-ctl.bash +++ b/packaging/completions/omachat-ctl.bash @@ -1,8 +1,8 @@ _omachat_ctl() { local current="${COMP_WORDS[COMP_CWORD]}" if [[ $COMP_CWORD -eq 1 ]]; then - COMPREPLY=($(compgen -W '--socket status fingerprint join leave send join-room leave-room rooms panic' -- "$current")) - elif [[ ${COMP_WORDS[1]} == status || ${COMP_WORDS[1]} == rooms ]]; then + COMPREPLY=($(compgen -W '--socket status fingerprint join leave send join-room leave-room rooms room-members panic' -- "$current")) + elif [[ ${COMP_WORDS[1]} == status || ${COMP_WORDS[1]} == rooms || ${COMP_WORDS[1]} == room-members ]]; then COMPREPLY=($(compgen -W '--json' -- "$current")) elif [[ ${COMP_WORDS[1]} == join-room ]]; then COMPREPLY=($(compgen -W '--invite' -- "$current")) diff --git a/packaging/completions/omachat-ctl.fish b/packaging/completions/omachat-ctl.fish index d3ea7e3..05d7d5d 100644 --- a/packaging/completions/omachat-ctl.fish +++ b/packaging/completions/omachat-ctl.fish @@ -1,7 +1,8 @@ complete -c omachat-ctl -l socket -r -d 'Daemon socket path' -complete -c omachat-ctl -n '__fish_use_subcommand' -a 'status fingerprint join leave send join-room leave-room rooms panic' +complete -c omachat-ctl -n '__fish_use_subcommand' -a 'status fingerprint join leave send join-room leave-room rooms room-members panic' complete -c omachat-ctl -n '__fish_seen_subcommand_from status' -l json -d 'Emit compact JSON' complete -c omachat-ctl -n '__fish_seen_subcommand_from fingerprint' -l qr -d 'Render ANSI QR code' complete -c omachat-ctl -n '__fish_seen_subcommand_from panic' -l confirm -a ERASE -d 'Confirm destructive erase' complete -c omachat-ctl -n '__fish_seen_subcommand_from rooms' -l json -d 'Emit compact JSON' +complete -c omachat-ctl -n '__fish_seen_subcommand_from room-members' -l json -d 'Emit compact JSON' complete -c omachat-ctl -n '__fish_seen_subcommand_from join-room' -l invite -r -d 'Invite code' diff --git a/packaging/man/omachat-ctl.1 b/packaging/man/omachat-ctl.1 index 032c14d..abaffe8 100644 --- a/packaging/man/omachat-ctl.1 +++ b/packaging/man/omachat-ctl.1 @@ -41,6 +41,11 @@ Describe every configured room relay: its verified signing identity, how that identity was obtained, connection state, and the joined rooms with their relay-published metadata. .TP +.B room-members RELAY GROUP [--json] +Show the relay-published membership view, role labels, relay-admin state, and +moderation provenance for a room. This view may be partial and does not grant +client-side authorization; relay policy remains authoritative. +.TP .B panic --confirm ERASE Cryptographically erase local state and stop the daemon. .SH EXIT STATUS diff --git a/packaging/man/omachat.1 b/packaging/man/omachat.1 index 0df4cea..58e826d 100644 --- a/packaging/man/omachat.1 +++ b/packaging/man/omachat.1 @@ -8,6 +8,7 @@ omachat \- OmaChat terminal client Opens the bounded ANSI terminal interface and connects to the per-user daemon. Commands include /join, /leave, /who, /block, /send, /join-room RELAY GROUP [CODE], /leave-room RELAY GROUP, /rooms, +/room-members RELAY GROUP, /panic ERASE, and /detach. Panic erasure is destructive and cannot remove copies already held by relays, peers, backups, or filesystem snapshots.