feat(daemon): expose watching over the RPC surface and the CLI
The watch subscription was reachable only from the library. It is most useful against a daemon -- that is the session that stays connected long enough to receive the server's status pushes -- so it needs the wire. - proto: user.watch / user.unwatch / user.watched, with a Watched response - SessionApi gains watch_user / unwatch_user / watched_users, so a local and a remote session are still interchangeable above the seam - CLI: `watch add`, `watch remove`, `watch list`, documented in SKILL.md including the caveat that a one-shot run's watch ends with the run - docs/openrpc.json regenerated from the wire types
This commit is contained in:
@@ -15,6 +15,7 @@ use super::proto::{
|
||||
Method, QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults,
|
||||
Searches, Seconds, SessionLossEvent, SharesStatus, SlotsParams,
|
||||
TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, UserResult,
|
||||
Watched,
|
||||
};
|
||||
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
|
||||
use serde_json::{Value, json};
|
||||
@@ -51,9 +52,11 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
}
|
||||
Method::RoomSay => schema_of::<SayParams>(generator),
|
||||
Method::MessageSend => schema_of::<MessageParams>(generator),
|
||||
Method::BrowseUser | Method::UserRequest | Method::UserInfoOf => {
|
||||
schema_of::<UserRef>(generator)
|
||||
}
|
||||
Method::BrowseUser
|
||||
| Method::UserRequest
|
||||
| Method::UserInfoOf
|
||||
| Method::UserWatch
|
||||
| Method::UserUnwatch => schema_of::<UserRef>(generator),
|
||||
Method::SharesSet => schema_of::<DirectoriesParams>(generator),
|
||||
// Methods that take no parameters.
|
||||
Method::DaemonStatus
|
||||
@@ -68,7 +71,8 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
| Method::MessageHistory
|
||||
| Method::SearchList
|
||||
| Method::SharesStatusOf
|
||||
| Method::SharesReindex => json!({ "type": "null" }),
|
||||
| Method::SharesReindex
|
||||
| Method::UserWatched => json!({ "type": "null" }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +92,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
Method::UploadList => schema_of::<Uploads>(generator),
|
||||
Method::PrivilegesOwn => schema_of::<Seconds>(generator),
|
||||
Method::RoomMembers => schema_of::<Members>(generator),
|
||||
Method::UserWatched => schema_of::<Watched>(generator),
|
||||
Method::MessageHistory => schema_of::<Messages>(generator),
|
||||
Method::UserInfoOf => schema_of::<UserResult>(generator),
|
||||
Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => {
|
||||
@@ -112,7 +117,9 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
| Method::MessageSend
|
||||
| Method::BrowseUser
|
||||
| Method::SearchForget
|
||||
| Method::UserRequest => schema_of::<Ack>(generator),
|
||||
| Method::UserRequest
|
||||
| Method::UserWatch
|
||||
| Method::UserUnwatch => schema_of::<Ack>(generator),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +179,11 @@ fn summary(method: Method) -> &'static str {
|
||||
Method::RoomLeave => "Leave a chat room.",
|
||||
Method::RoomSay => "Post a message to a room.",
|
||||
Method::RoomMembers => "Who is currently in a room.",
|
||||
Method::UserWatch => {
|
||||
"Watch a user, so the server keeps pushing their status."
|
||||
}
|
||||
Method::UserUnwatch => "Stop watching a user.",
|
||||
Method::UserWatched => "Everyone this session is watching.",
|
||||
Method::MessageSend => "Send a private message.",
|
||||
Method::MessageHistory => {
|
||||
"Private messages the daemon collected, including while nobody was attached."
|
||||
|
||||
@@ -139,6 +139,13 @@ pub struct Members {
|
||||
pub users: Vec<String>,
|
||||
}
|
||||
|
||||
/// Everyone this session is watching, sorted by name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct Watched {
|
||||
pub users: Vec<String>,
|
||||
}
|
||||
|
||||
/// One line of chat history. Unlike a live [`UserMessageDto`] this carries a
|
||||
/// direction, because a conversation the daemon collected includes what this
|
||||
/// account sent as well as what it received.
|
||||
|
||||
@@ -106,6 +106,9 @@ wire_vocabulary! {
|
||||
BrowseUser => "browse.user",
|
||||
UserRequest => "user.request",
|
||||
UserInfoOf => "user.info",
|
||||
UserWatch => "user.watch",
|
||||
UserUnwatch => "user.unwatch",
|
||||
UserWatched => "user.watched",
|
||||
SharesStatusOf => "shares.status",
|
||||
SharesSet => "shares.set",
|
||||
SharesReindex => "shares.reindex",
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::proto::{
|
||||
MessageParams, Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams,
|
||||
RoomRef, RpcError, SayParams, SearchResultDto, SearchResults,
|
||||
SearchSummary, Searches, Seconds, SharesStatus, SlotsParams, TransferRef,
|
||||
UploadInfoDto, Uploads, UserInfoDto, UserRef, UserResult,
|
||||
UploadInfoDto, Uploads, UserInfoDto, UserRef, UserResult, Watched,
|
||||
};
|
||||
use crate::api::SessionApi;
|
||||
use crate::output::Exit;
|
||||
@@ -306,6 +306,24 @@ impl Daemon {
|
||||
})
|
||||
}
|
||||
|
||||
Method::UserWatch => {
|
||||
let user: UserRef = parse(params)?;
|
||||
self.session.watch_user(&user.username).map_err(|e| {
|
||||
RpcError::application(Exit::Connection, e.to_string())
|
||||
})?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::UserUnwatch => {
|
||||
let user: UserRef = parse(params)?;
|
||||
self.session.unwatch_user(&user.username).map_err(|e| {
|
||||
RpcError::application(Exit::Connection, e.to_string())
|
||||
})?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::UserWatched => ok(Watched {
|
||||
users: self.session.watched_users(),
|
||||
}),
|
||||
|
||||
Method::SharesStatusOf => ok(self.shares()),
|
||||
Method::SharesSet => {
|
||||
let directories: DirectoriesParams = parse(params)?;
|
||||
@@ -593,6 +611,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_watch_is_readable_back_and_survives_until_unwatched() {
|
||||
// The three methods are only useful together: what `user.watch`
|
||||
// records, `user.watched` must report, and `user.unwatch` must undo.
|
||||
let daemon = daemon_with_token("t");
|
||||
let user = |name: &str| json!({ "username": name });
|
||||
|
||||
daemon
|
||||
.call(Method::UserWatch, user("alice"))
|
||||
.expect("watch");
|
||||
daemon.call(Method::UserWatch, user("bob")).expect("watch");
|
||||
let watched = daemon
|
||||
.call(Method::UserWatched, Value::Null)
|
||||
.expect("watched");
|
||||
assert_eq!(watched["users"], json!(["alice", "bob"]));
|
||||
|
||||
daemon
|
||||
.call(Method::UserUnwatch, user("alice"))
|
||||
.expect("unwatch");
|
||||
let watched = daemon
|
||||
.call(Method::UserWatched, Value::Null)
|
||||
.expect("watched");
|
||||
assert_eq!(
|
||||
watched["users"],
|
||||
json!(["bob"]),
|
||||
"unwatching one must not clear the rest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watching_the_same_user_twice_lists_them_once() {
|
||||
let daemon = daemon_with_token("t");
|
||||
let alice = json!({ "username": "alice" });
|
||||
daemon
|
||||
.call(Method::UserWatch, alice.clone())
|
||||
.expect("watch");
|
||||
daemon.call(Method::UserWatch, alice).expect("watch");
|
||||
let watched = daemon
|
||||
.call(Method::UserWatched, Value::Null)
|
||||
.expect("watched");
|
||||
assert_eq!(watched["users"], json!(["alice"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_is_recorded_so_the_daemon_can_wind_down() {
|
||||
let daemon = daemon_with_token("t");
|
||||
@@ -605,11 +666,17 @@ mod tests {
|
||||
|
||||
// A session that answers nothing: enough to exercise dispatch, parameter
|
||||
// validation, and the auth gate without a Soulseek server.
|
||||
struct SilentSession;
|
||||
/// A session that answers nothing, except that it remembers watches:
|
||||
/// the watch methods are only meaningful if what one call records the
|
||||
/// next call can read back.
|
||||
#[derive(Default)]
|
||||
struct SilentSession {
|
||||
watched: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
fn daemon_with_token(token: &str) -> Daemon {
|
||||
Daemon {
|
||||
session: Arc::new(SilentSession),
|
||||
session: Arc::new(SilentSession::default()),
|
||||
hub: Arc::new(Hub::new()),
|
||||
browses: Arc::new(PendingBrowses::default()),
|
||||
downloads: Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -792,6 +859,24 @@ mod tests {
|
||||
) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn watch_user(&self, username: &str) -> soulseek_rs::Result<()> {
|
||||
let mut watched = self.watched.lock().expect("watch list");
|
||||
if !watched.iter().any(|u| u == username) {
|
||||
watched.push(username.to_string());
|
||||
watched.sort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn unwatch_user(&self, username: &str) -> soulseek_rs::Result<()> {
|
||||
self.watched
|
||||
.lock()
|
||||
.expect("watch list")
|
||||
.retain(|u| u != username);
|
||||
Ok(())
|
||||
}
|
||||
fn watched_users(&self) -> Vec<String> {
|
||||
self.watched.lock().expect("watch list").clone()
|
||||
}
|
||||
fn user_info(&self, _username: &str) -> Option<soulseek_rs::UserInfo> {
|
||||
None
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user