feat(cli): expose sharing, peer lookup and settings as one-shot commands

An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.

New commands:

  serve [--follow]                stay online sharing; one record per upload
  whoami                          credentials, connection and what we offer
  user <NAME>                     a peer's presence and share counts
  room users <ROOM>               the member roster
  shares list|add|remove|status|reindex
  config path|list|get|set

`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.

Library additions, all request/poll pairs matching the existing conventions:

- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
  GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
  logged as unhandled. The wire format is taken from soulfind's own source
  and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
  arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
  observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
  message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
This commit is contained in:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent be6581ee6c
commit 03e8924a6b
21 changed files with 2187 additions and 19 deletions
+53 -1
View File
@@ -13,11 +13,13 @@ use crate::message::server::MessageUser;
use crate::message::server::ParentMinSpeedHandler;
use crate::message::server::ParentSpeedRatioHandler;
use crate::message::server::PrivilegedUsersHandler;
use crate::message::server::RoomListHandler;
use crate::message::server::SayChatroomHandler;
use crate::message::server::UserJoinedRoomHandler;
use crate::message::server::UserLeftRoomHandler;
use crate::message::server::WishListIntervalHandler;
use crate::message::server::{
GetUserStatsHandler, GetUserStatusHandler, RoomListHandler,
};
use crate::message::{Handlers, MessageType};
use crate::message::{Message, MessageReader};
use crate::peer::ConnectionType;
@@ -141,7 +143,10 @@ impl UserMessage {
}
}
/// The server actor's mailbox. Marked non-exhaustive: each protocol message
/// the client learns adds a variant, and that must not break callers.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ServerMessage {
ProcessRead,
LoginStatus(bool),
@@ -175,6 +180,19 @@ pub enum ServerMessage {
},
PrivateMessageReceived(UserMessage),
RoomListReceived(Vec<RoomInfo>),
/// A `GetUserStatus` (code 7) reply.
UserStatusReceived {
username: String,
status: u32,
privileged: bool,
},
/// A `GetUserStats` (code 36) reply.
UserStatsReceived {
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
},
RoomJoined {
room: String,
users: Vec<String>,
@@ -354,6 +372,8 @@ impl ServerActor {
handlers.register_handler(LoginHandler);
handlers.register_handler(RoomListHandler);
handlers.register_handler(GetUserStatusHandler);
handlers.register_handler(GetUserStatsHandler);
handlers.register_handler(JoinRoomHandler);
handlers.register_handler(LeaveRoomHandler);
handlers.register_handler(SayChatroomHandler);
@@ -452,6 +472,30 @@ impl ServerActor {
ServerMessage::RoomListReceived(rooms) => {
self.forward_room_event(RoomEvent::List(rooms));
}
ServerMessage::UserStatusReceived {
username,
status,
privileged,
} => {
self.forward_to_client(ClientOperation::UserStatusReceived {
username,
status,
privileged,
});
}
ServerMessage::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::RoomJoined { room, users } => {
self.forward_room_event(RoomEvent::Joined { room, users });
}
@@ -566,6 +610,14 @@ impl ServerActor {
}
}
/// Hand an operation to the client loop, logging a dead channel rather
/// than unwinding the actor.
fn forward_to_client(&self, operation: ClientOperation) {
if let Err(e) = self.client_channel.send(operation) {
error!("[server] Error forwarding to client: {}", e);
}
}
fn handle_private_message_received(&self, user_message: UserMessage) {
debug!("[server] Private message from {}", user_message.username());
if let Err(e) = self