Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion crates/omachat-ctl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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());
}
Expand Down
15 changes: 15 additions & 0 deletions crates/omachat-proto/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -226,6 +231,11 @@ enum StrictRequestWire {
version: u16,
id: String,
},
RoomMembers {
version: u16,
id: String,
params: RoomParams,
},
Panic {
version: u16,
id: String,
Expand Down Expand Up @@ -432,6 +442,11 @@ impl From<StrictRequestWire> 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,
Expand Down
7 changes: 7 additions & 0 deletions crates/omachat-proto/tests/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
13 changes: 13 additions & 0 deletions crates/omachat-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
};
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions crates/omachatd/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -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<serde_json::Value, CoreError> {
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,
Expand Down
126 changes: 118 additions & 8 deletions crates/omachatd/src/room_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ enum RoomCommand {
Describe {
reply: oneshot::Sender<Value>,
},
Members {
group_id: String,
reply: oneshot::Sender<Result<Value, RoomError>>,
},
}

/// One configured relay as seen by the core.
Expand Down Expand Up @@ -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<Value, RoomError> {
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.
Expand Down Expand Up @@ -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 } => {
Expand Down Expand Up @@ -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));
}
}
}

Expand Down Expand Up @@ -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()),
Expand All @@ -1244,6 +1273,85 @@ fn room_summary(
})
}

fn room_members(url: &str, state: &RelayRoomState, group_id: &str) -> Result<Value, RoomError> {
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::<Vec<_>>();
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,
Expand Down Expand Up @@ -1312,6 +1420,7 @@ pub enum RoomError {
Unavailable { status: String, detail: Value },
NotJoined,
InvalidGroup,
UnknownRoom,
InvalidEvent,
Subscription(String),
Publish(String),
Expand All @@ -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) => {
Expand Down
31 changes: 31 additions & 0 deletions crates/omachatd/tests/rooms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packaging/completions/_omachat-ctl
Original file line number Diff line number Diff line change
@@ -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)' ;;
Expand Down
Loading
Loading